mirror of
https://github.com/UiO-Gaming/ShiteMusicBot.git
synced 2026-11-05 13:56:51 +01:00
858 lines
36 KiB
Python
858 lines
36 KiB
Python
# Discord Packages
|
|
import discord
|
|
import lavalink
|
|
from discord import VoiceChannel
|
|
from discord.ext import commands, tasks
|
|
from lavalink.events import (
|
|
NodeChangedEvent, NodeConnectedEvent, NodeDisconnectedEvent, PlayerUpdateEvent, QueueEndEvent, TrackEndEvent,
|
|
TrackStartEvent, TrackStuckEvent)
|
|
from lavalink.models import AudioTrack
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import urllib.parse as urlparse
|
|
from typing import List, Optional, Tuple
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
from bot import MusicBot
|
|
|
|
# Bot Utilities
|
|
from musicbot.utils.mixplayer.player import MixPlayer
|
|
from ...utils import checks, timeformatter
|
|
from ...utils.thumbnailer import Thumbnailer
|
|
from ...utils.userinteraction.paginators import QueuePaginator, TextPaginator
|
|
from ...utils.userinteraction.scroller import ClearOn, Scroller
|
|
from ...utils.userinteraction.selector import SelectMode, Selector, SelectorButton, SelectorItem
|
|
from .decorators import require_playing, require_queue, require_voice_connection, voteable
|
|
from .music_errors import MusicError, PlayerNotAvailableError, WrongTextChannelError
|
|
from .voice_client import BasicVoiceClient
|
|
|
|
time_rx = re.compile('[0-9]+')
|
|
url_rx = re.compile('https?:\\/\\/(?:www\\.)?.+')
|
|
|
|
|
|
class Music(commands.Cog):
|
|
def __init__(self, bot: MusicBot):
|
|
self.bot: MusicBot = bot
|
|
self.logger = self.bot.main_logger.bot_logger.getChild("Music")
|
|
|
|
self.thumbnailer = Thumbnailer(bot=self.bot)
|
|
|
|
self.leave_timer.start()
|
|
if self.bot.lavalink is None:
|
|
raise MusicError("Lavalink is not yet initialized")
|
|
self.lavalink: lavalink.Client = self.bot.lavalink
|
|
self.lavalink.add_event_hook(self.track_hook)
|
|
|
|
async def cog_check(self, ctx):
|
|
if not ctx.guild:
|
|
raise commands.NoPrivateMessage
|
|
if textchannels := self.bot.settings.get(ctx.guild, 'channels.text', []):
|
|
if ctx.channel.id not in textchannels:
|
|
raise WrongTextChannelError('You need to be in the right text channel', channels=textchannels)
|
|
return True
|
|
|
|
async def cog_before_invoke(self, ctx):
|
|
""" Ensures a valid player exists whenever a command is run """
|
|
# Creates a new only if one doesn't exist, ensures a valid player for all checks.
|
|
if ctx.guild:
|
|
self.lavalink.player_manager.create(ctx.guild.id)
|
|
|
|
def get_player(self, guild: discord.Guild) -> MixPlayer:
|
|
player: Optional[MixPlayer] = self.lavalink.player_manager.get(guild.id)
|
|
if player is None:
|
|
raise PlayerNotAvailableError(f"Tried to get player for guild {guild.id} but got None")
|
|
return player
|
|
|
|
async def enqueue(self, ctx, track: AudioTrack, embed, standalone: bool = False,
|
|
check_max_length: bool = True) -> bool:
|
|
player = self.get_player(ctx.guild)
|
|
|
|
track_duration_str = timeformatter.format_track_duration(track)
|
|
|
|
# Only add tracks that don't exceed the max track length
|
|
maxlength = self.max_track_length(ctx.guild, player)
|
|
if (track.stream or track.duration > maxlength) and check_max_length:
|
|
embed.description = ctx.localizer.format_str("{enqueue.toolong}",
|
|
_length=track_duration_str,
|
|
_max=timeformatter.format_ms(maxlength))
|
|
return False
|
|
|
|
track.extra["thumbnail_url"] = await self.thumbnailer.identify(track.identifier, track.uri)
|
|
track.requester = ctx.author.id
|
|
|
|
# Add to player
|
|
track, pos_global, pos_local = player.add(requester=ctx.author, track=track)
|
|
|
|
if player.current is not None and not standalone:
|
|
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)
|
|
|
|
embed.title = '{enqueue.enqueued}'
|
|
|
|
if thumbnail_url := track.extra["thumbnail_url"]:
|
|
embed.set_thumbnail(url=thumbnail_url)
|
|
|
|
embed.description = f'[{track.title}]({track.uri})\n**{track_duration_str}**'
|
|
return True
|
|
|
|
def get_current_song_embed(self, ctx, include_time=False):
|
|
player = self.get_player(ctx.guild)
|
|
|
|
if not player.current:
|
|
return
|
|
|
|
song = f'**[{player.current.title}]({player.current.uri})**'
|
|
if include_time:
|
|
position = timeformatter.format_ms(player.position)
|
|
duration = timeformatter.format_track_duration(player.current)
|
|
song += f'\n({position}/{duration})'
|
|
|
|
embed = discord.Embed(color=ctx.me.color, description=song, title='{now}')
|
|
|
|
if thumbnail_url := player.current.extra["thumbnail_url"]:
|
|
embed.set_thumbnail(url=thumbnail_url)
|
|
|
|
if member := ctx.guild.get_member(player.current.requester):
|
|
embed.set_footer(text=f'{{requested_by}} {member.display_name}', icon_url=member.display_avatar.url)
|
|
|
|
embed = ctx.localizer.format_embed(embed)
|
|
return embed
|
|
|
|
def max_track_length(self, guild, player) -> float:
|
|
# Numeric constants
|
|
minimum_dynamic_length = 10
|
|
minutes_to_milliseconds = 60 * 1000
|
|
configured_max = self.bot.settings.get(guild, 'duration.max', float('inf'))
|
|
is_dynamic = self.bot.settings.get(guild, 'duration.is_dynamic', 'default_duration_type')
|
|
listeners = max(1, len(player.listeners)) # Avoid division by 0
|
|
|
|
if configured_max > minimum_dynamic_length and is_dynamic:
|
|
return minutes_to_milliseconds * max(configured_max/listeners, minimum_dynamic_length)
|
|
else:
|
|
return minutes_to_milliseconds * configured_max
|
|
|
|
async def _search_and_play_query(self, ctx, query: str, check_max_length: bool):
|
|
self.logger.debug("Query: %s" % query)
|
|
player = self.get_player(ctx.guild)
|
|
query = query.strip('<>')
|
|
|
|
if not url_rx.match(query):
|
|
query = f'ytsearch:{query}'
|
|
|
|
results: lavalink.LoadResult = 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.load_type == 'PLAYLIST_LOADED':
|
|
tracks = [track for track in results.tracks if
|
|
await self.enqueue(ctx, track, embed, True, check_max_length)]
|
|
if tracks:
|
|
embed.title = '{playlist_enqued}'
|
|
embed.description = f'{results.playlist_info.name} - {len(tracks)} {{tracks}}'
|
|
else:
|
|
# TODO: This really means no tracks passed the max_length requirement
|
|
return await ctx.send(ctx.localizer.format_str("{nothing_found}"))
|
|
else:
|
|
await self.enqueue(ctx, results.tracks[0], embed, check_max_length=check_max_length)
|
|
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
if not player.is_playing:
|
|
await player.play()
|
|
|
|
@commands.command(name='play')
|
|
@require_voice_connection(should_connect=True)
|
|
async def _play(self, ctx, *, query: str):
|
|
""" Searches and plays a song from a given query. """
|
|
await self._search_and_play_query(ctx, query, check_max_length=True)
|
|
|
|
@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, ignoring configured max duration. """
|
|
await self._search_and_play_query(ctx, query, check_max_length=False)
|
|
|
|
@commands.command(name='seek')
|
|
@checks.dj_or(alone=True, track_requester=True)
|
|
@require_voice_connection()
|
|
@require_playing(require_user_listening=True)
|
|
async def _seek(self, ctx, *, time: str):
|
|
""" Seeks to a given position in a track. """
|
|
player = self.get_player(ctx.guild)
|
|
if seconds := time_rx.search(time):
|
|
# Convert to milliseconds, include sign
|
|
milliseconds = int(seconds.group())*1000 * (-1 if time.startswith('-1') else 1)
|
|
|
|
track_time = player.position + milliseconds
|
|
await player.seek(int(track_time))
|
|
msg = ctx.localizer.format_str("{seek.track_moved}", _position=timeformatter.format_ms(track_time))
|
|
await ctx.send(msg)
|
|
else:
|
|
await ctx.send(ctx.localizer.format_str("{seek.missing_amount}"))
|
|
|
|
@commands.command(name='skip')
|
|
@require_voice_connection()
|
|
@require_playing()
|
|
@voteable(requester_override=True, react_to_vote=True)
|
|
async def _skip(self, ctx):
|
|
""" Skips the current track. """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
await player.skip()
|
|
embed = self.get_current_song_embed(ctx)
|
|
await ctx.send(ctx.localizer.format_str("{skip.skipped}"), embed=embed)
|
|
|
|
@commands.command(name='skipto')
|
|
@checks.dj_or(alone=True)
|
|
@require_playing(require_user_listening=True)
|
|
async def _skip_to(self, ctx, pos: int = 1):
|
|
""" Plays the queue from a specific point. Disregards tracks before the pos. """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
# TODO: Do all queue out of range messages the same way
|
|
if pos < 1:
|
|
return await ctx.send(ctx.localizer.format_str("{skip_to.invalid_pos}"))
|
|
if len(player.queue) < pos:
|
|
return await ctx.send(ctx.localizer.format_str("{skip_to.exceeds_queue}"))
|
|
|
|
await player.skip(pos - 1)
|
|
|
|
if player.current:
|
|
msg = ctx.localizer.format_str("{skip_to.skipped_to}", _title=player.current.title, _pos=pos)
|
|
await ctx.send(msg)
|
|
else:
|
|
self.logger.error("Expected current track to be valid due to bounds checks")
|
|
|
|
@commands.command(name='stop')
|
|
@require_voice_connection()
|
|
@require_playing(require_user_listening=True)
|
|
@voteable(DJ_override=True, react_to_vote=True)
|
|
async def _stop(self, ctx):
|
|
""" Stops the player and clears its queue. """
|
|
player = self.get_player(ctx.guild)
|
|
player.queue.clear()
|
|
await player.stop()
|
|
|
|
embed = discord.Embed(color=ctx.me.color, title='{stop}')
|
|
await ctx.send(embed=ctx.localizer.format_embed(embed))
|
|
|
|
@commands.command(name='now')
|
|
@require_playing()
|
|
async def _now(self, ctx):
|
|
if embed := self.get_current_song_embed(ctx, include_time=True):
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='queue')
|
|
@require_queue(require_member_queue=True)
|
|
async def _queue(self, ctx, *, member: Optional[discord.Member] = None):
|
|
""" Shows the player's queue. """
|
|
player = self.get_player(ctx.guild)
|
|
pagified_queue = QueuePaginator(ctx.localizer, player, color=ctx.me.color, member=member)
|
|
scroller = Scroller(ctx, pagified_queue)
|
|
await scroller.start_scrolling(ClearOn.ManualExit | ClearOn.Timeout)
|
|
|
|
@commands.command(name='myqueue')
|
|
@require_queue(require_author_queue=True)
|
|
async def _myqueue(self, ctx):
|
|
""" Shows your queue. """
|
|
await self._queue(ctx, member=ctx.author)
|
|
|
|
@commands.command(name='pause')
|
|
@checks.dj_or(alone=True)
|
|
@require_voice_connection()
|
|
@require_playing()
|
|
async def _pause(self, ctx):
|
|
""" Pauses/Resumes the current track. """
|
|
player = self.get_player(ctx.guild)
|
|
await player.set_pause(not player.paused)
|
|
if player.paused:
|
|
await ctx.send(ctx.localizer.format_str("{resume.paused}"))
|
|
else:
|
|
await ctx.send(ctx.localizer.format_str("{resume.resumed}"))
|
|
|
|
@commands.command(name='shuffle')
|
|
@require_voice_connection()
|
|
@require_queue(require_author_queue=True)
|
|
async def _shuffle(self, ctx):
|
|
""" Shuffles your queue. """
|
|
player = self.get_player(ctx.guild)
|
|
player.shuffle_user_queue(ctx.author)
|
|
await ctx.send(ctx.localizer.format_str("{shuffle}"))
|
|
|
|
@commands.command(name='move')
|
|
@require_voice_connection()
|
|
@require_queue(require_author_queue=True)
|
|
async def _move(self, ctx):
|
|
""" Moves a song in your queue. """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
# We create a new selector for each selection
|
|
def build_move_selector(ctx, queue: List[AudioTrack], title: str, first: bool):
|
|
async def return_track(track):
|
|
return track
|
|
|
|
choices = []
|
|
for index, track in enumerate(queue, start=1):
|
|
blank = " " * 6
|
|
if first:
|
|
prefix = f'`{blank}`\n`{index:<6}`'
|
|
else:
|
|
prefix = f'`{index:<3}-> `\n`{blank}`'
|
|
label = f'{prefix} [{track.title}]({track.uri})'
|
|
selection = SelectorItem(label, str(index), wrap_in_button_callback(return_track, track))
|
|
choices.append(selection)
|
|
|
|
return Selector(ctx, choices, select_mode=SelectMode.SpanningMultiSelect, use_tick_for_stop_emoji=True,
|
|
color=ctx.me.color, title=ctx.localizer.format_str(title))
|
|
|
|
message = None
|
|
page = 0
|
|
while True:
|
|
# Prompt the user for which track to move
|
|
selector = build_move_selector(ctx, player.user_queue(ctx.author), "{moved.choose_pos}", True)
|
|
message, timed_out, track_to_move = await selector.start_scrolling(ClearOn.Timeout, message, page)
|
|
page = selector.page_number
|
|
|
|
if not track_to_move or timed_out:
|
|
break
|
|
|
|
# Prompt the user for where to move it
|
|
selector = build_move_selector(ctx, player.user_queue(ctx.author), "{moved.choose_song}", False)
|
|
message, timed_out, track_to_replace = await selector.start_scrolling(ClearOn.Timeout, message, page)
|
|
page = selector.page_number
|
|
|
|
if not track_to_replace or timed_out:
|
|
break
|
|
|
|
# At this point the user has chosen two songs, we will move the first song to be in front of the second
|
|
# one in the queue. Find the index of both songs in the current queue
|
|
|
|
# Keep updating the user queue in case it changes while the user is selecting songs
|
|
user_queue = player.user_queue(ctx.author)
|
|
try:
|
|
pos_initial = user_queue.index(track_to_move[0])
|
|
pos_final = user_queue.index(track_to_replace[0])
|
|
player.move_user_track(ctx.author, pos_initial, pos_final)
|
|
except ValueError:
|
|
# Track not found in queue. Queue could have changed during selection
|
|
# if a song was skipped or the current song finished
|
|
pass
|
|
|
|
if message:
|
|
await message.delete()
|
|
|
|
async def _interactive_remove(self, ctx, queue: List[AudioTrack]):
|
|
"""
|
|
Helper function for creating an interactive selector over a given queue
|
|
in which will remove the selected tracks on exit.
|
|
"""
|
|
player = self.get_player(ctx.guild)
|
|
|
|
tracks_to_remove = []
|
|
|
|
async def update_remove_list(tracks_list, track: AudioTrack):
|
|
# It seems duplicate songs still don't satisfy equality
|
|
# which meas remove is sufficient to preserve order
|
|
# of similar items
|
|
if track in tracks_list:
|
|
tracks_list.remove(track)
|
|
else:
|
|
tracks_list.append(track)
|
|
|
|
# The callback from the selector takes a discord interaction and
|
|
# the button class. We wrap the track remove callback to handle those
|
|
# arguments and update button color
|
|
def add_change_button_color(func, *args):
|
|
async def inner(_, button: SelectorButton):
|
|
if button.style == discord.ButtonStyle.red:
|
|
button.style = discord.ButtonStyle.gray
|
|
else:
|
|
button.style = discord.ButtonStyle.red
|
|
return await func(*args)
|
|
return inner
|
|
|
|
selector_buttons = []
|
|
# Build each selection from the queue, a visible string and a callback.
|
|
for index, track in enumerate(queue, start=1):
|
|
requester = self.bot.get_user(track.requester)
|
|
selector_buttons.append(
|
|
SelectorItem(f'`{index}` [{track.title}]({track.uri}) - {requester.mention if requester else ""}',
|
|
str(index), add_change_button_color(update_remove_list, tracks_to_remove, track)))
|
|
|
|
remove_selector = Selector(ctx, selector_buttons, select_mode=SelectMode.MultiSelect,
|
|
use_tick_for_stop_emoji=True, color=ctx.me.color, title='Select songs to remove')
|
|
_, timed_out, _ = await remove_selector.start_scrolling(ClearOn.AnyExit)
|
|
|
|
# If any tracks were removed create a scroller for navigating them
|
|
if tracks_to_remove and not timed_out:
|
|
paginator = TextPaginator(color=ctx.me.color, title="Removed")
|
|
|
|
tracks_removed: List[Tuple[int, str]] = []
|
|
for track in tracks_to_remove:
|
|
if remove_result := player.remove_track(track):
|
|
(position, removed_track) = remove_result
|
|
if requester := self.bot.get_user(removed_track.requester):
|
|
tracks_removed.append((position, f"{removed_track.title} - {requester.mention}"))
|
|
|
|
# remove_track returns the global queue index of the track,
|
|
# so we display the removed tracks in "queue order"
|
|
for removed in sorted(tracks_removed, key=lambda x: x[0]):
|
|
paginator.add_line(removed[1])
|
|
|
|
paginator.close_page()
|
|
scroller = Scroller(ctx, paginator)
|
|
await scroller.start_scrolling(ClearOn.ManualExit | ClearOn.Timeout)
|
|
|
|
@commands.command(name='remove')
|
|
@require_voice_connection()
|
|
@require_queue(require_author_queue=True)
|
|
async def _remove(self, ctx):
|
|
""" Remove a song from your queue. """
|
|
player = self.get_player(ctx.guild)
|
|
return await self._interactive_remove(ctx, player.user_queue(ctx.author))
|
|
|
|
@commands.command(name="DJremove")
|
|
@checks.dj_or()
|
|
@require_voice_connection()
|
|
@require_queue()
|
|
async def _djremove(self, ctx, member: Optional[discord.Member] = None):
|
|
""" Remove a song from either the global queue or a users queue"""
|
|
player = self.get_player(ctx.guild)
|
|
# TODO: add way to allow "member" as regular arg in require_queue() instead of only kwargs
|
|
queue = player.user_queue(member) if member else player.global_queue()
|
|
return await self._interactive_remove(ctx, queue)
|
|
|
|
@commands.command(name='removeuser')
|
|
@checks.dj_or(alone=True)
|
|
@require_voice_connection()
|
|
@require_queue(require_member_queue=True)
|
|
async def _user_queue_remove(self, ctx, *, member: discord.Member):
|
|
""" Remove a song from either the global queue or a users queue"""
|
|
player = self.get_player(ctx.guild)
|
|
player.remove_user_queue(member)
|
|
|
|
embed = discord.Embed(description="{dj_remove_user}", color=ctx.me.color)
|
|
embed = ctx.localizer.format_embed(embed, _id=member.id)
|
|
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='search')
|
|
@require_voice_connection(should_connect=True)
|
|
async def _search(self, ctx, *, query):
|
|
player = self.get_player(ctx.guild)
|
|
|
|
self.logger.debug("Query: %s" % query)
|
|
if not query.startswith('ytsearch:') and not query.startswith('scsearch:'):
|
|
query = 'ytsearch:' + query
|
|
|
|
results = await player.node.get_tracks(query)
|
|
|
|
if not results or not results['tracks']:
|
|
embed = discord.Embed(description='{nothing_found}', color=0x36393F)
|
|
embed = ctx.localizer.format_embed(embed)
|
|
return await ctx.send(embed=embed)
|
|
|
|
# This is the base embed that will be modified by the selector
|
|
embed = discord.Embed(color=ctx.me.color)
|
|
buttons = []
|
|
for i, track in enumerate(results['tracks'], start=1):
|
|
duration = timeformatter.format_ms(int(track.duration))
|
|
interaction = SelectorItem(f'`{i}` [{track.title}]({track.uri}) `{duration}`', str(i),
|
|
wrap_in_button_callback(
|
|
self.enqueue, ctx, track, embed))
|
|
buttons.append(interaction)
|
|
|
|
search_selector = Selector(ctx, buttons, select_mode=SelectMode.SingleSelect,
|
|
color=ctx.me.color, title=ctx.localizer.format_str('{results}'))
|
|
message, timed_out, _ = await search_selector.start_scrolling(ClearOn.Timeout)
|
|
|
|
if timed_out:
|
|
return
|
|
|
|
await message.edit(embed=ctx.localizer.format_embed(embed), view=None)
|
|
|
|
if not player.is_playing:
|
|
await player.play()
|
|
|
|
@commands.command(name='disconnect')
|
|
@require_voice_connection()
|
|
@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.get_player(ctx.guild)
|
|
|
|
player.queue.clear()
|
|
await player.stop()
|
|
await ctx.voice_client.disconnect()
|
|
embed = discord.Embed(description='{disconnect.disconnected}', color=ctx.me.color)
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='reconnect')
|
|
@require_voice_connection()
|
|
@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.get_player(ctx.guild)
|
|
current_channel = player.channel_id
|
|
|
|
async def inner_reconnect():
|
|
await player.stop()
|
|
if ctx.voice_client:
|
|
await ctx.voice_client.disconnect()
|
|
await asyncio.sleep(1) # Pretend stuff is happening/give everything some time to reset.
|
|
channel = ctx.guild.get_channel(current_channel)
|
|
await channel.connect(cls=BasicVoiceClient)
|
|
|
|
if player.current:
|
|
track = player.current
|
|
start_time = player.position
|
|
await inner_reconnect()
|
|
if track:
|
|
await player.play(track, start_time=int(start_time))
|
|
else:
|
|
await inner_reconnect()
|
|
|
|
@commands.command(name='volume')
|
|
@checks.dj_or(alone=True, track_requester=True)
|
|
@require_playing(require_user_listening=True)
|
|
async def _volume(self, ctx, volume: Optional[int] = None):
|
|
""" Changes the player's volume. Must be between 0 and 1000. Error Handling for that is done by Lavalink. """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
if not volume:
|
|
return await ctx.send(f'🔈 | {player.volume}%')
|
|
|
|
is_dj = checks.is_dj(ctx)
|
|
is_admin = getattr(ctx.author.guild_permissions, 'administrator', None) is True
|
|
if player.current and (int(player.current.requester) == ctx.author.id and not is_dj and not is_admin):
|
|
if not 50 <= volume <= 125:
|
|
return await ctx.send(ctx.localizer.format_str("{volume.out_of_range}"))
|
|
await player.set_volume(volume)
|
|
|
|
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='forcedisconnect')
|
|
@checks.dj_or(alone=True)
|
|
async def _forcedisconnect(self, ctx):
|
|
""" Attempts to force disconnect the bot without checking if it is connected initially. """
|
|
try:
|
|
player = self.get_player(ctx.guild)
|
|
player.queue.clear()
|
|
await player.stop()
|
|
except Exception as e:
|
|
self.logger.error("Error forcedisconnecting")
|
|
self.logger.exception(e)
|
|
await ctx.voice_client.disconnect(force=True)
|
|
embed = discord.Embed(description='{disconnect.disconnected}', color=ctx.me.color)
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='normalize')
|
|
@checks.dj_or(alone=True)
|
|
@require_voice_connection()
|
|
async def _normalize(self, ctx):
|
|
""" Reset the equalizer and volume """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
await player.set_volume(100)
|
|
await player.bassboost(False)
|
|
await player.nightcoreify(False)
|
|
|
|
embed = discord.Embed(description='{volume.reset}', color=ctx.me.color)
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='boost')
|
|
@checks.dj_or(alone=True)
|
|
async def _boost(self, ctx, boost: bool = False):
|
|
""" Set the equalizer to bass boost the music """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
if boost is not None:
|
|
await player.bassboost(boost)
|
|
|
|
embed = discord.Embed(color=ctx.me.color)
|
|
embed.description = '{boost.on}' if player.boosted else '{boost.off}'
|
|
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='nightcore')
|
|
@checks.dj_or(alone=True)
|
|
async def _nightcore(self, ctx, boost: bool = False):
|
|
""" Set a filter mimicking nightcore the music """
|
|
player = self.get_player(ctx.guild)
|
|
|
|
await player.nightcoreify(boost)
|
|
|
|
embed = discord.Embed(color=ctx.me.color)
|
|
embed.description = '{nightcore.on}' if player.nightcore_enabled else '{nightcore.off}'
|
|
embed = ctx.localizer.format_embed(embed)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name='history')
|
|
async def _history(self, ctx):
|
|
""" Show the last 10 songs played """
|
|
player = self.get_player(ctx.guild)
|
|
history = player.get_history()
|
|
if not history:
|
|
embed = discord.Embed(description='{history.empty}', color=ctx.me.color)
|
|
embed = ctx.localizer.format_embed(embed)
|
|
return await ctx.send(embed=embed)
|
|
track = history[0]
|
|
description = ctx.localizer.format_str("{history.current}", _title=track.title, _uri=track.uri,
|
|
_id=track.requester) + '\n\n'
|
|
description += ctx.localizer.format_str("{history.previous}", _len=len(history)-1) + '\n'
|
|
for index, track in enumerate(history[1:], start=1):
|
|
description += ctx.localizer.format_str("{history.track}", _index=-index, _title=track.title,
|
|
_uri=track.uri, _id=track.requester) + '\n'
|
|
|
|
embed = discord.Embed(title=ctx.localizer.format_str('{history.title}'), color=ctx.me.color,
|
|
description=description)
|
|
|
|
if current := player.current:
|
|
if thumbnail_url := current.extra["thumbnail_url"]:
|
|
embed.set_thumbnail(url=thumbnail_url)
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.cooldown(1, 5, commands.BucketType.guild)
|
|
@commands.command(name='lyrics')
|
|
async def _lyrics(self, ctx, *query: str):
|
|
|
|
# TODO:
|
|
player = self.get_player(ctx.guild)
|
|
|
|
genius_access_token = self.bot.APIkeys.get('genius', None)
|
|
|
|
if genius_access_token is None:
|
|
return await ctx.send('Missing API key')
|
|
|
|
excluded_words = ['music', 'video', 'version', 'original', 'lyrics', 'lyric',
|
|
'official', 'live', 'instrumental', 'audio', 'hd']
|
|
|
|
query_as_string = ' '.join(query)
|
|
|
|
if not query_as_string and player.is_playing and player.current:
|
|
query_as_string = player.current.title
|
|
query_as_string = re.sub(r'[-()_\[\]]', '', query_as_string)
|
|
filtered_words = [word for word in query_as_string.split() if word.lower() not in excluded_words]
|
|
query_as_string = ' '.join(filtered_words)
|
|
|
|
async def get_site_content(url):
|
|
async with self.bot.session.get(url) as resp:
|
|
response = await resp.read()
|
|
return response.decode('utf-8')
|
|
|
|
embed = discord.Embed(description=':mag_right:')
|
|
status_msg = await ctx.send(embed=embed)
|
|
|
|
try:
|
|
url = 'https://api.genius.com/search?' + urlparse.urlencode(
|
|
{'access_token': genius_access_token, 'q': query_as_string})
|
|
result = await get_site_content(url)
|
|
response = json.loads(result)
|
|
song_id = response['response']['hits'][0]['result']['id']
|
|
except Exception:
|
|
embed = discord.Embed(description=':x:', color=0xFF0000)
|
|
return await status_msg.edit(embed=embed)
|
|
|
|
result = await get_site_content(f'https://api.genius.com/songs/{song_id}?access_token={genius_access_token}')
|
|
song = json.loads(result)['response']['song']
|
|
|
|
response = await get_site_content(song['url'])
|
|
scraped_data = BeautifulSoup(response, 'html.parser')
|
|
lyrics = scraped_data.find(class_='lyrics').get_text()
|
|
|
|
paginator = TextPaginator(max_size=2000, max_lines=50, color=0xFFFF64)
|
|
for line in lyrics.split('\n'):
|
|
paginator.add_line(line)
|
|
|
|
await status_msg.delete()
|
|
|
|
paginator.pages[0].url = song['url']
|
|
paginator.pages[0].title = song['full_title']
|
|
paginator.pages[0].set_thumbnail(url=song['header_image_thumbnail_url'])
|
|
paginator.pages[0].set_author(name='Genius', icon_url='https://i.imgur.com/NmCTsoF.png')
|
|
|
|
if len(paginator.pages) < 4:
|
|
for page in paginator.pages:
|
|
await ctx.send(embed=page)
|
|
else:
|
|
paginator.add_page_indicator(ctx.localizer)
|
|
await Scroller(ctx, paginator).start_scrolling(ClearOn.AnyExit)
|
|
|
|
@commands.command(name='scrub')
|
|
@checks.dj_or(alone=True)
|
|
@require_voice_connection()
|
|
@require_playing(require_user_listening=True)
|
|
async def _scrub(self, ctx):
|
|
""" Shows a set of controls which can be used to skip forward or backwards in the song """
|
|
player = self.get_player(ctx.guild)
|
|
controls = '{scrub.controls}'
|
|
|
|
def seek_callback(player: MixPlayer, seconds):
|
|
async def inner(_interaction, _button):
|
|
newpos = player.position + seconds * 1000
|
|
return await player.seek(newpos)
|
|
return inner
|
|
|
|
def toggle_pause_callback(player: MixPlayer):
|
|
async def inner(_interaction, button):
|
|
should_pause = not player.paused
|
|
button.style = discord.ButtonStyle.red if should_pause else discord.ButtonStyle.gray
|
|
return await player.set_pause(should_pause)
|
|
return inner
|
|
|
|
scrubber_controls = [
|
|
SelectorItem("", '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', seek_callback(player, -1000)),
|
|
SelectorItem("", '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}', seek_callback(player, -15)),
|
|
SelectorItem("", '\N{Black Right-Pointing Triangle with Double Vertical Bar}',
|
|
toggle_pause_callback(player)),
|
|
SelectorItem("", '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}', seek_callback(player, 15)),
|
|
SelectorItem("", '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', seek_callback(player, 1000))
|
|
]
|
|
scrubber = Selector(ctx, scrubber_controls, select_mode=SelectMode.MultiSelect, use_tick_for_stop_emoji=True,
|
|
default_text=ctx.localizer.format_str(controls), color=ctx.me.color)
|
|
await scrubber.start_scrolling(ClearOn.AnyExit)
|
|
|
|
@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.get_player(ctx.guild)
|
|
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.get_player(ctx.guild)
|
|
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.get_player(ctx.guild)
|
|
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)
|
|
|
|
async def cog_unload(self):
|
|
self.lavalink._event_hooks.clear()
|
|
|
|
async def track_hook(self, event):
|
|
if isinstance(event, TrackEndEvent):
|
|
if channel := self.bot.get_channel(event.player.fetch('channel')):
|
|
if isinstance(channel, VoiceChannel):
|
|
player = self.get_player(channel.guild)
|
|
player.skip_voters.clear()
|
|
|
|
if isinstance(event, TrackStartEvent):
|
|
pass
|
|
if isinstance(event, QueueEndEvent):
|
|
if channel := self.bot.get_channel(event.player.fetch('channel')):
|
|
if isinstance(channel, VoiceChannel):
|
|
await self.check_leave_voice(channel.guild)
|
|
if isinstance(event, PlayerUpdateEvent):
|
|
pass
|
|
if isinstance(event, NodeDisconnectedEvent):
|
|
pass
|
|
if isinstance(event, NodeConnectedEvent):
|
|
pass
|
|
if isinstance(event, NodeChangedEvent):
|
|
pass
|
|
if isinstance(event, TrackStuckEvent):
|
|
pass
|
|
|
|
@commands.Cog.listener()
|
|
async def on_voice_state_update(self, member: discord.Member, _: discord.VoiceState, after: discord.VoiceState):
|
|
""" Updates listeners when the bot or a user changes voice state """
|
|
if self.bot.user is None:
|
|
return # Bot not logged in
|
|
if member.id == self.bot.user.id and after.channel is not None:
|
|
voice_channel = after.channel
|
|
try:
|
|
player = self.get_player(member.guild)
|
|
except PlayerNotAvailableError: # This is expected if we have not created a player for the guild yet
|
|
return
|
|
player.clear_listeners()
|
|
for member in voice_channel.members:
|
|
if not member.bot:
|
|
player.update_listeners(member, member.voice)
|
|
|
|
if not member.bot:
|
|
try:
|
|
player = self.get_player(member.guild)
|
|
except PlayerNotAvailableError: # This is expected if we have not created a player for the guild yet
|
|
return
|
|
player.update_listeners(member, after)
|
|
await self.check_leave_voice(member.guild)
|
|
|
|
async def check_leave_voice(self, guild: discord.Guild):
|
|
""" Checks if the bot should leave the voice channel """
|
|
# TODO, disconnect timer?
|
|
player = self.get_player(guild)
|
|
if len(player.listeners) == 0 and player.is_connected:
|
|
if player.queue.empty and player.current is None:
|
|
await player.stop()
|
|
if voice_client := guild.voice_client:
|
|
await voice_client.disconnect(force=False)
|
|
|
|
async def leave_check(self):
|
|
for player_id in self.lavalink.player_manager.players:
|
|
if guild := self.bot.get_guild(player_id):
|
|
await self.check_leave_voice(guild)
|
|
|
|
@tasks.loop(seconds=10.0)
|
|
async def leave_timer(self):
|
|
try:
|
|
await self.leave_check()
|
|
except Exception as err:
|
|
self.logger.error("Error in leave_timer loop")
|
|
self.logger.exception(err)
|
|
|
|
|
|
def wrap_in_button_callback(func, *args):
|
|
"""
|
|
Take a callback and convert it to the form required by a SelectorItem
|
|
"""
|
|
async def inner(_interaction, _button):
|
|
return await func(*args)
|
|
return inner
|
|
|
|
|
|
async def setup(bot):
|
|
await bot.add_cog(Music(bot))
|