diff --git a/redbot/cogs/audio/apis/interface.py b/redbot/cogs/audio/apis/interface.py index 7798b4bac..f5b84c6df 100644 --- a/redbot/cogs/audio/apis/interface.py +++ b/redbot/cogs/audio/apis/interface.py @@ -211,6 +211,7 @@ class AudioAPIInterface: time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level) youtube_api_error = None + global_api = self.cog.global_api_user.get("can_read") async for track in AsyncIter(tracks): if isinstance(track, str): break @@ -267,13 +268,15 @@ class AudioAPIInterface: track_count += 1 if notifier is not None and ((track_count % 2 == 0) or (track_count == total_tracks)): await notifier.notify_user(current=track_count, total=total_tracks, key="youtube") - if notifier is not None and youtube_api_error: + if notifier is not None and (youtube_api_error and not global_api): error_embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Failing to get tracks, skipping remaining."), ) await notifier.update_embed(error_embed) break + elif notifier is not None and (youtube_api_error and global_api): + continue if CacheLevel.set_spotify().is_subset(current_cache_level): task = ("insert", ("spotify", database_entries)) self.append_task(ctx, *task) @@ -447,11 +450,12 @@ class AudioAPIInterface: List of Youtube URLs. """ await self.global_cache_api._get_api_key() - globaldb_toggle = await self.config.global_db_enabled() + globaldb_toggle = self.cog.global_api_user.get("can_read") global_entry = globaldb_toggle and query_global track_list: List = [] has_not_allowed = False youtube_api_error = None + skip_youtube_api = False try: current_cache_level = CacheLevel(await self.config.cache_level()) guild_data = await self.config.guild(ctx.guild).all() @@ -518,7 +522,7 @@ class AudioAPIInterface: llresponse["loadType"] = "V2_COMPAT" llresponse = LoadResult(llresponse) val = llresponse or None - if val is None: + if val is None and not skip_youtube_api: try: val = await self.fetch_youtube_query( ctx, track_info, current_cache_level=current_cache_level @@ -526,6 +530,7 @@ class AudioAPIInterface: except YouTubeApiError as err: val = None youtube_api_error = err.message + skip_youtube_api = True if not youtube_api_error: if youtube_cache and val and llresponse is None: task = ("update", ("youtube", {"track": track_info})) @@ -589,7 +594,9 @@ class AudioAPIInterface: seconds=seconds, ) - if youtube_api_error or consecutive_fails >= (20 if global_entry else 10): + if (youtube_api_error and not global_entry) or consecutive_fails >= ( + 20 if global_entry else 10 + ): error_embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Failing to get tracks, skipping remaining."), @@ -793,7 +800,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() + globaldb_toggle = self.cog.global_api_user.get("can_read") valid_global_entry = False results = None called_api = False @@ -925,6 +932,7 @@ class AudioAPIInterface: 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) + notify_channel_id = player.fetch("channel") playlist = None tracks = None if autoplaylist["enabled"]: @@ -973,7 +981,7 @@ class AudioAPIInterface: and not query.local_track_path.exists() ): continue - notify_channel = self.bot.get_channel(player.fetch("channel")) + notify_channel = self.bot.get_channel(notify_channel_id) if not await self.cog.is_query_allowed( self.config, notify_channel, @@ -997,8 +1005,20 @@ class AudioAPIInterface: ) player.add(player.channel.guild.me, track) self.bot.dispatch( - "red_audio_track_auto_play", player.channel.guild, track, player.channel.guild.me + "red_audio_track_auto_play", + player.channel.guild, + track, + player.channel.guild.me, + player, ) + if notify_channel_id: + await self.config.guild_from_id( + guild_id=player.channel.guild.id + ).currently_auto_playing_in.set([notify_channel_id, player.channel.id]) + else: + await self.config.guild_from_id( + guild_id=player.channel.guild.id + ).currently_auto_playing_in.set([]) if not player.current: await player.play() diff --git a/redbot/cogs/audio/apis/playlist_interface.py b/redbot/cogs/audio/apis/playlist_interface.py index a21d1517b..e33eb93b7 100644 --- a/redbot/cogs/audio/apis/playlist_interface.py +++ b/redbot/cogs/audio/apis/playlist_interface.py @@ -235,7 +235,7 @@ class PlaylistCompat23: Trying to access the User scope without an user id. """ guild = data.get("guild") or kwargs.get("guild") - author: int = data.get("author") or 0 + author: int = data.get("author") or kwargs.get("author") or 0 playlist_id = data.get("id") or playlist_number name = data.get("name", "Unnamed") playlist_url = data.get("playlist_url", None) diff --git a/redbot/cogs/audio/core/__init__.py b/redbot/cogs/audio/core/__init__.py index 1ecfc3a56..184cddb26 100644 --- a/redbot/cogs/audio/core/__init__.py +++ b/redbot/cogs/audio/core/__init__.py @@ -2,7 +2,7 @@ import asyncio import datetime import json -from collections import Counter +from collections import Counter, defaultdict from pathlib import Path from typing import Mapping @@ -77,6 +77,9 @@ class Audio( self.session = aiohttp.ClientSession(json_serialize=json.dumps) self.cog_ready_event = asyncio.Event() + self._ws_resume = defaultdict(asyncio.Event) + self._ws_op_codes = defaultdict(asyncio.LifoQueue) + self.cog_init_task = None self.global_api_user = { "fetched": False, @@ -85,10 +88,12 @@ class Audio( "can_delete": False, } self._ll_guild_updates = set() + self._diconnected_shard = set() self._last_ll_update = datetime.datetime.now(datetime.timezone.utc) default_global = dict( schema_version=1, + bundled_playlist_version=0, owner_notification=0, cache_level=0, cache_age=365, @@ -107,8 +112,14 @@ class Audio( default_guild = dict( auto_play=False, + currently_auto_playing_in=None, auto_deafen=True, - autoplaylist={"enabled": False, "id": None, "name": None, "scope": None}, + autoplaylist=dict( + enabled=True, + id=42069, + name="Aikaterna's curated tracks", + scope=PlaylistScope.GLOBAL.value, + ), persist_queue=True, disconnect=False, dj_enabled=False, diff --git a/redbot/cogs/audio/core/abc.py b/redbot/cogs/audio/core/abc.py index 7535f7c99..9c423127b 100644 --- a/redbot/cogs/audio/core/abc.py +++ b/redbot/cogs/audio/core/abc.py @@ -4,7 +4,7 @@ import asyncio import datetime from abc import ABC, abstractmethod -from collections import Counter +from collections import Counter, defaultdict from pathlib import Path from typing import Set, TYPE_CHECKING, Any, List, Mapping, MutableMapping, Optional, Tuple, Union @@ -41,7 +41,7 @@ class MixinMeta(ABC): db_conn: Optional[APSWConnectionWrapper] session: aiohttp.ClientSession - skip_votes: MutableMapping[int, List[int]] + skip_votes: MutableMapping[int, Set[int]] play_lock: MutableMapping[int, bool] _daily_playlist_cache: MutableMapping[int, bool] _daily_global_playlist_cache: MutableMapping[int, bool] @@ -62,12 +62,14 @@ class MixinMeta(ABC): player_automated_timer_task: Optional[asyncio.Task] cog_init_task: Optional[asyncio.Task] cog_ready_event: asyncio.Event - + _ws_resume: defaultdict[Any, asyncio.Event] + _ws_op_codes: defaultdict[int, asyncio.LifoQueue] _default_lavalink_settings: Mapping permission_cache = discord.Permissions _last_ll_update: datetime.datetime _ll_guild_updates: Set[int] + _diconnected_shard: Set[int] @abstractmethod async def command_llsetup(self, ctx: commands.Context): @@ -306,6 +308,14 @@ class MixinMeta(ABC): async def _playlist_check(self, ctx: commands.Context) -> bool: raise NotImplementedError() + @abstractmethod + async def _build_bundled_playlist(self, forced: bool = None) -> None: + raise NotImplementedError() + + @abstractmethod + def decode_track(self, track: str, decode_errors: str = "") -> MutableMapping: + raise NotImplementedError() + @abstractmethod async def can_manage_playlist( self, scope: str, playlist: "Playlist", ctx: commands.Context, user, guild diff --git a/redbot/cogs/audio/core/cog_utils.py b/redbot/cogs/audio/core/cog_utils.py index d40672591..f8099d431 100644 --- a/redbot/cogs/audio/core/cog_utils.py +++ b/redbot/cogs/audio/core/cog_utils.py @@ -1,12 +1,15 @@ from abc import ABC from typing import Final +from base64 import b64decode +from io import BytesIO +import struct from redbot import VersionInfo from redbot.core import commands from ..converters import get_lazy_converter, get_playlist_converter -__version__ = VersionInfo.from_json({"major": 2, "minor": 3, "micro": 0, "releaselevel": "final"}) +__version__ = VersionInfo.from_json({"major": 2, "minor": 4, "micro": 0, "releaselevel": "final"}) __author__ = ["aikaterna", "Draper"] @@ -57,3 +60,90 @@ class CompositeMetaClass(type(commands.Cog), type(ABC)): """ pass + + +# Both DataReader and DataWriter are taken from https://github.com/Devoxin/Lavalink.py/blob/master/lavalink/datarw.py +# These are licenced under MIT, Thanks Devoxin for putting these together! +# The license can be found in https://github.com/Devoxin/Lavalink.py/blob/master/LICENSE + + +class DataReader: + def __init__(self, ts): + self._buf = BytesIO(b64decode(ts)) + + def _read(self, n): + return self._buf.read(n) + + def read_byte(self): + return self._read(1) + + def read_boolean(self): + (result,) = struct.unpack("B", self.read_byte()) + return result != 0 + + def read_unsigned_short(self): + (result,) = struct.unpack(">H", self._read(2)) + return result + + def read_int(self): + (result,) = struct.unpack(">i", self._read(4)) + return result + + def read_long(self): + (result,) = struct.unpack(">Q", self._read(8)) + return result + + def read_utf(self): + text_length = self.read_unsigned_short() + return self._read(text_length) + + +class DataWriter: + def __init__(self): + self._buf = BytesIO() + + def _write(self, data): + self._buf.write(data) + + def write_byte(self, byte): + self._buf.write(byte) + + def write_boolean(self, b): + enc = struct.pack("B", 1 if b else 0) + self.write_byte(enc) + + def write_unsigned_short(self, s): + enc = struct.pack(">H", s) + self._write(enc) + + def write_int(self, i): + enc = struct.pack(">i", i) + self._write(enc) + + def write_long(self, l): + enc = struct.pack(">Q", l) + self._write(enc) + + def write_utf(self, s): + utf = s.encode("utf8") + byte_len = len(utf) + + if byte_len > 65535: + raise OverflowError("UTF string may not exceed 65535 bytes!") + + self.write_unsigned_short(byte_len) + self._write(utf) + + def finish(self): + with BytesIO() as track_buf: + byte_len = self._buf.getbuffer().nbytes + flags = byte_len | (1 << 30) + enc_flags = struct.pack(">i", flags) + track_buf.write(enc_flags) + + self._buf.seek(0) + track_buf.write(self._buf.read()) + self._buf.close() + + track_buf.seek(0) + return track_buf.read() diff --git a/redbot/cogs/audio/core/commands/audioset.py b/redbot/cogs/audio/core/commands/audioset.py index 830f83b94..d99110f7b 100644 --- a/redbot/cogs/audio/core/commands/audioset.py +++ b/redbot/cogs/audio/core/commands/audioset.py @@ -558,7 +558,13 @@ class AudioSetCommands(MixinMeta, metaclass=CompositeMetaClass): @command_audioset_autoplay.command(name="reset") async def command_audioset_autoplay_reset(self, ctx: commands.Context): """Resets auto-play to the default playlist.""" - playlist_data = dict(enabled=False, id=None, name=None, scope=None) + playlist_data = dict( + enabled=True, + id=42069, + name="Aikaterna's curated tracks", + scope=PlaylistScope.GLOBAL.value, + ) + await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) return await self.send_embed_msg( ctx, diff --git a/redbot/cogs/audio/core/commands/controller.py b/redbot/cogs/audio/core/commands/controller.py index 1181a533e..84a60c0ad 100644 --- a/redbot/cogs/audio/core/commands/controller.py +++ b/redbot/cogs/audio/core/commands/controller.py @@ -68,10 +68,14 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): eq = player.fetch("eq") player.queue = [] player.store("playing_song", None) + player.store("autoplay_notified", False) if eq: await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands) await player.stop() await player.disconnect() + await self.config.guild_from_id(guild_id=ctx.guild.id).currently_auto_playing_in.set( + [] + ) self._ll_guild_updates.discard(ctx.guild.id) await self.api_interface.persistent_queue_api.drop(ctx.guild.id) @@ -91,6 +95,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): } expected = tuple(emoji.values()) player = lavalink.get_player(ctx.guild.id) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if player.current: arrow = await self.draw_time(ctx) pos = self.format_time(player.position) @@ -212,7 +218,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Manage Tracks"), description=_("You need the DJ role to pause or resume tracks."), ) - + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if not player.current: return await self.send_embed_msg(ctx, title=_("Nothing playing.")) description = await self.get_track_description( @@ -266,7 +273,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): "to enqueue the previous song tracks." ), ) - + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if player.fetch("prev_song") is None: return await self.send_embed_msg( ctx, title=_("Unable To Play Tracks"), description=_("No previous track.") @@ -332,7 +340,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Seek Tracks"), description=_("You need the DJ role or be the track requester to use seek."), ) - + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if player.current: if player.current.is_stream: return await self.send_embed_msg( @@ -405,6 +414,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Toggle Shuffle"), description=_("You must be in the voice channel to toggle shuffle."), ) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) shuffle = await self.config.guild(ctx.guild).shuffle() await self.config.guild(ctx.guild).shuffle.set(not shuffle) @@ -448,6 +459,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Toggle Shuffle"), description=_("You must be in the voice channel to toggle shuffle."), ) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) bumped = await self.config.guild(ctx.guild).shuffle_bumped() await self.config.guild(ctx.guild).shuffle_bumped.set(not bumped) @@ -504,7 +517,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Skip Tracks"), description=_("You can only skip the current track."), ) - + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if vote_enabled: if not can_skip: if skip_to_track is not None: @@ -516,10 +530,10 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): ), ) if ctx.author.id in self.skip_votes[ctx.guild.id]: - self.skip_votes[ctx.guild.id].remove(ctx.author.id) + self.skip_votes[ctx.guild.id].discard(ctx.author.id) reply = _("I removed your vote to skip.") else: - self.skip_votes[ctx.guild.id].append(ctx.author.id) + self.skip_votes[ctx.guild.id].add(ctx.author.id) reply = _("You voted to skip.") num_votes = len(self.skip_votes[ctx.guild.id]) @@ -532,7 +546,7 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): vote = int(100 * num_votes / num_members) percent = await self.config.guild(ctx.guild).vote_percent() if vote >= percent: - self.skip_votes[ctx.guild.id] = [] + self.skip_votes[ctx.guild.id] = set() await self.send_embed_msg(ctx, title=_("Vote threshold met.")) return await self._skip_action(ctx) else: @@ -583,6 +597,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Stop Player"), description=_("You need the DJ role to stop the music."), ) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if ( player.is_playing or (not player.is_playing and player.paused) @@ -597,7 +613,11 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): player.store("prev_requester", None) player.store("prev_song", None) player.store("requester", None) + player.store("autoplay_notified", False) await player.stop() + await self.config.guild_from_id(guild_id=ctx.guild.id).currently_auto_playing_in.set( + [] + ) await self.send_embed_msg(ctx, title=_("Stopping...")) await self.api_interface.persistent_queue_api.drop(ctx.guild.id) @@ -642,17 +662,28 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): description=_("I don't have permission to connect to your channel."), ) if not self._player_check(ctx): - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) else: player = lavalink.get_player(ctx.guild.id) - if ctx.author.voice.channel == player.channel: + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) + if ( + ctx.author.voice.channel == player.channel + and ctx.guild.me in ctx.author.voice.channel.members + ): ctx.command.reset_cooldown(ctx) return - await player.move_to(ctx.author.voice.channel) - await self.self_deafen(player) + await player.move_to( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) except AttributeError: ctx.command.reset_cooldown(ctx) return await self.send_embed_msg( @@ -693,23 +724,33 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Change Volume"), description=_("You must be in the voice channel to change the volume."), ) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if dj_enabled and not can_skip and not await self._has_dj_role(ctx, ctx.author): return await self.send_embed_msg( ctx, title=_("Unable To Change Volume"), description=_("You need the DJ role to change the volume."), ) + if vol < 0: vol = 0 if vol > 150: vol = 150 await self.config.guild(ctx.guild).volume.set(vol) if self._player_check(ctx): - await lavalink.get_player(ctx.guild.id).set_volume(vol) + player = lavalink.get_player(ctx.guild.id) + await player.set_volume(vol) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) else: await self.config.guild(ctx.guild).volume.set(vol) if self._player_check(ctx): - await lavalink.get_player(ctx.guild.id).set_volume(vol) + player = lavalink.get_player(ctx.guild.id) + await player.set_volume(vol) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) + embed = discord.Embed(title=_("Volume:"), description=str(vol) + "%") if not self._player_check(ctx): embed.set_footer(text=_("Nothing playing.")) @@ -741,6 +782,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Toggle Repeat"), description=_("You must be in the voice channel to toggle repeat."), ) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) autoplay = await self.config.guild(ctx.guild).auto_play() repeat = await self.config.guild(ctx.guild).repeat() @@ -784,6 +827,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Modify Queue"), description=_("You must be in the voice channel to manage the queue."), ) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) if isinstance(index_or_url, int): if index_or_url > len(player.queue) or index_or_url < 1: return await self.send_embed_msg( @@ -864,7 +909,8 @@ class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Bump Track"), description=_("Song number must be greater than 1 and within the queue limit."), ) - + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) bump_index = index - 1 bump_song = player.queue[bump_index] bump_song.extras["bumped"] = True diff --git a/redbot/cogs/audio/core/commands/equalizer.py b/redbot/cogs/audio/core/commands/equalizer.py index 461b07a9e..53b6521e5 100644 --- a/redbot/cogs/audio/core/commands/equalizer.py +++ b/redbot/cogs/audio/core/commands/equalizer.py @@ -173,7 +173,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Load Preset"), description=_("You need the DJ role to load equalizer presets."), ) - + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq_values) await self._eq_check(ctx, player) eq = player.fetch("eq", Equalizer()) @@ -202,6 +203,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass): description=_("You need the DJ role to reset the equalizer."), ) player = lavalink.get_player(ctx.guild.id) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) eq = player.fetch("eq", Equalizer()) for band in range(eq.band_count): @@ -284,6 +287,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass): return await eq_exists_msg.edit(embed=embed2) player = lavalink.get_player(ctx.guild.id) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) eq = player.fetch("eq", Equalizer()) to_append = {eq_preset: {"author": ctx.author.id, "bands": eq.bands}} new_eq_presets = {**eq_presets, **to_append} @@ -325,6 +330,8 @@ class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass): ) player = lavalink.get_player(ctx.guild.id) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) band_names = [ "25", "40", diff --git a/redbot/cogs/audio/core/commands/llset.py b/redbot/cogs/audio/core/commands/llset.py index 74706807b..9a2fb6dcc 100644 --- a/redbot/cogs/audio/core/commands/llset.py +++ b/redbot/cogs/audio/core/commands/llset.py @@ -223,7 +223,7 @@ class LavalinkSetupCommands(MixinMeta, metaclass=CompositeMetaClass): msg = "----" + _("Connection Settings") + "---- \n" msg += _("Host: [{host}]\n").format(host=host) msg += _("WS Port: [{port}]\n").format(port=ws_port) - if ws_port != rest_port: + if ws_port != rest_port and rest_port != 2333: msg += _("Rest Port: [{port}]\n").format(port=rest_port) msg += _("Password: [{password}]\n").format(password=password) try: diff --git a/redbot/cogs/audio/core/commands/player.py b/redbot/cogs/audio/core/commands/player.py index 68ce87768..ed6593893 100644 --- a/redbot/cogs/audio/core/commands/player.py +++ b/redbot/cogs/audio/core/commands/player.py @@ -78,10 +78,14 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Play Tracks"), description=_("I don't have permission to connect to your channel."), ) - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except AttributeError: return await self.send_embed_msg( ctx, @@ -185,10 +189,14 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Play Tracks"), description=_("I don't have permission to connect to your channel."), ) - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except AttributeError: return await self.send_embed_msg( ctx, @@ -450,10 +458,14 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Play Tracks"), description=_("I don't have permission to connect to your channel."), ) - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except AttributeError: return await self.send_embed_msg( ctx, @@ -566,10 +578,14 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Play Tracks"), description=_("I don't have permission to connect to your channel."), ) - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except AttributeError: return await self.send_embed_msg( ctx, @@ -626,10 +642,8 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): if not guild_data["auto_play"]: await ctx.invoke(self.command_audioset_autoplay_toggle) - if not guild_data["notify"] and ( - (player.current and not player.current.extras.get("autoplay")) or not player.current - ): - await self.send_embed_msg(ctx, title=_("Auto play started.")) + if not guild_data["notify"] and not player.fetch("autoplay_notified", False): + pass elif player.current: await self.send_embed_msg(ctx, title=_("Adding a track to queue.")) @@ -692,10 +706,14 @@ class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Search For Tracks"), description=_("I don't have permission to connect to your channel."), ) - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except AttributeError: return await self.send_embed_msg( ctx, diff --git a/redbot/cogs/audio/core/commands/playlists.py b/redbot/cogs/audio/core/commands/playlists.py index 758fb9e5b..52bb0a799 100644 --- a/redbot/cogs/audio/core/commands/playlists.py +++ b/redbot/cogs/audio/core/commands/playlists.py @@ -1678,7 +1678,7 @@ class PlaylistCommands(MixinMeta, metaclass=CompositeMetaClass): try: if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): return - if playlist.url: + if playlist.url or playlist.id == 42069: player = lavalink.get_player(ctx.guild.id) added, removed, playlist = await self._maybe_update_playlist( ctx, player, playlist diff --git a/redbot/cogs/audio/core/commands/queue.py b/redbot/cogs/audio/core/commands/queue.py index fe8937260..be96636ec 100644 --- a/redbot/cogs/audio/core/commands/queue.py +++ b/redbot/cogs/audio/core/commands/queue.py @@ -338,10 +338,14 @@ class QueueCommands(MixinMeta, metaclass=CompositeMetaClass): title=_("Unable To Shuffle Queue"), description=_("I don't have permission to connect to your channel."), ) - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) 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 682f59e1e..391a41aed 100644 --- a/redbot/cogs/audio/core/events/cog.py +++ b/redbot/cogs/audio/core/events/cog.py @@ -32,8 +32,10 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): if await self.bot.cog_disabled_in_guild(self, guild): player = lavalink.get_player(guild.id) + player.store("autoplay_notified", False) await player.stop() await player.disconnect() + await self.config.guild_from_id(guild_id=guild.id).currently_auto_playing_in.set([]) return track_identifier = track.track_identifier @@ -157,7 +159,9 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): 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): + async def on_red_audio_track_enqueue( + self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member + ): if not (track and guild): return persist_cache = self._persist_queue_cache.setdefault( @@ -181,3 +185,28 @@ class AudioEvents(MixinMeta, metaclass=CompositeMetaClass): 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_auto_play( + self, + guild: discord.Guild, + track: lavalink.Track, + requester: discord.Member, + player: lavalink.Player, + ): + notify_channel = self.bot.get_channel(player.fetch("channel")) + tries = 0 + while not player._is_playing: + await asyncio.sleep(0.1) + if tries > 1000: + return + + if notify_channel and not player.fetch("autoplay_notified", False): + if ( + len(player.manager.players) < 10 + or not player._last_resume + and player._last_resume + datetime.timedelta(seconds=60) + > datetime.datetime.now(tz=datetime.timezone.utc) + ): + await self.send_embed_msg(notify_channel, title=_("Auto Play started.")) + player.store("autoplay_notified", True) diff --git a/redbot/cogs/audio/core/events/dpy.py b/redbot/cogs/audio/core/events/dpy.py index c0aa2d4c6..671676fe8 100644 --- a/redbot/cogs/audio/core/events/dpy.py +++ b/redbot/cogs/audio/core/events/dpy.py @@ -244,7 +244,7 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): lavalink.unregister_event_listener(self.lavalink_event_handler) lavalink.unregister_update_listener(self.lavalink_update_handler) - self.bot.loop.create_task(lavalink.close()) + self.bot.loop.create_task(lavalink.close(self.bot)) if self.player_manager is not None: self.bot.loop.create_task(self.player_manager.shutdown()) @@ -259,12 +259,31 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): await self.cog_ready_event.wait() if after.channel != before.channel: try: - self.skip_votes[before.channel.guild.id].remove(member.id) + self.skip_votes[before.channel.guild.id].discard(member.id) except (ValueError, KeyError, AttributeError): pass + + # if ( + # member == member.guild.me + # and before.channel + # and after.channel + # and after.channel.id != before.channel.id + # ): + # try: + # player = lavalink.get_player(member.guild.id) + # if player.is_playing: + # await player.resume(player.current, start=player.position, replace=False) + # log.debug("Bot changed channel - Resume playback") + # except: + # log.debug("Bot changed channel - Unable to resume playback") + 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: + if ( + channel + and bot_voice_state is False + and await self.config.guild(member.guild).auto_deafen() + ): try: player = lavalink.get_player(channel.guild.id) except (KeyError, AttributeError): @@ -272,3 +291,15 @@ class DpyEvents(MixinMeta, metaclass=CompositeMetaClass): else: if player.channel.id == channel.id: await self.self_deafen(player) + + @commands.Cog.listener() + async def on_shard_disconnect(self, shard_id): + self._diconnected_shard.add(shard_id) + + @commands.Cog.listener() + async def on_shard_ready(self, shard_id): + self._diconnected_shard.discard(shard_id) + + @commands.Cog.listener() + async def on_shard_resumed(self, shard_id): + self._diconnected_shard.discard(shard_id) diff --git a/redbot/cogs/audio/core/events/lavalink.py b/redbot/cogs/audio/core/events/lavalink.py index 84a23d3d9..bd623f946 100644 --- a/redbot/cogs/audio/core/events/lavalink.py +++ b/redbot/cogs/audio/core/events/lavalink.py @@ -3,9 +3,12 @@ import contextlib import datetime import logging from pathlib import Path +from typing import Dict import discord import lavalink +from discord.backoff import ExponentialBackoff +from discord.gateway import DiscordWebSocket from redbot.core.i18n import Translator, set_contextual_locales_from_guild from ...errors import DatabaseError, TrackEnqueueError @@ -13,6 +16,9 @@ from ..abc import MixinMeta from ..cog_utils import CompositeMetaClass log = logging.getLogger("red.cogs.Audio.cog.Events.lavalink") +ws_audio_log = logging.getLogger("red.Audio.WS.Audio") +ws_audio_log.setLevel(logging.WARNING) + _ = Translator("Audio", Path(__file__)) @@ -28,27 +34,62 @@ 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 not (current_channel and guild): + player.store("autoplay_notified", False) + await player.stop() + await player.disconnect() + return if await self.bot.cog_disabled_in_guild(self, guild): await player.stop() await player.disconnect() + if guild: + await self.config.guild_from_id(guild_id=guild.id).currently_auto_playing_in.set( + [] + ) return guild_id = self.rgetattr(guild, "id", None) if not guild: return + guild_data = await self.config.guild(guild).all() + disconnect = guild_data["disconnect"] + if event_type == lavalink.LavalinkEvents.FORCED_DISCONNECT: + self.bot.dispatch("red_audio_audio_disconnect", guild) + await self.config.guild_from_id(guild_id=guild_id).currently_auto_playing_in.set([]) + self._ll_guild_updates.discard(guild.id) + return + + if event_type == lavalink.LavalinkEvents.WEBSOCKET_CLOSED: + deafen = guild_data["auto_deafen"] + event_channel_id = extra.get("channelID") + _error_code = extra.get("code") + if _error_code in [1000] or not guild: + if _error_code == 1000: + await player.resume(player.current, start=player.position, replace=False) + return + await self._ws_op_codes[guild_id].put((event_channel_id, _error_code)) + try: + if guild_id not in self._ws_resume: + self._ws_resume[guild_id].set() + + await self._websocket_closed_handler( + guild=guild, player=player, extra=extra, deafen=deafen, disconnect=disconnect + ) + except Exception: + log.exception( + f"Error in WEBSOCKET_CLOSED handling for guild: {player.channel.guild.id}" + ) + return + await set_contextual_locales_from_guild(self.bot, guild) current_requester = self.rgetattr(current_track, "requester", None) current_stream = self.rgetattr(current_track, "is_stream", None) current_length = self.rgetattr(current_track, "length", None) current_thumbnail = self.rgetattr(current_track, "thumbnail", None) - current_extras = self.rgetattr(current_track, "extras", {}) 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 = await self.get_track_description( current_track, self.local_folder_current_path @@ -59,7 +100,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): await self.maybe_reset_error_counter(player) if event_type == lavalink.LavalinkEvents.TRACK_START: - self.skip_votes[guild_id] = [] + self.skip_votes[guild_id] = set() playing_song = player.fetch("playing_song") requester = player.fetch("requester") player.store("prev_song", playing_song) @@ -71,25 +112,35 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): await self.api_interface.persistent_queue_api.played( guild_id=guild_id, track_id=current_track.track_identifier ) + notify_channel = player.fetch("channel") + if notify_channel and autoplay: + await self.config.guild_from_id(guild_id=guild_id).currently_auto_playing_in.set( + [notify_channel, player.channel.id] + ) + else: + await self.config.guild_from_id(guild_id=guild_id).currently_auto_playing_in.set( + [] + ) 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) + player.store("resume_attempts", 0) 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 ( + if player.is_auto_playing or ( autoplay and not player.queue and player.fetch("playing_song") is not None and self.playlist_api is not None and self.api_interface is not None ): + notify_channel = player.fetch("channel") try: await self.api_interface.autoplay(player, self.playlist_api) except DatabaseError: - notify_channel = player.fetch("channel") notify_channel = self.bot.get_channel(notify_channel) if notify_channel: await self.send_embed_msg( @@ -97,7 +148,6 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): ) return except TrackEnqueueError: - notify_channel = player.fetch("channel") notify_channel = self.bot.get_channel(notify_channel) if notify_channel: await self.send_embed_msg( @@ -116,18 +166,7 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): if player.fetch("notify_message") is not None: with contextlib.suppress(discord.HTTPException): await player.fetch("notify_message").delete() - - if ( - autoplay - and current_extras.get("autoplay") - and ( - prev_song is None - or (hasattr(prev_song, "extras") and not prev_song.extras.get("autoplay")) - ) - ): - await self.send_embed_msg(notify_channel, title=_("Auto Play started.")) - - if not description: + if not (description and notify_channel): return if current_stream: dur = "LIVE" @@ -166,6 +205,9 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): await self.send_embed_msg(notify_channel, title=_("Queue ended.")) if disconnect: self.bot.dispatch("red_audio_audio_disconnect", guild) + await self.config.guild_from_id( + guild_id=guild_id + ).currently_auto_playing_in.set([]) await player.disconnect() self._ll_guild_updates.discard(guild.id) if status: @@ -197,10 +239,14 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): eq = player.fetch("eq") player.queue = [] player.store("playing_song", None) + player.store("autoplay_notified", False) if eq: await self.config.custom("EQUALIZER", guild_id).eq_bands.set(eq.bands) await player.stop() await player.disconnect() + await self.config.guild_from_id(guild_id=guild_id).currently_auto_playing_in.set( + [] + ) self._ll_guild_updates.discard(guild_id) self.bot.dispatch("red_audio_audio_disconnect", guild) if message_channel: @@ -240,3 +286,209 @@ class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass): ) await message_channel.send(embed=embed) await player.skip() + + async def _websocket_closed_handler( + self, + guild: discord.Guild, + player: lavalink.Player, + extra: Dict, + deafen: bool, + disconnect: bool, + ) -> None: + guild_id = guild.id + event_channel_id = extra.get("channelID") + try: + if not self._ws_resume[guild_id].is_set(): + await self._ws_resume[guild_id].wait() + else: + self._ws_resume[guild_id].clear() + node = player.node + voice_ws: DiscordWebSocket = node.get_voice_ws(guild_id) + code = extra.get("code") + by_remote = extra.get("byRemote", "") + reason = extra.get("reason", "No Specified Reason").strip() + channel_id = player.channel.id + try: + event_channel_id, to_handle_code = await self._ws_op_codes[guild_id].get() + except asyncio.QueueEmpty: + log.debug("Empty queue - Resuming Processor - Early exit") + return + + if code != to_handle_code: + code = to_handle_code + if player.channel.id != event_channel_id: + code = 4014 + if event_channel_id != channel_id: + ws_audio_log.info( + f"Received an op code for a channel that is no longer valid; {event_channel_id} " + f"in guild: {guild_id} - Active channel {channel_id} | " + f"Reason: Error code {code} & {reason}." + ) + self._ws_op_codes[guild_id]._init(self._ws_op_codes[guild_id]._maxsize) + return + if player.channel: + current_perms = player.channel.permissions_for(player.channel.guild.me) + has_perm = current_perms.speak and current_perms.connect + else: + has_perm = False + if code in (1000,) and has_perm and player.current and player.is_playing: + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.resume(player.current, start=player.position, replace=True) + ws_audio_log.info( + f"Player resumed in channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & {reason}." + ) + self._ws_op_codes[guild_id]._init(self._ws_op_codes[guild_id]._maxsize) + return + + if voice_ws.socket._closing or voice_ws.socket.closed or not voice_ws.open: + if player._con_delay: + delay = player._con_delay.delay() + else: + player._con_delay = ExponentialBackoff(base=1) + delay = player._con_delay.delay() + ws_audio_log.warning( + "YOU CAN IGNORE THIS UNLESS IT'S CONSISTENTLY REPEATING FOR THE SAME GUILD - " + f"Voice websocket closed for guild {guild_id} -> " + f"Socket Closed {voice_ws.socket._closing or voice_ws.socket.closed}. " + f"Code: {code} -- Remote: {by_remote} -- {reason}" + ) + ws_audio_log.debug( + f"Reconnecting to channel {channel_id} in guild: {guild_id} | {delay:.2f}s" + ) + await asyncio.sleep(delay) + while voice_ws.socket._closing or voice_ws.socket.closed or not voice_ws.open: + voice_ws = node.get_voice_ws(guild_id) + await asyncio.sleep(0.1) + + if has_perm and player.current and player.is_playing: + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.connect(deafen=deafen) + await player.resume(player.current, start=player.position, replace=True) + ws_audio_log.info( + "Voice websocket reconnected " + f"to channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Currently playing." + ) + elif has_perm and player.paused and player.current: + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.connect(deafen=deafen) + await player.resume( + player.current, start=player.position, replace=True, pause=True + ) + ws_audio_log.info( + "Voice websocket reconnected " + f"to channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Currently Paused." + ) + elif has_perm and (not disconnect) and (not player.is_playing): + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.connect(deafen=deafen) + ws_audio_log.info( + "Voice websocket reconnected " + f"to channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Not playing, but auto disconnect disabled." + ) + self._ll_guild_updates.discard(guild_id) + elif not has_perm: + self.bot.dispatch("red_audio_audio_disconnect", guild) + ws_audio_log.info( + "Voice websocket disconnected " + f"from channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Missing permissions." + ) + self._ll_guild_updates.discard(guild_id) + player.store("autoplay_notified", False) + await player.stop() + await player.disconnect() + await self.config.guild_from_id( + guild_id=guild_id + ).currently_auto_playing_in.set([]) + else: + self.bot.dispatch("red_audio_audio_disconnect", guild) + ws_audio_log.info( + "Voice websocket disconnected " + f"from channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Unknown." + ) + self._ll_guild_updates.discard(guild_id) + player.store("autoplay_notified", False) + await player.stop() + await player.disconnect() + await self.config.guild_from_id( + guild_id=guild_id + ).currently_auto_playing_in.set([]) + elif code in (42069,) and has_perm and player.current and player.is_playing: + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.connect(deafen=deafen) + await player.resume(player.current, start=player.position, replace=True) + ws_audio_log.info( + f"Player resumed in channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & {reason}." + ) + elif code in (4015, 4009, 4006, 4000, 1006): + if player._con_delay: + delay = player._con_delay.delay() + else: + player._con_delay = ExponentialBackoff(base=1) + delay = player._con_delay.delay() + ws_audio_log.debug( + f"Reconnecting to channel {channel_id} in guild: {guild_id} | {delay:.2f}s" + ) + await asyncio.sleep(delay) + if has_perm and player.current and player.is_playing: + await player.connect(deafen=deafen) + await player.resume(player.current, start=player.position, replace=True) + ws_audio_log.info( + "Voice websocket reconnected " + f"to channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Player is active." + ) + elif has_perm and player.paused and player.current: + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.connect(deafen=deafen) + await player.resume( + player.current, start=player.position, replace=True, pause=True + ) + ws_audio_log.info( + "Voice websocket reconnected " + f"to channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Player is paused." + ) + elif has_perm and (not disconnect) and (not player.is_playing): + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.connect(deafen=deafen) + ws_audio_log.info( + "Voice websocket reconnected " + f"to channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Not playing." + ) + self._ll_guild_updates.discard(guild_id) + elif not has_perm: + self.bot.dispatch("red_audio_audio_disconnect", guild) + ws_audio_log.info( + "Voice websocket disconnected " + f"from channel {channel_id} in guild: {guild_id} | " + f"Reason: Error code {code} & Missing permissions." + ) + self._ll_guild_updates.discard(guild_id) + player.store("autoplay_notified", False) + await player.stop() + await player.disconnect() + await self.config.guild_from_id( + guild_id=guild_id + ).currently_auto_playing_in.set([]) + else: + if not player.paused and player.current: + player.store("resumes", player.fetch("resumes", 0) + 1) + await player.resume(player.current, start=player.position, replace=False) + ws_audio_log.info( + "WS EVENT - IGNORED (Healthy Socket) | " + f"Voice websocket closed event for guild {guild_id} -> " + f"Code: {code} -- Remote: {by_remote} -- {reason}" + ) + except Exception: + log.exception("Error in task") + finally: + self._ws_op_codes[guild_id]._init(self._ws_op_codes[guild_id]._maxsize) + self._ws_resume[guild_id].set() diff --git a/redbot/cogs/audio/core/tasks/lavalink.py b/redbot/cogs/audio/core/tasks/lavalink.py index 1d8868c94..25f11f7a3 100644 --- a/redbot/cogs/audio/core/tasks/lavalink.py +++ b/redbot/cogs/audio/core/tasks/lavalink.py @@ -99,7 +99,6 @@ class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass): bot=self.bot, host=host, password=password, - rest_port=ws_port, ws_port=ws_port, timeout=timeout, resume_key=f"Red-Core-Audio-{self.bot.user.id}-{data_manager.instance_name}", diff --git a/redbot/cogs/audio/core/tasks/player.py b/redbot/cogs/audio/core/tasks/player.py index a35b91914..acbe19219 100644 --- a/redbot/cogs/audio/core/tasks/player.py +++ b/redbot/cogs/audio/core/tasks/player.py @@ -47,15 +47,36 @@ class PlayerTasks(MixinMeta, metaclass=CompositeMetaClass): servers.update(pause_times) async for sid in AsyncIter(servers, steps=5): server_obj = self.bot.get_guild(sid) - if sid in stop_times and await self.config.guild(server_obj).emptydc_enabled(): + if not server_obj: + stop_times.pop(sid, None) + pause_times.pop(sid, None) + try: + player = lavalink.get_player(sid) + await self.api_interface.persistent_queue_api.drop(sid) + player.store("autoplay_notified", False) + await player.stop() + await player.disconnect() + await self.config.guild_from_id( + guild_id=sid + ).currently_auto_playing_in.set([]) + except Exception as err: + debug_exc_log( + log, err, f"Exception raised in Audio's emptydc_timer for {sid}." + ) + + elif sid in stop_times and await self.config.guild(server_obj).emptydc_enabled(): emptydc_timer = await self.config.guild(server_obj).emptydc_timer() if (time.time() - stop_times[sid]) >= emptydc_timer: stop_times.pop(sid) try: player = lavalink.get_player(sid) await self.api_interface.persistent_queue_api.drop(sid) + player.store("autoplay_notified", False) await player.stop() await player.disconnect() + await self.config.guild_from_id( + guild_id=sid + ).currently_auto_playing_in.set([]) except Exception as err: if "No such player for that guild" in str(err): stop_times.pop(sid, None) diff --git a/redbot/cogs/audio/core/tasks/startup.py b/redbot/cogs/audio/core/tasks/startup.py index 3fd5c9658..df2b30fc7 100644 --- a/redbot/cogs/audio/core/tasks/startup.py +++ b/redbot/cogs/audio/core/tasks/startup.py @@ -10,12 +10,14 @@ import lavalink from redbot.core.data_manager import cog_data_path from redbot.core.i18n import Translator +from redbot.core.utils import AsyncIter 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 ...errors import DatabaseError, TrackEnqueueError from ...utils import task_callback from ..abc import MixinMeta from ..cog_utils import _OWNER_NOTIFICATION, _SCHEMA_VERSION, CompositeMetaClass @@ -52,6 +54,7 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): ) await self.playlist_api.delete_scheduled() await self.api_interface.persistent_queue_api.delete_scheduled() + await self._build_bundled_playlist() self.lavalink_restart_connect() self.player_automated_timer_task = self.bot.loop.create_task( self.player_automated_timer() @@ -66,13 +69,29 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): async def restore_players(self): tries = 0 tracks_to_restore = await self.api_interface.persistent_queue_api.fetch_all() - await asyncio.sleep(10) + while not lavalink.node._nodes: + await asyncio.sleep(1) + tries += 1 + if tries > 60: + log.exception("Unable to restore players, couldn't connect to Lavalink.") + return + metadata = {} + all_guilds = await self.config.all_guilds() + async for guild_id, guild_data in AsyncIter(all_guilds.items(), steps=100): + if guild_data["auto_play"]: + if guild_data["currently_auto_playing_in"]: + notify_channel, vc_id = guild_data["currently_auto_playing_in"] + metadata[guild_id] = (notify_channel, vc_id) + for guild_id, track_data in itertools.groupby(tracks_to_restore, key=lambda x: x.guild_id): await asyncio.sleep(0) + tries = 0 try: - player: Optional[lavalink.Player] + player: Optional[lavalink.Player] = None track_data = list(track_data) guild = self.bot.get_guild(guild_id) + if not guild: + continue persist_cache = self._persist_queue_cache.setdefault( guild_id, await self.config.guild(guild).persist_queue() ) @@ -88,40 +107,48 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): player = None except KeyError: player = None - vc = 0 + guild_data = await self.config.guild_from_id(guild.id).all() + shuffle = guild_data["shuffle"] + repeat = guild_data["repeat"] + volume = guild_data["volume"] + shuffle_bumped = guild_data["shuffle_bumped"] + auto_deafen = guild_data["auto_deafen"] + if player is None: - while tries < 25 and vc is not None: + while tries < 5 and vc is not None: try: - vc = guild.get_channel(track_data[-1].room_id) + notify_channel_id, vc_id = metadata.pop( + guild_id, (None, track_data[-1].room_id) + ) + vc = guild.get_channel(vc_id) if not vc: break perms = vc.permissions_for(guild.me) if not (perms.connect and perms.speak): vc = None break - await lavalink.connect(vc) + await lavalink.connect(vc, deafen=auto_deafen) player = lavalink.get_player(guild.id) player.store("connect", datetime.datetime.utcnow()) player.store("guild", guild_id) - await self.self_deafen(player) + player.store("channel", notify_channel_id) break except IndexError: await asyncio.sleep(5) tries += 1 except Exception as exc: + tries += 1 debug_exc_log(log, exc, "Failed to restore music voice channel") if vc is None: break + else: + await asyncio.sleep(1) - if tries >= 25 or guild is None or vc is None: + if tries >= 5 or guild is None or vc is None or player 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 @@ -137,6 +164,90 @@ class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass): debug_exc_log(log, err, f"Error restoring player in {guild_id}") await self.api_interface.persistent_queue_api.drop(guild_id) + for guild_id, (notify_channel_id, vc_id) in metadata.items(): + guild = self.bot.get_guild(guild_id) + player: Optional[lavalink.Player] = None + vc = 0 + tries = 0 + if not guild: + continue + if self.lavalink_connection_aborted: + player = None + else: + try: + player = lavalink.get_player(guild_id) + except IndexError: + player = None + except KeyError: + player = None + if player is None: + guild_data = await self.config.guild_from_id(guild.id).all() + shuffle = guild_data["shuffle"] + repeat = guild_data["repeat"] + volume = guild_data["volume"] + shuffle_bumped = guild_data["shuffle_bumped"] + auto_deafen = guild_data["auto_deafen"] + + while tries < 5 and vc is not None: + try: + vc = guild.get_channel(vc_id) + if not vc: + break + perms = vc.permissions_for(guild.me) + if not (perms.connect and perms.speak): + vc = None + break + await lavalink.connect(vc, deafen=auto_deafen) + player = lavalink.get_player(guild.id) + player.store("connect", datetime.datetime.utcnow()) + player.store("guild", guild_id) + player.store("channel", notify_channel_id) + break + except IndexError: + await asyncio.sleep(5) + tries += 1 + except Exception as exc: + tries += 1 + debug_exc_log(log, exc, "Failed to restore music voice channel") + if vc is None: + break + else: + await asyncio.sleep(1) + if tries >= 5 or guild is None or vc is None or player is None: + continue + + player.repeat = repeat + player.shuffle = shuffle + player.shuffle_bumped = shuffle_bumped + if player.volume != volume: + await player.set_volume(volume) + player.maybe_shuffle() + if not player.is_playing: + notify_channel = player.fetch("channel") + try: + await self.api_interface.autoplay(player, self.playlist_api) + except DatabaseError: + notify_channel = self.bot.get_channel(notify_channel) + if notify_channel: + await self.send_embed_msg( + notify_channel, title=_("Couldn't get a valid track.") + ) + return + except TrackEnqueueError: + notify_channel = self.bot.get_channel(notify_channel) + if notify_channel: + await self.send_embed_msg( + notify_channel, + title=_("Unable to Get Track"), + description=_( + "I'm unable to get a track from Lavalink at the moment, " + "try again in a few minutes." + ), + ) + return + del metadata + del all_guilds + async def maybe_message_all_owners(self): current_notification = await self.config.owner_notification() if current_notification == _OWNER_NOTIFICATION: diff --git a/redbot/cogs/audio/core/utilities/formatting.py b/redbot/cogs/audio/core/utilities/formatting.py index 4cc4500c5..427e1881e 100644 --- a/redbot/cogs/audio/core/utilities/formatting.py +++ b/redbot/cogs/audio/core/utilities/formatting.py @@ -99,10 +99,14 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): description = _("Please check your console or logs for details.") return await self.send_embed_msg(ctx, title=msg, description=description) try: - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except AttributeError: return await self.send_embed_msg(ctx, title=_("Connect to a voice channel first.")) except IndexError: @@ -239,24 +243,26 @@ class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass): if query.is_local: search_list += "`{0}.` **{1}**\n[{2}]\n".format( search_track_num, - track.title, - LocalPath(track.uri, self.local_folder_current_path).to_string_user(), + discord.utils.escape_markdown(track.title), + discord.utils.escape_markdown( + LocalPath(track.uri, self.local_folder_current_path).to_string_user() + ), ) else: search_list += "`{0}.` **[{1}]({2})**\n".format( - search_track_num, track.title, track.uri + search_track_num, discord.utils.escape_markdown(track.title), track.uri ) except AttributeError: track = Query.process_input(track, self.local_folder_current_path) if track.is_local and command != "search": search_list += "`{}.` **{}**\n".format( - search_track_num, track.to_string_user() + search_track_num, discord.utils.escape_markdown(track.to_string_user()) ) if track.is_album: folder = True else: search_list += "`{}.` **{}**\n".format( - search_track_num, track.to_string_user() + search_track_num, discord.utils.escape_markdown(track.to_string_user()) ) if hasattr(tracks[0], "uri") and hasattr(tracks[0], "track_identifier"): title = _("Tracks Found:") diff --git a/redbot/cogs/audio/core/utilities/local_tracks.py b/redbot/cogs/audio/core/utilities/local_tracks.py index 62d2d7b19..09cd3688b 100644 --- a/redbot/cogs/audio/core/utilities/local_tracks.py +++ b/redbot/cogs/audio/core/utilities/local_tracks.py @@ -4,6 +4,7 @@ import logging from pathlib import Path from typing import List, Union +import discord import lavalink from fuzzywuzzy import process @@ -121,7 +122,7 @@ class LocalTrackUtilities(MixinMeta, metaclass=CompositeMetaClass): if percent_match > 85: search_list.extend( [ - i.to_string_user() + discord.utils.escape_markdown(i.to_string_user()) for i in to_search if i.local_track_path is not None and i.local_track_path.name == track_match diff --git a/redbot/cogs/audio/core/utilities/miscellaneous.py b/redbot/cogs/audio/core/utilities/miscellaneous.py index 484d46db5..beb561f15 100644 --- a/redbot/cogs/audio/core/utilities/miscellaneous.py +++ b/redbot/cogs/audio/core/utilities/miscellaneous.py @@ -5,14 +5,14 @@ import functools import json import logging import re +import struct from pathlib import Path - from typing import Any, Final, Mapping, MutableMapping, Pattern, Union, cast import discord import lavalink - from discord.embeds import EmptyEmbed + from redbot.core import bank, commands from redbot.core.commands import Context from redbot.core.i18n import Translator @@ -22,7 +22,7 @@ from redbot.core.utils.chat_formatting import humanize_number from ...apis.playlist_interface import get_all_playlist_for_migration23 from ...utils import PlaylistScope, task_callback from ..abc import MixinMeta -from ..cog_utils import CompositeMetaClass +from ..cog_utils import CompositeMetaClass, DataReader log = logging.getLogger("red.cogs.Audio.cog.Utilities.miscellaneous") _ = Translator("Audio", Path(__file__)) @@ -338,3 +338,47 @@ class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass): if database_entries: await self.api_interface.local_cache_api.lavalink.insert(database_entries) + + def decode_track(self, track: str, decode_errors: str = "ignore") -> MutableMapping: + """ + Decodes a base64 track string into an AudioTrack object. + Parameters + ---------- + track: :class:`str` + The base64 track string. + decode_errors: :class:`str` + The action to take upon encountering erroneous characters within track titles. + Returns + ------- + :class:`AudioTrack` + """ + reader = DataReader(track) + + flags = (reader.read_int() & 0xC0000000) >> 30 + (version,) = ( + struct.unpack("B", reader.read_byte()) if flags & 1 != 0 else 1 + ) # pylint: disable=unused-variable + + title = reader.read_utf().decode(errors=decode_errors) + author = reader.read_utf().decode() + length = reader.read_long() + identifier = reader.read_utf().decode() + is_stream = reader.read_boolean() + uri = reader.read_utf().decode() if reader.read_boolean() else None + source = reader.read_utf().decode() + position = reader.read_long() # noqa: F841 pylint: disable=unused-variable + + track_object = { + "track": track, + "info": { + "title": title, + "author": author, + "length": length, + "identifier": identifier, + "isStream": is_stream, + "uri": uri, + "isSeekable": not is_stream, + }, + } + + return track_object diff --git a/redbot/cogs/audio/core/utilities/player.py b/redbot/cogs/audio/core/utilities/player.py index 24f9882bf..f29e01702 100644 --- a/redbot/cogs/audio/core/utilities/player.py +++ b/redbot/cogs/audio/core/utilities/player.py @@ -218,10 +218,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): 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) + await player.channel.guild.change_voice_state(channel=player.channel, self_deaf=True) async def _get_spotify_tracks( self, ctx: commands.Context, query: Query, forced: bool = False @@ -646,7 +643,7 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): lock=self.update_player_lock, notifier=notifier, forced=forced, - query_global=await self.config.global_db_enabled(), + query_global=self.global_api_user.get("can_read"), ) except SpotifyFetchError as error: self.update_player_lock(ctx, False) @@ -716,8 +713,10 @@ class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass): and player.position == 0 and len(player.queue) == 0 ): - await player.move_to(user_channel) - await self.self_deafen(player) + await player.move_to( + user_channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) return True else: return False diff --git a/redbot/cogs/audio/core/utilities/playlists.py b/redbot/cogs/audio/core/utilities/playlists.py index 404f1398c..5e41bd4bc 100644 --- a/redbot/cogs/audio/core/utilities/playlists.py +++ b/redbot/cogs/audio/core/utilities/playlists.py @@ -4,14 +4,17 @@ import datetime import json import logging import math +import random +import time from pathlib import Path from typing import List, MutableMapping, Optional, Tuple, Union +import aiohttp import discord import lavalink - from discord.embeds import EmptyEmbed + from redbot.core import commands from redbot.core.i18n import Translator from redbot.core.utils import AsyncIter @@ -19,7 +22,7 @@ from redbot.core.utils.chat_formatting import box from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.predicates import ReactionPredicate -from ...apis.playlist_interface import Playlist, create_playlist +from ...apis.playlist_interface import Playlist, PlaylistCompat23, create_playlist from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query from ...audio_logging import debug_exc_log from ...errors import TooManyMatches, TrackEnqueueError @@ -29,6 +32,9 @@ from ..cog_utils import CompositeMetaClass log = logging.getLogger("red.cogs.Audio.cog.Utilities.playlists") _ = Translator("Audio", Path(__file__)) +CURRATED_DATA = ( + "https://gist.githubusercontent.com/Drapersniper/cbe10d7053c844f8c69637bb4fd9c5c3/raw/json" +) class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): @@ -470,6 +476,18 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): async def _maybe_update_playlist( self, ctx: commands.Context, player: lavalink.player_manager.Player, playlist: Playlist ) -> Tuple[List[lavalink.Track], List[lavalink.Track], Playlist]: + if playlist.id == 42069: + _, updated_tracks = await self._get_bundled_playlist_tracks() + results = {} + old_tracks = playlist.tracks_obj + new_tracks = [lavalink.Track(data=track) for track in updated_tracks] + removed = list(set(old_tracks) - set(new_tracks)) + added = list(set(new_tracks) - set(old_tracks)) + if removed or added: + await playlist.edit(results) + + return added, removed, playlist + if playlist.url is None: return [], [], playlist results = {} @@ -517,10 +535,14 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): description=_("I don't have permission to connect to your channel."), ) return False - await lavalink.connect(ctx.author.voice.channel) + await lavalink.connect( + ctx.author.voice.channel, + deafen=await self.config.guild_from_id(ctx.guild.id).auto_deafen(), + ) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) - await self.self_deafen(player) + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) except IndexError: await self.send_embed_msg( ctx, @@ -659,3 +681,50 @@ class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass): return ctx.name if ctx else _("the Server") if the else _("Server") elif scope == PlaylistScope.USER.value: return str(ctx) if ctx else _("the User") if the else _("User") + + async def _get_bundled_playlist_tracks(self): + async with aiohttp.ClientSession(json_serialize=json.dumps) as session: + async with session.get( + CURRATED_DATA + f"?timestamp={int(time.time())}", + headers={"content-type": "application/json"}, + ) as response: + if response.status != 200: + return 0, [] + try: + data = json.loads(await response.read()) + except Exception: + log.exception("Curated playlist couldn't be parsed, report this error.") + data = {} + web_version = data.get("version", 0) + entries = data.get("entries", []) + if entries: + random.shuffle(entries) + tracks = [] + async for entry in AsyncIter(entries, steps=25): + with contextlib.suppress(Exception): + tracks.append(self.decode_track(entry)) + return web_version, tracks + + async def _build_bundled_playlist(self, forced=False): + current_version = await self.config.bundled_playlist_version() + web_version, tracks = await self._get_bundled_playlist_tracks() + + if not forced and current_version >= web_version: + return + + playlist_data = dict() + playlist_data["name"] = "Aikaterna's curated tracks" + playlist_data["tracks"] = tracks + + playlist = await PlaylistCompat23.from_json( + bot=self.bot, + playlist_api=self.playlist_api, + scope=PlaylistScope.GLOBAL.value, + playlist_number=42069, + data=playlist_data, + guild=None, + author=self.bot.user.id, + ) + await playlist.save() + await self.config.bundled_playlist_version.set(web_version) + log.info("Curated playlist has been updated.") diff --git a/redbot/cogs/audio/data/application.yml b/redbot/cogs/audio/data/application.yml index 8d28be6e6..46c8c3eb1 100644 --- a/redbot/cogs/audio/data/application.yml +++ b/redbot/cogs/audio/data/application.yml @@ -14,7 +14,7 @@ lavalink: http: true local: true sentryDsn: "" - bufferDurationMs: 400 + bufferDurationMs: 1000 youtubePlaylistLoadLimit: 10000 logging: file: diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index 74fb1cb53..206a39d48 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -22,7 +22,7 @@ from .errors import LavalinkDownloadFailed from .utils import task_callback _ = Translator("Audio", pathlib.Path(__file__)) -log = logging.getLogger("red.audio.manager") +log = logging.getLogger("red.Audio.manager") JAR_VERSION: Final[str] = "3.3.2.3" JAR_BUILD: Final[int] = 1212 LAVALINK_DOWNLOAD_URL: Final[str] = ( @@ -234,6 +234,7 @@ class ServerManager: line = await self._proc.stdout.readline() if _RE_READY_LINE.search(line): self.ready.set() + log.info("Internal Lavalink server is ready to receive requests.") break if _FAILED_TO_START.search(line): raise RuntimeError(f"Lavalink failed to start: {line.decode().strip()}") diff --git a/setup.cfg b/setup.cfg index 54c58586e..85c22a937 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,12 +51,13 @@ install_requires = idna==2.10 Markdown==3.3.3 multidict==5.1.0 + PyNaCl==1.3.0 Pygments==2.7.4 python-dateutil==2.8.1 python-Levenshtein-wheels==0.13.2 pytz==2021.1 PyYAML==5.4.1 - Red-Lavalink==0.7.2 + Red-Lavalink==0.8.0 rich==9.9.0 schema==0.7.4 six==1.15.0 diff --git a/tools/primary_deps.ini b/tools/primary_deps.ini index 345cf60a7..6a717e2bf 100644 --- a/tools/primary_deps.ini +++ b/tools/primary_deps.ini @@ -26,6 +26,7 @@ install_requires = schema tqdm uvloop; sys_platform != "win32" and platform_python_implementation == "CPython" + PyNaCl [options.extras_require] docs =