diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8dc9a23..5df49ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,4 +105,4 @@ jobs: env: REPO: ${{ steps.vars.outputs.repo }} if: github.event_name != 'pull_request' - run: docker push $REPO + run: docker push -a $REPO diff --git a/cogs/music/__init__.py b/cogs/music/__init__.py index 492ebc9..7e3bf7f 100644 --- a/cogs/music/__init__.py +++ b/cogs/music/__init__.py @@ -36,14 +36,18 @@ class Music(commands.Cog): ws = self.bot._connection._get_websocket(guild_id) await ws.voice_state(str(guild_id), channel_id) - async def enqueue(self, ctx, track, embed, silent=False): + async def enqueue(self, ctx, track, embed, silent=False, check_max_length=True): player = self.bot.lavalink.player_manager.get(ctx.guild.id) # Only add tracks that don't exceed the max track length if maxlength := self.max_track_length(ctx.guild, player): - if track['info']['length'] > maxlength: + if track['info']['length'] > maxlength and check_max_length: + if track['info']['isStream']: + length = '{live}' + else: + length = timeformatter.format_ms(track['info']['length']) embed.description = ctx.localizer.format_str("{enqueue.toolong}", - _length=timeformatter.format_ms(track['info']['length']), + _length=length, _max=timeformatter.format_ms(maxlength)) return embed, False @@ -55,7 +59,10 @@ class Music(commands.Cog): track, pos_global, pos_local = player.add(requester=ctx.author.id, track=track) if player.current is not None and not silent: - until_play = player.queue_duration(include_current=True, end_pos=pos_global) + if player.current.stream: + until_play = '--:--' + else: + until_play = player.queue_duration(include_current=True, end_pos=pos_global) embed.add_field(name="{enqueue.position}", value=f"`{pos_local + 1}({pos_global + 1})`", inline=True) embed.add_field(name="{enqueue.playing_in}", value=f"`{until_play} ({{enqueue.estimated}})`", inline=True) @@ -66,7 +73,10 @@ class Music(commands.Cog): if thumbnail_url: embed.set_thumbnail(url=thumbnail_url) - duration = timeformatter.format_ms(int(track.duration)) + if track.stream: + duration = '{live}' + else: + duration = timeformatter.format_ms(int(track.duration)) embed.description = f'[{track.title}]({track.uri})\n**{duration}**' return embed, True @@ -110,12 +120,12 @@ class Music(commands.Cog): # commands from .basic_commands import ( - _disconnect, _djremove, _move, _myqueue, _now, _pause, _play, _queue, _reconnect, _remove, _search, _seek, - _shuffle, _skip, _skip_to, _stop, _user_queue_remove, _volume) + _disconnect, _djremove, _forceplay, _move, _myqueue, _now, _pause, _play, _queue, _reconnect, _remove, _search, + _seek, _shuffle, _skip, _skip_to, _stop, _user_queue_remove, _volume) # events from .events import check_leave_voice, cog_unload, leave_check, leave_timer, on_voice_state_update, track_hook # commands - from .extra_commands import _boost, _history, _lyrics, _normalize, _scrub + from .extra_commands import _boost, _history, _loop, _loop_start, _loop_stop, _lyrics, _normalize, _scrub def setup(bot): diff --git a/cogs/music/basic_commands.py b/cogs/music/basic_commands.py index f433166..4507c20 100644 --- a/cogs/music/basic_commands.py +++ b/cogs/music/basic_commands.py @@ -77,7 +77,7 @@ async def _seek(self, ctx, *, time: str): @commands.command(name='skip') @require_voice_connection() @require_playing() -@voteable(requester_override=True) +@voteable(requester_override=True, react_to_vote=True) async def _skip(self, ctx): """ Skips the current track. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) @@ -112,7 +112,7 @@ async def _skip_to(self, ctx, pos: int = 1): @commands.command(name='stop') @require_voice_connection() @require_playing(require_user_listening=True) -@voteable(DJ_override=True) +@voteable(DJ_override=True, react_to_vote=True) async def _stop(self, ctx): """ Stops the player and clears its queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) @@ -204,7 +204,11 @@ async def _move(self, ctx): # Get the initial and final position message, page, selections = await selector.start_scrolling() - pos_initial, pos_final = selections[0], selections[1] + + try: + pos_initial, pos_final = selections[0], selections[1] + except (IndexError, TypeError): + return await message.delete() # Move the track moved = player.move_user_track(ctx.author.id, pos_initial, pos_final) @@ -247,7 +251,10 @@ async def _remove(self, ctx): selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, color=0xFF0000, title='Remove song plz') message, _, removed = await selector.start_scrolling() - await message.edit(content=ctx.localizer.format_str("{remove}", _title=removed.title), embed=None) + if removed: + await message.edit(content=ctx.localizer.format_str("{remove}", _title=removed.title), embed=None) + else: + await message.delete() @commands.command(name="DJremove") @@ -322,18 +329,22 @@ async def _search(self, ctx, *, query): search_selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, color=ctx.me.color, title=ctx.localizer.format_str('{results}')) # Let the user scroll through results - message, current_page, (embed, added) = await search_selector.start_scrolling() + message, current_page, result = await search_selector.start_scrolling() - embed = ctx.localizer.format_embed(embed) - await message.edit(embed=embed) + if result: + (embed, added) = result + embed = ctx.localizer.format_embed(embed) + await message.edit(embed=embed) - if not player.is_playing: - await player.play() + if not player.is_playing: + await player.play() + else: + await message.delete() @commands.command(name='disconnect') @require_voice_connection() -@voteable(DJ_override=True) +@voteable(DJ_override=True, react_to_vote=True) async def _disconnect(self, ctx): """ Disconnects the player from the voice channel and clears its queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) @@ -348,7 +359,7 @@ async def _disconnect(self, ctx): @commands.command(name='reconnect') @require_voice_connection() -@voteable(DJ_override=True) +@voteable(DJ_override=True, react_to_vote=True) async def _reconnect(self, ctx): """ Tries to disconnect then reconnect the player in case the bot gets stuck on a song """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) @@ -390,3 +401,41 @@ async def _volume(self, ctx, volume: int = None): embed = discord.Embed(description="{volume.set_to}", color=ctx.me.color) embed = ctx.localizer.format_embed(embed, _volume=player.volume) await ctx.send(embed=embed) + + +@commands.command(name='forceplay') +@require_voice_connection(should_connect=True) +@voteable(DJ_override=True, react_to_vote=True) +async def _forceplay(self, ctx, *, query: str): + """ Searches and plays a song from a given query. """ + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + query = query.strip('<>') + + if not url_rx.match(query): + query = f'ytsearch:{query}' + + results = await player.node.get_tracks(query) + + if not results or not results['tracks']: + return await ctx.send(ctx.localizer.format_str("{nothing_found}")) + + embed = discord.Embed(color=ctx.me.color) + + if results['loadType'] == 'PLAYLIST_LOADED': + numtracks = 0 + for track in results['tracks']: + _, track_added = await self.enqueue(ctx, track, embed, silent=True, check_max_length=False) + if track_added: + numtracks += 1 + + embed.title = '{playlist_enqued}' + embed.description = f'{results["playlistInfo"]["name"]} - {numtracks} {{tracks}}' + else: + track = results['tracks'][0] + await self.enqueue(ctx, track, embed, check_max_length=False) + + embed = ctx.localizer.format_embed(embed) + await ctx.send(embed=embed) + + if not player.is_playing: + await player.play() diff --git a/cogs/music/decorators.py b/cogs/music/decorators.py index 997ba4b..6db999d 100644 --- a/cogs/music/decorators.py +++ b/cogs/music/decorators.py @@ -2,6 +2,7 @@ import discord from discord.ext import commands +import asyncio import functools import inspect import math @@ -127,7 +128,7 @@ def require_queue(require_member_queue=False, require_author_queue=False): return ensure_queue -def voteable(requester_override=False, DJ_override=False): +def voteable(requester_override=False, DJ_override=False, react_to_vote=False): def make_voteable(func): @functools.wraps(func) async def voteable_inner(self, ctx, *command_args, **kwargs): @@ -140,11 +141,54 @@ def voteable(requester_override=False, DJ_override=False): enough_votes = votes/total >= threshold/100 DJ = DJ_override and is_dj(ctx) - requester = player.current.requester == ctx.author.id and requester_override + requester = player.current is None or (player.current.requester == ctx.author.id and requester_override) if enough_votes or requester or DJ: await func(self, ctx, *command_args, **kwargs) player.clear_votes() + + elif react_to_vote: + embed = discord.Embed(title="Votes", + description=f"{votes} out of {math.ceil(total*threshold/100)} required votes.", + color=ctx.me.color) + embed.set_footer(text=f'{{requested_by}} {ctx.author.name}', icon_url=ctx.author.avatar_url) + msg = await ctx.send(embed=ctx.localizer.format_embed(embed)) + await msg.add_reaction('👍') + + def check(reaction, user): + if not reaction.message.id == msg.id: + return False + + if user is None or user not in player.listeners: + return False + + if reaction.emoji == '👍' and user.id not in player.get_voters(func.__name__): + player.add_vote(func.__name__, user) + return True + return False + + while True: + try: + reaction, user = await self.bot.wait_for('reaction_add', timeout=15.0, check=check) + except asyncio.TimeoutError: + await msg.clear_reactions() + embed.title = '' + embed.set_footer(text='{time_expired}') + await msg.edit(embed=ctx.localizer.format_embed(embed)) + break + + total = len(player.listeners) + votes = len(player.get_voters(func.__name__)) + + embed.description = f"{votes} out of {math.ceil(total*threshold/100)} required votes." + await msg.edit(embed=ctx.localizer.format_embed(embed)) + + if votes/total >= threshold/100: + player.clear_votes() + await msg.delete() + await func(self, ctx, *command_args, **kwargs) + break + else: if votes != 0: needed = math.ceil(total*threshold/100) diff --git a/cogs/music/extra_commands.py b/cogs/music/extra_commands.py index 5bef7be..b4caebb 100644 --- a/cogs/music/extra_commands.py +++ b/cogs/music/extra_commands.py @@ -16,7 +16,7 @@ from bs4 import BeautifulSoup from ..utils import checks from ..utils.paginator import Scroller, TextPaginator -from .decorators import require_playing, require_voice_connection +from .decorators import require_playing, require_voice_connection, voteable @commands.command(name='normalize') @@ -213,3 +213,43 @@ async def _scrub(self, ctx): if not isinstance(arg, bool): arg = player.position + arg * 1000 await selection(arg) + + +@commands.group(name='loop') +async def _loop(self, ctx): + # This is done using subcommands to have separate vote counts for starting and stopping. + if ctx.invoked_subcommand is None: + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + embed = discord.Embed(color=ctx.me.color, title='Loop status') + + if player.looping: + embed.description = 'The bot is currently looping: ♾️' + else: + embed.description = 'The bot is currently not looping' + await ctx.send(embed=embed) + + +@_loop.command(name='start') +@require_playing(require_user_listening=True) +@voteable(DJ_override=True, react_to_vote=True) +async def _loop_start(self, ctx): + """ Set the equalizer to bass boost the music """ + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player.enable_looping(True) + embed = discord.Embed(color=ctx.me.color) + embed.description = 'Looping turned on' + embed = ctx.localizer.format_embed(embed) + await ctx.send(embed=embed) + + +@_loop.command(name='stop') +@require_playing(require_user_listening=True) +@voteable(DJ_override=True, react_to_vote=True) +async def _loop_stop(self, ctx): + """ Set the equalizer to bass boost the music """ + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player.enable_looping(False) + embed = discord.Embed(color=ctx.me.color) + embed.description = 'Looping turned off' + embed = ctx.localizer.format_embed(embed) + await ctx.send(embed=embed) diff --git a/cogs/utils/bot_version.py b/cogs/utils/bot_version.py index 54ea582..6ed0953 100644 --- a/cogs/utils/bot_version.py +++ b/cogs/utils/bot_version.py @@ -1 +1 @@ -bot_version = "0.1.6" +bot_version = "0.1.7" diff --git a/cogs/utils/mixplayer.py b/cogs/utils/mixplayer.py index 108c795..eaef58c 100644 --- a/cogs/utils/mixplayer.py +++ b/cogs/utils/mixplayer.py @@ -102,6 +102,13 @@ class MixPlayer(DefaultPlayer): self.clear_votes() await self.play() + async def stop(self): + """ Stops the player. """ + await self.node._send(op='stop', guildId=self.guild_id) + self.current = None + self.queue.looping = False + self.clear_votes() + def update_listeners(self, member, voice_state): if self.is_connected: vc = int(self.channel_id) @@ -137,6 +144,15 @@ class MixPlayer(DefaultPlayer): for category, votes in self.voteables.items(): votes.clear() + def enable_looping(self, looping): + if (not self.queue.looping) and looping: + self.queue.enable_looping(looping) + if track := self.current: + if looping: + self.queue.add_track(track.requester, track) + elif not looping and self.queue.looping: + self.queue.enable_looping(looping) + async def handle_event(self, event): """ Handles the given event as necessary. """ if isinstance(event, (TrackStuckEvent, TrackExceptionEvent)) or \ @@ -163,6 +179,10 @@ class MixPlayer(DefaultPlayer): self.boosted = False await self.reset_equalizer() + @property + def looping(self): + return self.queue.looping + def roundrobin(*iterables): """roundrobin('ABC', 'D', 'EF') --> A D E B F C""" @@ -183,6 +203,8 @@ class MixQueue: self.queues = OrderedDict() self.priority_queue = [] self._history = deque(maxlen=11) # 10 + current + self.looping = False + self.loop_offset = 0 def __str__(self): tmp = '' @@ -207,17 +229,25 @@ class MixQueue: return length def get_queue(self): - return list(self) + if self.looping: + offset = self.loop_offset + return list(self)[offset:] + list(self)[:offset] + else: + return list(self) def clear(self): self.queues = OrderedDict() self.priority_queue = [] + self.looping = False + self.loop_offset = 0 # if dual is true also returns global positions of tracks def get_user_queue(self, requester: int, dual: bool = False): queue = self.queues.get(requester, []) if dual and queue: pos = [self._loc_to_glob(requester, i) for i in range(len(queue))] + if self.looping: + pos = [(p + self.loop_offset) % len(self) for p in pos] combined = zip(queue, pos) return list(combined) return queue @@ -228,11 +258,26 @@ class MixQueue: self._history.append(next_track) return next_track try: - next_track = self.queues[self.first_queue].pop(0) - self._shuffle() - self._clear_empty() - self._history.append(next_track) - return next_track + if self.looping: + try: + queue = list(self) + next_track = queue[self.loop_offset] + self.loop_offset += 1 + if self.loop_offset >= len(self): + self.loop_offset = 0 + self._history.append(next_track) + return next_track + except IndexError as e: + print(e) + print(self.loop_offset) + print(len(self)) + self.loop_offset = min(self.loop_offset, len(self)-1) + else: + next_track = self.queues[self.first_queue].pop(0) + self._shuffle() + self._clear_empty() + self._history.append(next_track) + return next_track except KeyError: pass @@ -257,17 +302,31 @@ class MixQueue: def remove_user_queue(self, requester: int): user_queue = self.queues.get(requester, []) if user_queue: - self.queues.pop(requester) + if self.looping: + # Should work, kinda hacky. + for pos, track in reversed(list(enumerate(user_queue))): + self.remove_user_track(requester, pos) + else: + self.queues.pop(requester) def remove_user_track(self, requester: int, pos: int): user_queue = self.queues.get(requester) if user_queue is not None: if pos < len(user_queue): + glob_pos = self._loc_to_glob(requester, pos) + if glob_pos <= self.loop_offset: + self.loop_offset -= 1 track = user_queue.pop(pos) self._clear_empty() return track def remove_global_track(self, pos: int): + # Get the actual index of the song + pos = list(self).index(self.get_queue()[pos]) + + if pos <= self.loop_offset: + self.loop_offset -= 1 + q, pos = self._glob_to_loc(pos) if q is None or pos is None: return @@ -291,6 +350,19 @@ class MixQueue: if queue: shuffle(queue) + def enable_looping(self, looping): + if (not self.looping) and looping: # Not enabled and turning on + self.looping = looping + self.loop_offset = 0 + elif self.looping and not looping: # Enabled to turning off + tmp = [track for track in self.get_queue()] + for key, _ in self.queues.items(): + self.queues = OrderedDict() + self.looping = looping + for track in tmp: + self.add_track(track.requester, track) + self.loop_offset = 0 + # Switches the order of user queues def _shuffle(self): self.queues.move_to_end(self.first_queue) diff --git a/localization/en_en/commands.yaml b/localization/en_en/commands.yaml index 5138b1f..03341f4 100644 --- a/localization/en_en/commands.yaml +++ b/localization/en_en/commands.yaml @@ -91,6 +91,20 @@ scrub: reconnect: aliases: [rc] description: 'disconnects then reconnects the bot in case of bugged songs' +forceplay: + aliases: [forceplay, fp] + args: '' # This is just a cosmetic representation of the arguments. + description: 'Searches and plays a song from a given query.' +loop: + aliases: [loop, looping] + description: 'Loops the queue' + sub_commands: + start: + aliases: ['on', start, begin] + description: 'Starts looping the queue' + stop: + aliases: ['off', stop, end] + description: 'Stops looping the queue' # Misc info: diff --git a/localization/nb_no/commands.yaml b/localization/nb_no/commands.yaml index 7ed7617..2f75945 100644 --- a/localization/nb_no/commands.yaml +++ b/localization/nb_no/commands.yaml @@ -88,6 +88,20 @@ lyrics: scrub: aliases: [kontroller] description: 'Gir deg en kontroller som du kan styre musikken med.' +forceplay: + aliases: [tvingspill, ts] + args: '' # This is just a cosmetic representation of the arguments. + description: 'Søker opp og spiller av en sang.' +loop: + aliases: [gjenta, repeter] + description: 'Styr om køen skal gjenta seg' + sub_commands: + start: + aliases: [på, start, begynn] + description: 'Får køen til å gjenta seg' + stop: + aliases: [av, stopp, slutt] + description: 'Får køen til å slutte å gjenta seg' # Misc info: