mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-21 18:27:59 -05:00
Merge V3/feature/audio into V3/develop (a.k.a. audio refactor) (#3459)
This commit is contained in:
23
redbot/cogs/audio/core/utilities/__init__.py
Normal file
23
redbot/cogs/audio/core/utilities/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
from .equalizer import EqualizerUtilities
|
||||
from .formatting import FormattingUtilities
|
||||
from .local_tracks import LocalTrackUtilities
|
||||
from .miscellaneous import MiscellaneousUtilities
|
||||
from .player import PlayerUtilities
|
||||
from .playlists import PlaylistUtilities
|
||||
from .queue import QueueUtilities
|
||||
from .validation import ValidationUtilities
|
||||
|
||||
|
||||
class Utilities(
|
||||
EqualizerUtilities,
|
||||
FormattingUtilities,
|
||||
LocalTrackUtilities,
|
||||
MiscellaneousUtilities,
|
||||
PlayerUtilities,
|
||||
PlaylistUtilities,
|
||||
QueueUtilities,
|
||||
ValidationUtilities,
|
||||
metaclass=CompositeMetaClass,
|
||||
):
|
||||
"""Class joining all utility subclasses"""
|
||||
174
redbot/cogs/audio/core/utilities/equalizer.py
Normal file
174
redbot/cogs/audio/core/utilities/equalizer.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import box
|
||||
|
||||
from ...equalizer import Equalizer
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.equalizer")
|
||||
|
||||
|
||||
class EqualizerUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def _apply_gain(self, guild_id: int, band: int, gain: float) -> None:
|
||||
const = {
|
||||
"op": "equalizer",
|
||||
"guildId": str(guild_id),
|
||||
"bands": [{"band": band, "gain": gain}],
|
||||
}
|
||||
|
||||
try:
|
||||
await lavalink.get_player(guild_id).node.send({**const})
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
async def _apply_gains(self, guild_id: int, gains: List[float]) -> None:
|
||||
const = {
|
||||
"op": "equalizer",
|
||||
"guildId": str(guild_id),
|
||||
"bands": [{"band": x, "gain": y} for x, y in enumerate(gains)],
|
||||
}
|
||||
|
||||
try:
|
||||
await lavalink.get_player(guild_id).node.send({**const})
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
async def _eq_check(self, ctx: commands.Context, player: lavalink.Player) -> None:
|
||||
eq = player.fetch("eq", Equalizer())
|
||||
|
||||
config_bands = await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands()
|
||||
if not config_bands:
|
||||
config_bands = eq.bands
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
|
||||
|
||||
if eq.bands != config_bands:
|
||||
band_num = list(range(0, eq.band_count))
|
||||
band_value = config_bands
|
||||
eq_dict = {}
|
||||
for k, v in zip(band_num, band_value):
|
||||
eq_dict[k] = v
|
||||
for band, value in eq_dict.items():
|
||||
eq.set_gain(band, value)
|
||||
player.store("eq", eq)
|
||||
await self._apply_gains(ctx.guild.id, config_bands)
|
||||
|
||||
async def _eq_interact(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
player: lavalink.Player,
|
||||
eq: Equalizer,
|
||||
message: discord.Message,
|
||||
selected: int,
|
||||
) -> None:
|
||||
player.store("eq", eq)
|
||||
emoji = {
|
||||
"far_left": "\N{BLACK LEFT-POINTING TRIANGLE}",
|
||||
"one_left": "\N{LEFTWARDS BLACK ARROW}",
|
||||
"max_output": "\N{BLACK UP-POINTING DOUBLE TRIANGLE}",
|
||||
"output_up": "\N{UP-POINTING SMALL RED TRIANGLE}",
|
||||
"output_down": "\N{DOWN-POINTING SMALL RED TRIANGLE}",
|
||||
"min_output": "\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}",
|
||||
"one_right": "\N{BLACK RIGHTWARDS ARROW}",
|
||||
"far_right": "\N{BLACK RIGHT-POINTING TRIANGLE}",
|
||||
"reset": "\N{BLACK CIRCLE FOR RECORD}",
|
||||
"info": "\N{INFORMATION SOURCE}",
|
||||
}
|
||||
selector = f'{" " * 8}{" " * selected}^^'
|
||||
try:
|
||||
await message.edit(content=box(f"{eq.visualise()}\n{selector}", lang="ini"))
|
||||
except discord.errors.NotFound:
|
||||
return
|
||||
try:
|
||||
(react_emoji, react_user) = await self._get_eq_reaction(ctx, message, emoji)
|
||||
except TypeError:
|
||||
return
|
||||
|
||||
if not react_emoji:
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
|
||||
await self._clear_react(message, emoji)
|
||||
|
||||
if react_emoji == "\N{LEFTWARDS BLACK ARROW}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
await self._eq_interact(ctx, player, eq, message, max(selected - 1, 0))
|
||||
|
||||
if react_emoji == "\N{BLACK RIGHTWARDS ARROW}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
await self._eq_interact(ctx, player, eq, message, min(selected + 1, 14))
|
||||
|
||||
if react_emoji == "\N{UP-POINTING SMALL RED TRIANGLE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
_max = float("{:.2f}".format(min(eq.get_gain(selected) + 0.1, 1.0)))
|
||||
eq.set_gain(selected, _max)
|
||||
await self._apply_gain(ctx.guild.id, selected, _max)
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{DOWN-POINTING SMALL RED TRIANGLE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
_min = float("{:.2f}".format(max(eq.get_gain(selected) - 0.1, -0.25)))
|
||||
eq.set_gain(selected, _min)
|
||||
await self._apply_gain(ctx.guild.id, selected, _min)
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{BLACK UP-POINTING DOUBLE TRIANGLE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
_max = 1.0
|
||||
eq.set_gain(selected, _max)
|
||||
await self._apply_gain(ctx.guild.id, selected, _max)
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
_min = -0.25
|
||||
eq.set_gain(selected, _min)
|
||||
await self._apply_gain(ctx.guild.id, selected, _min)
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{BLACK LEFT-POINTING TRIANGLE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
selected = 0
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{BLACK RIGHT-POINTING TRIANGLE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
selected = 14
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{BLACK CIRCLE FOR RECORD}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
for band in range(eq.band_count):
|
||||
eq.set_gain(band, 0.0)
|
||||
await self._apply_gains(ctx.guild.id, eq.bands)
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
if react_emoji == "\N{INFORMATION SOURCE}":
|
||||
await self.remove_react(message, react_emoji, react_user)
|
||||
await ctx.send_help(self.command_equalizer)
|
||||
await self._eq_interact(ctx, player, eq, message, selected)
|
||||
|
||||
async def _eq_msg_clear(self, eq_message: discord.Message):
|
||||
if eq_message is not None:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await eq_message.delete()
|
||||
|
||||
async def _get_eq_reaction(self, ctx: commands.Context, message: discord.Message, emoji):
|
||||
try:
|
||||
reaction, user = await self.bot.wait_for(
|
||||
"reaction_add",
|
||||
check=lambda r, u: r.message.id == message.id
|
||||
and u.id == ctx.author.id
|
||||
and r.emoji in emoji.values(),
|
||||
timeout=30,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._clear_react(message, emoji)
|
||||
return None
|
||||
else:
|
||||
return reaction.emoji, user
|
||||
376
redbot/cogs/audio/core/utilities/formatting.py
Normal file
376
redbot/cogs/audio/core/utilities/formatting.py
Normal file
@@ -0,0 +1,376 @@
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from discord.embeds import EmptyEmbed
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import box, escape
|
||||
|
||||
from ...audio_dataclasses import LocalPath, Query
|
||||
from ...audio_logging import IS_DEBUG
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.formatting")
|
||||
|
||||
RE_SQUARE = re.compile(r"[\[\]]")
|
||||
|
||||
|
||||
class FormattingUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def _genre_search_button_action(
|
||||
self, ctx: commands.Context, options: List, emoji: str, page: int, playlist: bool = False
|
||||
) -> str:
|
||||
try:
|
||||
if emoji == "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = options[0 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = options[1 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = options[2 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = options[3 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = options[4 + (page * 5)]
|
||||
else:
|
||||
search_choice = options[0 + (page * 5)]
|
||||
except IndexError:
|
||||
search_choice = options[-1]
|
||||
if not playlist:
|
||||
return list(search_choice.items())[0]
|
||||
else:
|
||||
return search_choice.get("uri")
|
||||
|
||||
async def _build_genre_search_page(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
tracks: List,
|
||||
page_num: int,
|
||||
title: str,
|
||||
playlist: bool = False,
|
||||
) -> discord.Embed:
|
||||
search_num_pages = math.ceil(len(tracks) / 5)
|
||||
search_idx_start = (page_num - 1) * 5
|
||||
search_idx_end = search_idx_start + 5
|
||||
search_list = ""
|
||||
async for i, entry in AsyncIter(tracks[search_idx_start:search_idx_end]).enumerate(
|
||||
start=search_idx_start
|
||||
):
|
||||
search_track_num = i + 1
|
||||
if search_track_num > 5:
|
||||
search_track_num = search_track_num % 5
|
||||
if search_track_num == 0:
|
||||
search_track_num = 5
|
||||
if playlist:
|
||||
name = "**[{}]({})** - {} {}".format(
|
||||
entry.get("name"), entry.get("url"), str(entry.get("tracks")), _("tracks")
|
||||
)
|
||||
else:
|
||||
name = f"{list(entry.keys())[0]}"
|
||||
search_list += f"`{search_track_num}.` {name}\n"
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=title, description=search_list
|
||||
)
|
||||
embed.set_footer(
|
||||
text=_("Page {page_num}/{total_pages}").format(
|
||||
page_num=page_num, total_pages=search_num_pages
|
||||
)
|
||||
)
|
||||
return embed
|
||||
|
||||
async def _search_button_action(
|
||||
self, ctx: commands.Context, tracks: List, emoji: str, page: int
|
||||
):
|
||||
if not self._player_check(ctx):
|
||||
if self.lavalink_connection_aborted:
|
||||
msg = _("Connection to Lavalink has failed.")
|
||||
description = EmptyEmbed
|
||||
if await self.bot.is_owner(ctx.author):
|
||||
description = _("Please check your console or logs for details.")
|
||||
return await self.send_embed_msg(ctx, title=msg, description=description)
|
||||
try:
|
||||
await lavalink.connect(ctx.author.voice.channel)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
player.store("connect", datetime.datetime.utcnow())
|
||||
except AttributeError:
|
||||
return await self.send_embed_msg(ctx, title=_("Connect to a voice channel first."))
|
||||
except IndexError:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Connection to Lavalink has not yet been established.")
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
if len(player.queue) >= 10000:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Play Tracks"), description=_("Queue size limit reached.")
|
||||
)
|
||||
if not await self.maybe_charge_requester(ctx, guild_data["jukebox_price"]):
|
||||
return
|
||||
try:
|
||||
if emoji == "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = tracks[0 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = tracks[1 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = tracks[2 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = tracks[3 + (page * 5)]
|
||||
elif emoji == "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}":
|
||||
search_choice = tracks[4 + (page * 5)]
|
||||
else:
|
||||
search_choice = tracks[0 + (page * 5)]
|
||||
except IndexError:
|
||||
search_choice = tracks[-1]
|
||||
if not hasattr(search_choice, "is_local") and getattr(search_choice, "uri", None):
|
||||
description = self.get_track_description(search_choice, self.local_folder_current_path)
|
||||
else:
|
||||
search_choice = Query.process_input(search_choice, self.local_folder_current_path)
|
||||
if search_choice.is_local:
|
||||
if (
|
||||
search_choice.local_track_path.exists()
|
||||
and search_choice.local_track_path.is_dir()
|
||||
):
|
||||
return await ctx.invoke(self.command_search, query=search_choice)
|
||||
elif (
|
||||
search_choice.local_track_path.exists()
|
||||
and search_choice.local_track_path.is_file()
|
||||
):
|
||||
search_choice.invoked_from = "localtrack"
|
||||
return await ctx.invoke(self.command_play, query=search_choice)
|
||||
|
||||
songembed = discord.Embed(title=_("Track Enqueued"), description=description)
|
||||
queue_dur = await self.queue_duration(ctx)
|
||||
queue_total_duration = self.format_time(queue_dur)
|
||||
before_queue_length = len(player.queue)
|
||||
|
||||
if not await self.is_query_allowed(
|
||||
self.config,
|
||||
ctx.guild,
|
||||
(
|
||||
f"{search_choice.title} {search_choice.author} {search_choice.uri} "
|
||||
f"{str(Query.process_input(search_choice, self.local_folder_current_path))}"
|
||||
),
|
||||
):
|
||||
if IS_DEBUG:
|
||||
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("This track is not allowed in this server.")
|
||||
)
|
||||
elif guild_data["maxlength"] > 0:
|
||||
|
||||
if self.is_track_too_long(search_choice.length, guild_data["maxlength"]):
|
||||
player.add(ctx.author, search_choice)
|
||||
player.maybe_shuffle()
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author
|
||||
)
|
||||
else:
|
||||
return await self.send_embed_msg(ctx, title=_("Track exceeds maximum length."))
|
||||
else:
|
||||
player.add(ctx.author, search_choice)
|
||||
player.maybe_shuffle()
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author
|
||||
)
|
||||
|
||||
if not guild_data["shuffle"] and queue_dur > 0:
|
||||
songembed.set_footer(
|
||||
text=_("{time} until track playback: #{position} in queue").format(
|
||||
time=queue_total_duration, position=before_queue_length + 1
|
||||
)
|
||||
)
|
||||
|
||||
if not player.current:
|
||||
await player.play()
|
||||
return await self.send_embed_msg(ctx, embed=songembed)
|
||||
|
||||
def _format_search_options(self, search_choice):
|
||||
query = Query.process_input(search_choice, self.local_folder_current_path)
|
||||
description = self.get_track_description(search_choice, self.local_folder_current_path)
|
||||
return description, query
|
||||
|
||||
async def _build_search_page(
|
||||
self, ctx: commands.Context, tracks: List, page_num: int
|
||||
) -> discord.Embed:
|
||||
search_num_pages = math.ceil(len(tracks) / 5)
|
||||
search_idx_start = (page_num - 1) * 5
|
||||
search_idx_end = search_idx_start + 5
|
||||
search_list = ""
|
||||
command = ctx.invoked_with
|
||||
folder = False
|
||||
async for i, track in AsyncIter(tracks[search_idx_start:search_idx_end]).enumerate(
|
||||
start=search_idx_start
|
||||
):
|
||||
search_track_num = i + 1
|
||||
if search_track_num > 5:
|
||||
search_track_num = search_track_num % 5
|
||||
if search_track_num == 0:
|
||||
search_track_num = 5
|
||||
try:
|
||||
query = Query.process_input(track.uri, self.local_folder_current_path)
|
||||
if query.is_local:
|
||||
search_list += "`{0}.` **{1}**\n[{2}]\n".format(
|
||||
search_track_num,
|
||||
track.title,
|
||||
LocalPath(track.uri, self.local_folder_current_path).to_string_user(),
|
||||
)
|
||||
else:
|
||||
search_list += "`{0}.` **[{1}]({2})**\n".format(
|
||||
search_track_num, track.title, track.uri
|
||||
)
|
||||
except AttributeError:
|
||||
track = Query.process_input(track, self.local_folder_current_path)
|
||||
if track.is_local and command != "search":
|
||||
search_list += "`{}.` **{}**\n".format(
|
||||
search_track_num, track.to_string_user()
|
||||
)
|
||||
if track.is_album:
|
||||
folder = True
|
||||
else:
|
||||
search_list += "`{}.` **{}**\n".format(
|
||||
search_track_num, track.to_string_user()
|
||||
)
|
||||
if hasattr(tracks[0], "uri") and hasattr(tracks[0], "track_identifier"):
|
||||
title = _("Tracks Found:")
|
||||
footer = _("search results")
|
||||
elif folder:
|
||||
title = _("Folders Found:")
|
||||
footer = _("local folders")
|
||||
else:
|
||||
title = _("Files Found:")
|
||||
footer = _("local tracks")
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=title, description=search_list
|
||||
)
|
||||
embed.set_footer(
|
||||
text=(_("Page {page_num}/{total_pages}") + " | {num_results} {footer}").format(
|
||||
page_num=page_num,
|
||||
total_pages=search_num_pages,
|
||||
num_results=len(tracks),
|
||||
footer=footer,
|
||||
)
|
||||
)
|
||||
return embed
|
||||
|
||||
def get_track_description(
|
||||
self, track, local_folder_current_path, shorten=False
|
||||
) -> Optional[str]:
|
||||
"""Get the user facing formatted track name"""
|
||||
string = None
|
||||
if track and getattr(track, "uri", None):
|
||||
query = Query.process_input(track.uri, local_folder_current_path)
|
||||
if query.is_local or "localtracks/" in track.uri:
|
||||
if (
|
||||
hasattr(track, "title")
|
||||
and track.title != "Unknown title"
|
||||
and hasattr(track, "author")
|
||||
and track.author != "Unknown artist"
|
||||
):
|
||||
if shorten:
|
||||
string = f"{track.author} - {track.title}"
|
||||
if len(string) > 40:
|
||||
string = "{}...".format((string[:40]).rstrip(" "))
|
||||
string = f'**{escape(f"{string}", formatting=True)}**'
|
||||
else:
|
||||
string = (
|
||||
f'**{escape(f"{track.author} - {track.title}", formatting=True)}**'
|
||||
+ escape(f"\n{query.to_string_user()} ", formatting=True)
|
||||
)
|
||||
elif hasattr(track, "title") and track.title != "Unknown title":
|
||||
if shorten:
|
||||
string = f"{track.title}"
|
||||
if len(string) > 40:
|
||||
string = "{}...".format((string[:40]).rstrip(" "))
|
||||
string = f'**{escape(f"{string}", formatting=True)}**'
|
||||
else:
|
||||
string = f'**{escape(f"{track.title}", formatting=True)}**' + escape(
|
||||
f"\n{query.to_string_user()} ", formatting=True
|
||||
)
|
||||
else:
|
||||
string = query.to_string_user()
|
||||
if shorten and len(string) > 40:
|
||||
string = "{}...".format((string[:40]).rstrip(" "))
|
||||
string = f'**{escape(f"{string}", formatting=True)}**'
|
||||
else:
|
||||
if track.author.lower() not in track.title.lower():
|
||||
title = f"{track.title} - {track.author}"
|
||||
else:
|
||||
title = track.title
|
||||
string = f"{title}"
|
||||
if shorten and len(string) > 40:
|
||||
string = "{}...".format((string[:40]).rstrip(" "))
|
||||
string = re.sub(RE_SQUARE, "", string)
|
||||
string = f"**[{escape(string, formatting=True)}]({track.uri}) **"
|
||||
elif hasattr(track, "to_string_user") and track.is_local:
|
||||
string = track.to_string_user() + " "
|
||||
if shorten and len(string) > 40:
|
||||
string = "{}...".format((string[:40]).rstrip(" "))
|
||||
string = f'**{escape(f"{string}", formatting=True)}**'
|
||||
return string
|
||||
|
||||
def get_track_description_unformatted(self, track, local_folder_current_path) -> Optional[str]:
|
||||
"""Get the user facing unformatted track name"""
|
||||
if track and hasattr(track, "uri"):
|
||||
query = Query.process_input(track.uri, local_folder_current_path)
|
||||
if query.is_local or "localtracks/" in track.uri:
|
||||
if (
|
||||
hasattr(track, "title")
|
||||
and track.title != "Unknown title"
|
||||
and hasattr(track, "author")
|
||||
and track.author != "Unknown artist"
|
||||
):
|
||||
return f"{track.author} - {track.title}"
|
||||
elif hasattr(track, "title") and track.title != "Unknown title":
|
||||
return f"{track.title}"
|
||||
else:
|
||||
return query.to_string_user()
|
||||
else:
|
||||
if track.author.lower() not in track.title.lower():
|
||||
title = f"{track.title} - {track.author}"
|
||||
else:
|
||||
title = track.title
|
||||
return f"{title}"
|
||||
elif hasattr(track, "to_string_user") and track.is_local:
|
||||
return track.to_string_user() + " "
|
||||
return None
|
||||
|
||||
def format_playlist_picker_data(self, pid, pname, ptracks, pauthor, scope) -> str:
|
||||
"""Format the values into a pretified codeblock"""
|
||||
author = self.bot.get_user(pauthor) or pauthor or _("Unknown")
|
||||
line = _(
|
||||
" - Name: <{pname}>\n"
|
||||
" - Scope: < {scope} >\n"
|
||||
" - ID: < {pid} >\n"
|
||||
" - Tracks: < {ptracks} >\n"
|
||||
" - Author: < {author} >\n\n"
|
||||
).format(
|
||||
pname=pname, scope=self.humanize_scope(scope), pid=pid, ptracks=ptracks, author=author
|
||||
)
|
||||
return box(line, lang="md")
|
||||
|
||||
async def draw_time(self, ctx) -> str:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
paused = player.paused
|
||||
pos = player.position
|
||||
dur = player.current.length
|
||||
sections = 12
|
||||
loc_time = round((pos / dur) * sections)
|
||||
bar = "\N{BOX DRAWINGS HEAVY HORIZONTAL}"
|
||||
seek = "\N{RADIO BUTTON}"
|
||||
if paused:
|
||||
msg = "\N{DOUBLE VERTICAL BAR}"
|
||||
else:
|
||||
msg = "\N{BLACK RIGHT-POINTING TRIANGLE}"
|
||||
for i in range(sections):
|
||||
if i == loc_time:
|
||||
msg += seek
|
||||
else:
|
||||
msg += bar
|
||||
return msg
|
||||
127
redbot/cogs/audio/core/utilities/local_tracks.py
Normal file
127
redbot/cogs/audio/core/utilities/local_tracks.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import contextlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Union
|
||||
|
||||
import lavalink
|
||||
from fuzzywuzzy import process
|
||||
|
||||
from redbot.core.utils import AsyncIter
|
||||
from redbot.core import commands
|
||||
|
||||
from ...errors import TrackEnqueueError
|
||||
from ...audio_dataclasses import LocalPath, Query
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.local_tracks")
|
||||
|
||||
|
||||
class LocalTrackUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def get_localtracks_folders(
|
||||
self, ctx: commands.Context, search_subfolders: bool = True
|
||||
) -> List[Union[Path, LocalPath]]:
|
||||
audio_data = LocalPath(None, self.local_folder_current_path)
|
||||
if not await self.localtracks_folder_exists(ctx):
|
||||
return []
|
||||
|
||||
return (
|
||||
await audio_data.subfolders_in_tree()
|
||||
if search_subfolders
|
||||
else await audio_data.subfolders()
|
||||
)
|
||||
|
||||
async def get_localtrack_folder_list(self, ctx: commands.Context, query: Query) -> List[Query]:
|
||||
"""Return a list of folders per the provided query"""
|
||||
if not await self.localtracks_folder_exists(ctx):
|
||||
return []
|
||||
query = Query.process_input(query, self.local_folder_current_path)
|
||||
if not query.is_local or query.local_track_path is None:
|
||||
return []
|
||||
if not query.local_track_path.exists():
|
||||
return []
|
||||
return (
|
||||
await query.local_track_path.tracks_in_tree()
|
||||
if query.search_subfolders
|
||||
else await query.local_track_path.tracks_in_folder()
|
||||
)
|
||||
|
||||
async def get_localtrack_folder_tracks(
|
||||
self, ctx, player: lavalink.player_manager.Player, query: Query
|
||||
) -> List[lavalink.rest_api.Track]:
|
||||
"""Return a list of tracks per the provided query"""
|
||||
if not await self.localtracks_folder_exists(ctx) or self.api_interface is None:
|
||||
return []
|
||||
|
||||
audio_data = LocalPath(None, self.local_folder_current_path)
|
||||
try:
|
||||
if query.local_track_path is not None:
|
||||
query.local_track_path.path.relative_to(audio_data.to_string())
|
||||
else:
|
||||
return []
|
||||
except ValueError:
|
||||
return []
|
||||
local_tracks = []
|
||||
async for local_file in AsyncIter(await self.get_all_localtrack_folder_tracks(ctx, query)):
|
||||
with contextlib.suppress(IndexError, TrackEnqueueError):
|
||||
trackdata, called_api = await self.api_interface.fetch_track(
|
||||
ctx, player, local_file
|
||||
)
|
||||
local_tracks.append(trackdata.tracks[0])
|
||||
return local_tracks
|
||||
|
||||
async def _local_play_all(
|
||||
self, ctx: commands.Context, query: Query, from_search: bool = False
|
||||
) -> None:
|
||||
if not await self.localtracks_folder_exists(ctx) or query.local_track_path is None:
|
||||
return None
|
||||
if from_search:
|
||||
query = Query.process_input(
|
||||
query.local_track_path.to_string(),
|
||||
self.local_folder_current_path,
|
||||
invoked_from="local folder",
|
||||
)
|
||||
await ctx.invoke(self.command_search, query=query)
|
||||
|
||||
async def get_all_localtrack_folder_tracks(
|
||||
self, ctx: commands.Context, query: Query
|
||||
) -> List[Query]:
|
||||
if not await self.localtracks_folder_exists(ctx) or query.local_track_path is None:
|
||||
return []
|
||||
return (
|
||||
await query.local_track_path.tracks_in_tree()
|
||||
if query.search_subfolders
|
||||
else await query.local_track_path.tracks_in_folder()
|
||||
)
|
||||
|
||||
async def localtracks_folder_exists(self, ctx: commands.Context) -> bool:
|
||||
folder = LocalPath(None, self.local_folder_current_path)
|
||||
if folder.localtrack_folder is None:
|
||||
return False
|
||||
elif folder.localtrack_folder.exists():
|
||||
return True
|
||||
elif ctx.invoked_with != "start":
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Invalid Environment"), description=_("No localtracks folder.")
|
||||
)
|
||||
return False
|
||||
|
||||
async def _build_local_search_list(
|
||||
self, to_search: List[Query], search_words: str
|
||||
) -> List[str]:
|
||||
to_search_string = {
|
||||
i.local_track_path.name for i in to_search if i.local_track_path is not None
|
||||
}
|
||||
search_results = process.extract(search_words, to_search_string, limit=50)
|
||||
search_list = []
|
||||
async for track_match, percent_match in AsyncIter(search_results):
|
||||
if percent_match > 85:
|
||||
search_list.extend(
|
||||
[
|
||||
i.to_string_user()
|
||||
for i in to_search
|
||||
if i.local_track_path is not None
|
||||
and i.local_track_path.name == track_match
|
||||
]
|
||||
)
|
||||
return search_list
|
||||
0
redbot/cogs/audio/core/utilities/menus/__init__.py
Normal file
0
redbot/cogs/audio/core/utilities/menus/__init__.py
Normal file
335
redbot/cogs/audio/core/utilities/miscellaneous.py
Normal file
335
redbot/cogs/audio/core/utilities/miscellaneous.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Final, MutableMapping, Union, cast, Mapping, Pattern
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from discord.embeds import EmptyEmbed
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import bank, commands
|
||||
from redbot.core.commands import Context
|
||||
from redbot.core.utils.chat_formatting import humanize_number
|
||||
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _, _SCHEMA_VERSION
|
||||
from ...apis.playlist_interface import get_all_playlist_for_migration23
|
||||
from ...utils import PlaylistScope
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.miscellaneous")
|
||||
|
||||
_RE_TIME_CONVERTER: Final[Pattern] = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])")
|
||||
_prefer_lyrics_cache = {}
|
||||
|
||||
|
||||
class MiscellaneousUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def _clear_react(
|
||||
self, message: discord.Message, emoji: MutableMapping = None
|
||||
) -> asyncio.Task:
|
||||
"""Non blocking version of clear_react."""
|
||||
return self.bot.loop.create_task(self.clear_react(message, emoji))
|
||||
|
||||
async def maybe_charge_requester(self, ctx: commands.Context, jukebox_price: int) -> bool:
|
||||
jukebox = await self.config.guild(ctx.guild).jukebox()
|
||||
if jukebox and not await self._can_instaskip(ctx, ctx.author):
|
||||
can_spend = await bank.can_spend(ctx.author, jukebox_price)
|
||||
if can_spend:
|
||||
await bank.withdraw_credits(ctx.author, jukebox_price)
|
||||
else:
|
||||
credits_name = await bank.get_currency_name(ctx.guild)
|
||||
bal = await bank.get_balance(ctx.author)
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Not enough {currency}").format(currency=credits_name),
|
||||
description=_(
|
||||
"{required_credits} {currency} required, but you have {bal}."
|
||||
).format(
|
||||
currency=credits_name,
|
||||
required_credits=humanize_number(jukebox_price),
|
||||
bal=humanize_number(bal),
|
||||
),
|
||||
)
|
||||
return can_spend
|
||||
else:
|
||||
return True
|
||||
|
||||
async def send_embed_msg(
|
||||
self, ctx: commands.Context, author: Mapping[str, str] = None, **kwargs
|
||||
) -> discord.Message:
|
||||
colour = kwargs.get("colour") or kwargs.get("color") or await self.bot.get_embed_color(ctx)
|
||||
title = kwargs.get("title", EmptyEmbed) or EmptyEmbed
|
||||
_type = kwargs.get("type", "rich") or "rich"
|
||||
url = kwargs.get("url", EmptyEmbed) or EmptyEmbed
|
||||
description = kwargs.get("description", EmptyEmbed) or EmptyEmbed
|
||||
timestamp = kwargs.get("timestamp")
|
||||
footer = kwargs.get("footer")
|
||||
thumbnail = kwargs.get("thumbnail")
|
||||
contents = dict(title=title, type=_type, url=url, description=description)
|
||||
if hasattr(kwargs.get("embed"), "to_dict"):
|
||||
embed = kwargs.get("embed")
|
||||
if embed is not None:
|
||||
embed = embed.to_dict()
|
||||
else:
|
||||
embed = {}
|
||||
colour = embed.get("color") if embed.get("color") else colour
|
||||
contents.update(embed)
|
||||
if timestamp and isinstance(timestamp, datetime.datetime):
|
||||
contents["timestamp"] = timestamp
|
||||
embed = discord.Embed.from_dict(contents)
|
||||
embed.color = colour
|
||||
if footer:
|
||||
embed.set_footer(text=footer)
|
||||
if thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
if author:
|
||||
name = author.get("name")
|
||||
url = author.get("url")
|
||||
if name and url:
|
||||
embed.set_author(name=name, icon_url=url)
|
||||
elif name:
|
||||
embed.set_author(name=name)
|
||||
return await ctx.send(embed=embed)
|
||||
|
||||
async def maybe_run_pending_db_tasks(self, ctx: commands.Context) -> None:
|
||||
if self.api_interface is not None:
|
||||
await self.api_interface.run_tasks(ctx)
|
||||
|
||||
async def _close_database(self) -> None:
|
||||
if self.api_interface is not None:
|
||||
await self.api_interface.run_all_pending_tasks()
|
||||
self.api_interface.close()
|
||||
|
||||
async def _check_api_tokens(self) -> MutableMapping:
|
||||
spotify = await self.bot.get_shared_api_tokens("spotify")
|
||||
youtube = await self.bot.get_shared_api_tokens("youtube")
|
||||
return {
|
||||
"spotify_client_id": spotify.get("client_id", ""),
|
||||
"spotify_client_secret": spotify.get("client_secret", ""),
|
||||
"youtube_api": youtube.get("api_key", ""),
|
||||
}
|
||||
|
||||
async def update_external_status(self) -> bool:
|
||||
external = await self.config.use_external_lavalink()
|
||||
if not external:
|
||||
if self.player_manager is not None:
|
||||
await self.player_manager.shutdown()
|
||||
await self.config.use_external_lavalink.set(True)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def rsetattr(self, obj, attr, val) -> None:
|
||||
pre, _, post = attr.rpartition(".")
|
||||
setattr(self.rgetattr(obj, pre) if pre else obj, post, val)
|
||||
|
||||
def rgetattr(self, obj, attr, *args) -> Any:
|
||||
def _getattr(obj2, attr2):
|
||||
return getattr(obj2, attr2, *args)
|
||||
|
||||
return functools.reduce(_getattr, [obj] + attr.split("."))
|
||||
|
||||
async def remove_react(
|
||||
self,
|
||||
message: discord.Message,
|
||||
react_emoji: Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str],
|
||||
react_user: discord.abc.User,
|
||||
) -> None:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.remove_reaction(react_emoji, react_user)
|
||||
|
||||
async def clear_react(self, message: discord.Message, emoji: MutableMapping = None) -> None:
|
||||
try:
|
||||
await message.clear_reactions()
|
||||
except discord.Forbidden:
|
||||
if not emoji:
|
||||
return
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
async for key in AsyncIter(emoji.values(), delay=0.2):
|
||||
await message.remove_reaction(key, self.bot.user)
|
||||
except discord.HTTPException:
|
||||
return
|
||||
|
||||
def get_track_json(
|
||||
self,
|
||||
player: lavalink.Player,
|
||||
position: Union[int, str] = None,
|
||||
other_track: lavalink.Track = None,
|
||||
) -> MutableMapping:
|
||||
if position == "np":
|
||||
queued_track = player.current
|
||||
elif position is None:
|
||||
queued_track = other_track
|
||||
else:
|
||||
queued_track = player.queue[position]
|
||||
return self.track_to_json(queued_track)
|
||||
|
||||
def track_to_json(self, track: lavalink.Track) -> MutableMapping:
|
||||
track_keys = track._info.keys()
|
||||
track_values = track._info.values()
|
||||
track_id = track.track_identifier
|
||||
track_info = {}
|
||||
for k, v in zip(track_keys, track_values):
|
||||
track_info[k] = v
|
||||
keys = ["track", "info", "extras"]
|
||||
values = [track_id, track_info]
|
||||
track_obj = {}
|
||||
for key, value in zip(keys, values):
|
||||
track_obj[key] = value
|
||||
return track_obj
|
||||
|
||||
def time_convert(self, length: Union[int, str]) -> int:
|
||||
if isinstance(length, int):
|
||||
return length
|
||||
|
||||
match = _RE_TIME_CONVERTER.match(length)
|
||||
if match is not None:
|
||||
hr = int(match.group(1)) if match.group(1) else 0
|
||||
mn = int(match.group(2)) if match.group(2) else 0
|
||||
sec = int(match.group(3)) if match.group(3) else 0
|
||||
pos = sec + (mn * 60) + (hr * 3600)
|
||||
return pos
|
||||
else:
|
||||
try:
|
||||
return int(length)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
async def queue_duration(self, ctx: commands.Context) -> int:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
duration = []
|
||||
async for i in AsyncIter(range(len(player.queue))):
|
||||
if not player.queue[i].is_stream:
|
||||
duration.append(player.queue[i].length)
|
||||
queue_dur = sum(duration)
|
||||
if not player.queue:
|
||||
queue_dur = 0
|
||||
try:
|
||||
if not player.current.is_stream:
|
||||
remain = player.current.length - player.position
|
||||
else:
|
||||
remain = 0
|
||||
except AttributeError:
|
||||
remain = 0
|
||||
queue_total_duration = remain + queue_dur
|
||||
return queue_total_duration
|
||||
|
||||
async def track_remaining_duration(self, ctx: commands.Context) -> int:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if not player.current:
|
||||
return 0
|
||||
try:
|
||||
if not player.current.is_stream:
|
||||
remain = player.current.length - player.position
|
||||
else:
|
||||
remain = 0
|
||||
except AttributeError:
|
||||
remain = 0
|
||||
return remain
|
||||
|
||||
def get_time_string(self, seconds: int) -> str:
|
||||
m, s = divmod(seconds, 60)
|
||||
h, m = divmod(m, 60)
|
||||
d, h = divmod(h, 24)
|
||||
|
||||
if d > 0:
|
||||
msg = "{0}d {1}h"
|
||||
elif d == 0 and h > 0:
|
||||
msg = "{1}h {2}m"
|
||||
elif d == 0 and h == 0 and m > 0:
|
||||
msg = "{2}m {3}s"
|
||||
elif d == 0 and h == 0 and m == 0 and s > 0:
|
||||
msg = "{3}s"
|
||||
else:
|
||||
msg = ""
|
||||
return msg.format(d, h, m, s)
|
||||
|
||||
def format_time(self, time: int) -> str:
|
||||
""" Formats the given time into DD:HH:MM:SS """
|
||||
seconds = time / 1000
|
||||
days, seconds = divmod(seconds, 24 * 60 * 60)
|
||||
hours, seconds = divmod(seconds, 60 * 60)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
day = ""
|
||||
hour = ""
|
||||
if days:
|
||||
day = "%02d:" % days
|
||||
if hours or day:
|
||||
hour = "%02d:" % hours
|
||||
minutes = "%02d:" % minutes
|
||||
sec = "%02d" % seconds
|
||||
return f"{day}{hour}{minutes}{sec}"
|
||||
|
||||
async def get_lyrics_status(self, ctx: Context) -> bool:
|
||||
global _prefer_lyrics_cache
|
||||
prefer_lyrics = _prefer_lyrics_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).prefer_lyrics()
|
||||
)
|
||||
return prefer_lyrics
|
||||
|
||||
async def data_schema_migration(self, from_version: int, to_version: int) -> None:
|
||||
database_entries = []
|
||||
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
if from_version == to_version:
|
||||
return
|
||||
if from_version < 2 <= to_version:
|
||||
all_guild_data = await self.config.all_guilds()
|
||||
all_playlist = {}
|
||||
async for guild_id, guild_data in AsyncIter(all_guild_data.items()):
|
||||
temp_guild_playlist = guild_data.pop("playlists", None)
|
||||
if temp_guild_playlist:
|
||||
guild_playlist = {}
|
||||
async for count, (name, data) in AsyncIter(
|
||||
temp_guild_playlist.items()
|
||||
).enumerate(start=1000):
|
||||
if not data or not name:
|
||||
continue
|
||||
playlist = {"id": count, "name": name, "guild": int(guild_id)}
|
||||
playlist.update(data)
|
||||
guild_playlist[str(count)] = playlist
|
||||
|
||||
tracks_in_playlist = data.get("tracks", []) or []
|
||||
async for t in AsyncIter(tracks_in_playlist):
|
||||
uri = t.get("info", {}).get("uri")
|
||||
if uri:
|
||||
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
|
||||
data = json.dumps(t)
|
||||
if all(
|
||||
k in data
|
||||
for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
|
||||
):
|
||||
database_entries.append(
|
||||
{
|
||||
"query": uri,
|
||||
"data": data,
|
||||
"last_updated": time_now,
|
||||
"last_fetched": time_now,
|
||||
}
|
||||
)
|
||||
if guild_playlist:
|
||||
all_playlist[str(guild_id)] = guild_playlist
|
||||
await self.config.custom(PlaylistScope.GUILD.value).set(all_playlist)
|
||||
# new schema is now in place
|
||||
await self.config.schema_version.set(2)
|
||||
|
||||
# migration done, now let's delete all the old stuff
|
||||
async for guild_id in AsyncIter(all_guild_data):
|
||||
await self.config.guild(
|
||||
cast(discord.Guild, discord.Object(id=guild_id))
|
||||
).clear_raw("playlists")
|
||||
if from_version < 3 <= to_version:
|
||||
for scope in PlaylistScope.list():
|
||||
scope_playlist = await get_all_playlist_for_migration23(
|
||||
self.bot, self.playlist_api, self.config, scope
|
||||
)
|
||||
async for p in AsyncIter(scope_playlist):
|
||||
await p.save()
|
||||
await self.config.custom(scope).clear()
|
||||
await self.config.schema_version.set(3)
|
||||
|
||||
if database_entries:
|
||||
await self.api_interface.local_cache_api.lavalink.insert(database_entries)
|
||||
669
redbot/cogs/audio/core/utilities/player.py
Normal file
669
redbot/cogs/audio/core/utilities/player.py
Normal file
@@ -0,0 +1,669 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
import lavalink
|
||||
from discord.embeds import EmptyEmbed
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import bold, escape
|
||||
|
||||
from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query
|
||||
from ...audio_logging import IS_DEBUG, debug_exc_log
|
||||
from ...errors import QueryUnauthorized, SpotifyFetchError, TrackEnqueueError
|
||||
from ...utils import Notifier
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.player")
|
||||
|
||||
|
||||
class PlayerUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def maybe_reset_error_counter(self, player: lavalink.Player) -> None:
|
||||
guild = self.rgetattr(player, "channel.guild.id", None)
|
||||
if not guild:
|
||||
return
|
||||
now = time.time()
|
||||
seconds_allowed = 10
|
||||
last_error = self._error_timer.setdefault(guild, now)
|
||||
if now - seconds_allowed > last_error:
|
||||
self._error_timer[guild] = 0
|
||||
self._error_counter[guild] = 0
|
||||
|
||||
async def increase_error_counter(self, player: lavalink.Player) -> bool:
|
||||
guild = self.rgetattr(player, "channel.guild.id", None)
|
||||
if not guild:
|
||||
return False
|
||||
now = time.time()
|
||||
self._error_counter[guild] += 1
|
||||
self._error_timer[guild] = now
|
||||
return self._error_counter[guild] >= 5
|
||||
|
||||
def get_active_player_count(self) -> Tuple[Optional[str], int]:
|
||||
try:
|
||||
current = next(
|
||||
(
|
||||
player.current
|
||||
for player in lavalink.active_players()
|
||||
if player.current is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
get_single_title = self.get_track_description_unformatted(
|
||||
current, self.local_folder_current_path
|
||||
)
|
||||
playing_servers = len(lavalink.active_players())
|
||||
except IndexError:
|
||||
get_single_title = None
|
||||
playing_servers = 0
|
||||
return get_single_title, playing_servers
|
||||
|
||||
async def update_bot_presence(self, track: Optional[str], playing_servers: int) -> None:
|
||||
if playing_servers == 0:
|
||||
await self.bot.change_presence(activity=None)
|
||||
elif playing_servers == 1:
|
||||
await self.bot.change_presence(
|
||||
activity=discord.Activity(name=track, type=discord.ActivityType.listening)
|
||||
)
|
||||
elif playing_servers > 1:
|
||||
await self.bot.change_presence(
|
||||
activity=discord.Activity(
|
||||
name=_("music in {} servers").format(playing_servers),
|
||||
type=discord.ActivityType.playing,
|
||||
)
|
||||
)
|
||||
|
||||
async def _can_instaskip(self, ctx: commands.Context, member: discord.Member) -> bool:
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
|
||||
if member.bot:
|
||||
return True
|
||||
|
||||
if member.id == ctx.guild.owner_id:
|
||||
return True
|
||||
|
||||
if dj_enabled and await self._has_dj_role(ctx, member):
|
||||
return True
|
||||
|
||||
if await self.bot.is_owner(member):
|
||||
return True
|
||||
|
||||
if await self.bot.is_mod(member):
|
||||
return True
|
||||
|
||||
if await self.maybe_move_player(ctx):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def is_requester_alone(self, ctx: commands.Context) -> bool:
|
||||
channel_members = self.rgetattr(ctx, "guild.me.voice.channel.members", [])
|
||||
nonbots = sum(m.id != ctx.author.id for m in channel_members if not m.bot)
|
||||
return not nonbots
|
||||
|
||||
async def _has_dj_role(self, ctx: commands.Context, member: discord.Member) -> bool:
|
||||
dj_role = self._dj_role_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_role()
|
||||
)
|
||||
dj_role_obj = ctx.guild.get_role(dj_role)
|
||||
return dj_role_obj in ctx.guild.get_member(member.id).roles
|
||||
|
||||
async def is_requester(self, ctx: commands.Context, member: discord.Member) -> bool:
|
||||
try:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
log.debug(f"Current requester is {player.current.requester}")
|
||||
return player.current.requester.id == member.id
|
||||
except Exception as err:
|
||||
debug_exc_log(log, err, "Caught error in `is_requester`")
|
||||
return False
|
||||
|
||||
async def _skip_action(self, ctx: commands.Context, skip_to_track: int = None) -> None:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
autoplay = await self.config.guild(player.channel.guild).auto_play()
|
||||
if not player.current or (not player.queue and not autoplay):
|
||||
try:
|
||||
pos, dur = player.position, player.current.length
|
||||
except AttributeError:
|
||||
await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
return
|
||||
time_remain = self.format_time(dur - pos)
|
||||
if player.current.is_stream:
|
||||
embed = discord.Embed(title=_("There's nothing in the queue."))
|
||||
embed.set_footer(
|
||||
text=_("Currently livestreaming {track}").format(track=player.current.title)
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(title=_("There's nothing in the queue."))
|
||||
embed.set_footer(
|
||||
text=_("{time} left on {track}").format(
|
||||
time=time_remain, track=player.current.title
|
||||
)
|
||||
)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
return
|
||||
elif autoplay and not player.queue:
|
||||
embed = discord.Embed(
|
||||
title=_("Track Skipped"),
|
||||
description=self.get_track_description(
|
||||
player.current, self.local_folder_current_path
|
||||
),
|
||||
)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
await player.skip()
|
||||
return
|
||||
|
||||
queue_to_append = []
|
||||
if skip_to_track is not None and skip_to_track != 1:
|
||||
if skip_to_track < 1:
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Track number must be equal to or greater than 1.")
|
||||
)
|
||||
return
|
||||
elif skip_to_track > len(player.queue):
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("There are only {queuelen} songs currently queued.").format(
|
||||
queuelen=len(player.queue)
|
||||
),
|
||||
)
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title=_("{skip_to_track} Tracks Skipped").format(skip_to_track=skip_to_track)
|
||||
)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
if player.repeat:
|
||||
queue_to_append = player.queue[0 : min(skip_to_track - 1, len(player.queue) - 1)]
|
||||
player.queue = player.queue[
|
||||
min(skip_to_track - 1, len(player.queue) - 1) : len(player.queue)
|
||||
]
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title=_("Track Skipped"),
|
||||
description=self.get_track_description(
|
||||
player.current, self.local_folder_current_path
|
||||
),
|
||||
)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
self.bot.dispatch("red_audio_skip_track", player.channel.guild, player.current, ctx.author)
|
||||
await player.play()
|
||||
player.queue += queue_to_append
|
||||
|
||||
def update_player_lock(self, ctx: commands.Context, true_or_false: bool) -> None:
|
||||
if true_or_false:
|
||||
self.play_lock[ctx.message.guild.id] = True
|
||||
else:
|
||||
self.play_lock[ctx.message.guild.id] = False
|
||||
|
||||
def _player_check(self, ctx: commands.Context) -> bool:
|
||||
if self.lavalink_connection_aborted:
|
||||
return False
|
||||
try:
|
||||
lavalink.get_player(ctx.guild.id)
|
||||
return True
|
||||
except (IndexError, KeyError):
|
||||
return False
|
||||
|
||||
async def _get_spotify_tracks(
|
||||
self, ctx: commands.Context, query: Query, forced: bool = False
|
||||
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
|
||||
if ctx.invoked_with in ["play", "genre"]:
|
||||
enqueue_tracks = True
|
||||
else:
|
||||
enqueue_tracks = False
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
api_data = await self._check_api_tokens()
|
||||
if any([not api_data["spotify_client_id"], not api_data["spotify_client_secret"]]):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Environment"),
|
||||
description=_(
|
||||
"The owner needs to set the Spotify client ID and Spotify client secret, "
|
||||
"before Spotify URLs or codes can be used. "
|
||||
"\nSee `{prefix}audioset spotifyapi` for instructions."
|
||||
).format(prefix=ctx.prefix),
|
||||
)
|
||||
elif not api_data["youtube_api"]:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Environment"),
|
||||
description=_(
|
||||
"The owner needs to set the YouTube API key before Spotify URLs or "
|
||||
"codes can be used.\nSee `{prefix}audioset youtubeapi` for instructions."
|
||||
).format(prefix=ctx.prefix),
|
||||
)
|
||||
try:
|
||||
if self.play_lock[ctx.message.guild.id]:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Tracks"),
|
||||
description=_("Wait until the playlist has finished loading."),
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if query.single_track:
|
||||
try:
|
||||
res = await self.api_interface.spotify_query(
|
||||
ctx, "track", query.id, skip_youtube=True, notifier=None
|
||||
)
|
||||
if not res:
|
||||
title = _("Nothing found.")
|
||||
embed = discord.Embed(title=title)
|
||||
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
|
||||
title = _("Track is not playable.")
|
||||
description = _(
|
||||
"**{suffix}** is not a fully supported "
|
||||
"format and some tracks may not play."
|
||||
).format(suffix=query.suffix)
|
||||
embed = discord.Embed(title=title, description=description)
|
||||
return await self.send_embed_msg(ctx, embed=embed)
|
||||
except SpotifyFetchError as error:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=error.message.format(prefix=ctx.prefix)
|
||||
)
|
||||
self.update_player_lock(ctx, False)
|
||||
try:
|
||||
if enqueue_tracks:
|
||||
new_query = Query.process_input(res[0], self.local_folder_current_path)
|
||||
new_query.start_time = query.start_time
|
||||
return await self._enqueue_tracks(ctx, new_query)
|
||||
else:
|
||||
query = Query.process_input(res[0], self.local_folder_current_path)
|
||||
try:
|
||||
result, called_api = await self.api_interface.fetch_track(
|
||||
ctx, player, query
|
||||
)
|
||||
except TrackEnqueueError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Get Track"),
|
||||
description=_(
|
||||
"I'm unable get a track from Lavalink at the moment, "
|
||||
"try again in a few minutes."
|
||||
),
|
||||
)
|
||||
tracks = result.tracks
|
||||
if not tracks:
|
||||
embed = discord.Embed(title=_("Nothing found."))
|
||||
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
|
||||
embed = discord.Embed(title=_("Track is not playable."))
|
||||
embed.description = _(
|
||||
"**{suffix}** is not a fully supported format and some "
|
||||
"tracks may not play."
|
||||
).format(suffix=query.suffix)
|
||||
return await self.send_embed_msg(ctx, embed=embed)
|
||||
single_track = tracks[0]
|
||||
single_track.start_timestamp = query.start_time * 1000
|
||||
single_track = [single_track]
|
||||
|
||||
return single_track
|
||||
|
||||
except KeyError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Environment"),
|
||||
description=_(
|
||||
"The Spotify API key or client secret has not been set properly. "
|
||||
"\nUse `{prefix}audioset spotifyapi` for instructions."
|
||||
).format(prefix=ctx.prefix),
|
||||
)
|
||||
elif query.is_album or query.is_playlist:
|
||||
self.update_player_lock(ctx, True)
|
||||
track_list = await self.fetch_spotify_playlist(
|
||||
ctx,
|
||||
"album" if query.is_album else "playlist",
|
||||
query,
|
||||
enqueue_tracks,
|
||||
forced=forced,
|
||||
)
|
||||
self.update_player_lock(ctx, False)
|
||||
return track_list
|
||||
else:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Find Tracks"),
|
||||
description=_("This doesn't seem to be a supported Spotify URL or code."),
|
||||
)
|
||||
|
||||
async def _enqueue_tracks(
|
||||
self, ctx: commands.Context, query: Union[Query, list], enqueue: bool = True
|
||||
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
try:
|
||||
if self.play_lock[ctx.message.guild.id]:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Tracks"),
|
||||
description=_("Wait until the playlist has finished loading."),
|
||||
)
|
||||
except KeyError:
|
||||
self.update_player_lock(ctx, True)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
first_track_only = False
|
||||
single_track = None
|
||||
index = None
|
||||
playlist_data = None
|
||||
playlist_url = None
|
||||
seek = 0
|
||||
if type(query) is not list:
|
||||
if not await self.is_query_allowed(
|
||||
self.config, ctx.guild, f"{query}", query_obj=query
|
||||
):
|
||||
raise QueryUnauthorized(
|
||||
_("{query} is not an allowed query.").format(query=query.to_string_user())
|
||||
)
|
||||
if query.single_track:
|
||||
first_track_only = True
|
||||
index = query.track_index
|
||||
if query.start_time:
|
||||
seek = query.start_time
|
||||
try:
|
||||
result, called_api = await self.api_interface.fetch_track(ctx, player, query)
|
||||
except TrackEnqueueError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Get Track"),
|
||||
description=_(
|
||||
"I'm unable get a track from Lavalink at the moment, "
|
||||
"try again in a few minutes."
|
||||
),
|
||||
)
|
||||
tracks = result.tracks
|
||||
playlist_data = result.playlist_info
|
||||
if not enqueue:
|
||||
return tracks
|
||||
if not tracks:
|
||||
self.update_player_lock(ctx, False)
|
||||
title = _("Nothing found.")
|
||||
embed = discord.Embed(title=title)
|
||||
if result.exception_message:
|
||||
embed.set_footer(text=result.exception_message[:2000].replace("\n", ""))
|
||||
if await self.config.use_external_lavalink() and query.is_local:
|
||||
embed.description = _(
|
||||
"Local tracks will not work "
|
||||
"if the `Lavalink.jar` cannot see the track.\n"
|
||||
"This may be due to permissions or because Lavalink.jar is being run "
|
||||
"in a different machine than the local tracks."
|
||||
)
|
||||
elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
|
||||
title = _("Track is not playable.")
|
||||
embed = discord.Embed(title=title)
|
||||
embed.description = _(
|
||||
"**{suffix}** is not a fully supported format and some "
|
||||
"tracks may not play."
|
||||
).format(suffix=query.suffix)
|
||||
return await self.send_embed_msg(ctx, embed=embed)
|
||||
else:
|
||||
tracks = query
|
||||
queue_dur = await self.queue_duration(ctx)
|
||||
queue_total_duration = self.format_time(queue_dur)
|
||||
before_queue_length = len(player.queue)
|
||||
|
||||
if not first_track_only and len(tracks) > 1:
|
||||
# a list of Tracks where all should be enqueued
|
||||
# this is a Spotify playlist already made into a list of Tracks or a
|
||||
# url where Lavalink handles providing all Track objects to use, like a
|
||||
# YouTube or Soundcloud playlist
|
||||
if len(player.queue) >= 10000:
|
||||
return await self.send_embed_msg(ctx, title=_("Queue size limit reached."))
|
||||
track_len = 0
|
||||
empty_queue = not player.queue
|
||||
async for track in AsyncIter(tracks):
|
||||
if len(player.queue) >= 10000:
|
||||
continue
|
||||
if not await self.is_query_allowed(
|
||||
self.config,
|
||||
ctx.guild,
|
||||
(
|
||||
f"{track.title} {track.author} {track.uri} "
|
||||
f"{str(Query.process_input(track, self.local_folder_current_path))}"
|
||||
),
|
||||
):
|
||||
if IS_DEBUG:
|
||||
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
|
||||
continue
|
||||
elif guild_data["maxlength"] > 0:
|
||||
if self.is_track_too_long(track, guild_data["maxlength"]):
|
||||
track_len += 1
|
||||
player.add(ctx.author, track)
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue", player.channel.guild, track, ctx.author
|
||||
)
|
||||
|
||||
else:
|
||||
track_len += 1
|
||||
player.add(ctx.author, track)
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue", player.channel.guild, track, ctx.author
|
||||
)
|
||||
player.maybe_shuffle(0 if empty_queue else 1)
|
||||
|
||||
if len(tracks) > track_len:
|
||||
maxlength_msg = _(" {bad_tracks} tracks cannot be queued.").format(
|
||||
bad_tracks=(len(tracks) - track_len)
|
||||
)
|
||||
else:
|
||||
maxlength_msg = ""
|
||||
playlist_name = escape(
|
||||
playlist_data.name if playlist_data else _("No Title"), formatting=True
|
||||
)
|
||||
embed = discord.Embed(
|
||||
description=bold(f"[{playlist_name}]({playlist_url})")
|
||||
if playlist_url
|
||||
else playlist_name,
|
||||
title=_("Playlist Enqueued"),
|
||||
)
|
||||
embed.set_footer(
|
||||
text=_("Added {num} tracks to the queue.{maxlength_msg}").format(
|
||||
num=track_len, maxlength_msg=maxlength_msg
|
||||
)
|
||||
)
|
||||
if not guild_data["shuffle"] and queue_dur > 0:
|
||||
embed.set_footer(
|
||||
text=_(
|
||||
"{time} until start of playlist playback: starts at #{position} in queue"
|
||||
).format(time=queue_total_duration, position=before_queue_length + 1)
|
||||
)
|
||||
if not player.current:
|
||||
await player.play()
|
||||
self.update_player_lock(ctx, False)
|
||||
message = await self.send_embed_msg(ctx, embed=embed)
|
||||
return tracks or message
|
||||
else:
|
||||
single_track = None
|
||||
# a ytsearch: prefixed item where we only need the first Track returned
|
||||
# this is in the case of [p]play <query>, a single Spotify url/code
|
||||
# or this is a localtrack item
|
||||
try:
|
||||
if len(player.queue) >= 10000:
|
||||
return await self.send_embed_msg(ctx, title=_("Queue size limit reached."))
|
||||
|
||||
single_track = (
|
||||
tracks
|
||||
if isinstance(tracks, lavalink.rest_api.Track)
|
||||
else tracks[index]
|
||||
if index
|
||||
else tracks[0]
|
||||
)
|
||||
if seek and seek > 0:
|
||||
single_track.start_timestamp = seek * 1000
|
||||
if not await self.is_query_allowed(
|
||||
self.config,
|
||||
ctx.guild,
|
||||
(
|
||||
f"{single_track.title} {single_track.author} {single_track.uri} "
|
||||
f"{str(Query.process_input(single_track, self.local_folder_current_path))}"
|
||||
),
|
||||
):
|
||||
if IS_DEBUG:
|
||||
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("This track is not allowed in this server.")
|
||||
)
|
||||
elif guild_data["maxlength"] > 0:
|
||||
if self.is_track_too_long(single_track, guild_data["maxlength"]):
|
||||
player.add(ctx.author, single_track)
|
||||
player.maybe_shuffle()
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue",
|
||||
player.channel.guild,
|
||||
single_track,
|
||||
ctx.author,
|
||||
)
|
||||
else:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Track exceeds maximum length.")
|
||||
)
|
||||
|
||||
else:
|
||||
player.add(ctx.author, single_track)
|
||||
player.maybe_shuffle()
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue", player.channel.guild, single_track, ctx.author
|
||||
)
|
||||
except IndexError:
|
||||
self.update_player_lock(ctx, False)
|
||||
title = _("Nothing found")
|
||||
desc = EmptyEmbed
|
||||
if await self.bot.is_owner(ctx.author):
|
||||
desc = _("Please check your console or logs for details.")
|
||||
return await self.send_embed_msg(ctx, title=title, description=desc)
|
||||
description = self.get_track_description(single_track, self.local_folder_current_path)
|
||||
embed = discord.Embed(title=_("Track Enqueued"), description=description)
|
||||
if not guild_data["shuffle"] and queue_dur > 0:
|
||||
embed.set_footer(
|
||||
text=_("{time} until track playback: #{position} in queue").format(
|
||||
time=queue_total_duration, position=before_queue_length + 1
|
||||
)
|
||||
)
|
||||
|
||||
if not player.current:
|
||||
await player.play()
|
||||
self.update_player_lock(ctx, False)
|
||||
message = await self.send_embed_msg(ctx, embed=embed)
|
||||
return single_track or message
|
||||
|
||||
async def fetch_spotify_playlist(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
stype: str,
|
||||
query: Query,
|
||||
enqueue: bool = False,
|
||||
forced: bool = False,
|
||||
):
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
try:
|
||||
embed1 = discord.Embed(title=_("Please wait, finding tracks..."))
|
||||
playlist_msg = await self.send_embed_msg(ctx, embed=embed1)
|
||||
notifier = Notifier(
|
||||
ctx,
|
||||
playlist_msg,
|
||||
{
|
||||
"spotify": _("Getting track {num}/{total}..."),
|
||||
"youtube": _("Matching track {num}/{total}..."),
|
||||
"lavalink": _("Loading track {num}/{total}..."),
|
||||
"lavalink_time": _("Approximate time remaining: {seconds}"),
|
||||
},
|
||||
)
|
||||
track_list = await self.api_interface.spotify_enqueue(
|
||||
ctx,
|
||||
stype,
|
||||
query.id,
|
||||
enqueue=enqueue,
|
||||
player=player,
|
||||
lock=self.update_player_lock,
|
||||
notifier=notifier,
|
||||
forced=forced,
|
||||
)
|
||||
except SpotifyFetchError as error:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Environment"),
|
||||
description=error.message.format(prefix=ctx.prefix),
|
||||
)
|
||||
except TrackEnqueueError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Get Track"),
|
||||
description=_(
|
||||
"I'm unable get a track from Lavalink at the moment,"
|
||||
"try again in a few minutes."
|
||||
),
|
||||
error=True,
|
||||
)
|
||||
except (RuntimeError, aiohttp.ServerDisconnectedError):
|
||||
self.update_player_lock(ctx, False)
|
||||
error_embed = discord.Embed(
|
||||
title=_("The connection was reset while loading the playlist.")
|
||||
)
|
||||
await self.send_embed_msg(ctx, embed=error_embed)
|
||||
return None
|
||||
except Exception as e:
|
||||
self.update_player_lock(ctx, False)
|
||||
raise e
|
||||
self.update_player_lock(ctx, False)
|
||||
return track_list
|
||||
|
||||
async def set_player_settings(self, ctx: commands.Context) -> None:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
shuffle = await self.config.guild(ctx.guild).shuffle()
|
||||
repeat = await self.config.guild(ctx.guild).repeat()
|
||||
volume = await self.config.guild(ctx.guild).volume()
|
||||
shuffle_bumped = await self.config.guild(ctx.guild).shuffle_bumped()
|
||||
player.repeat = repeat
|
||||
player.shuffle = shuffle
|
||||
player.shuffle_bumped = shuffle_bumped
|
||||
if player.volume != volume:
|
||||
await player.set_volume(volume)
|
||||
|
||||
async def maybe_move_player(self, ctx: commands.Context) -> bool:
|
||||
try:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
except KeyError:
|
||||
return False
|
||||
try:
|
||||
in_channel = sum(
|
||||
not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members
|
||||
)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
if not ctx.author.voice:
|
||||
user_channel = None
|
||||
else:
|
||||
user_channel = ctx.author.voice.channel
|
||||
|
||||
if in_channel == 0 and user_channel:
|
||||
if (
|
||||
(player.channel != user_channel)
|
||||
and not player.current
|
||||
and player.position == 0
|
||||
and len(player.queue) == 0
|
||||
):
|
||||
await player.move_to(user_channel)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def is_track_too_long(self, track: Union[lavalink.Track, int], maxlength: int) -> bool:
|
||||
try:
|
||||
length = round(track.length / 1000)
|
||||
except AttributeError:
|
||||
length = round(track / 1000)
|
||||
|
||||
if maxlength < length <= 92233720368547758070: # livestreams return 9223372036854775807ms
|
||||
return False
|
||||
return True
|
||||
647
redbot/cogs/audio/core/utilities/playlists.py
Normal file
647
redbot/cogs/audio/core/utilities/playlists.py
Normal file
@@ -0,0 +1,647 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import List, MutableMapping, Optional, Tuple, Union
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from discord.embeds import EmptyEmbed
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import box
|
||||
from redbot.core.utils.menus import start_adding_reactions
|
||||
from redbot.core.utils.predicates import ReactionPredicate
|
||||
|
||||
from ...apis.playlist_interface import Playlist, create_playlist
|
||||
from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query
|
||||
from ...audio_logging import debug_exc_log
|
||||
from ...errors import TooManyMatches, TrackEnqueueError
|
||||
from ...utils import Notifier, PlaylistScope
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.playlists")
|
||||
|
||||
|
||||
class PlaylistUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def can_manage_playlist(
|
||||
self, scope: str, playlist: Playlist, ctx: commands.Context, user, guild
|
||||
) -> bool:
|
||||
is_owner = await self.bot.is_owner(ctx.author)
|
||||
has_perms = False
|
||||
user_to_query = user
|
||||
guild_to_query = guild
|
||||
dj_enabled = None
|
||||
playlist_author = (
|
||||
guild.get_member(playlist.author)
|
||||
if guild
|
||||
else self.bot.get_user(playlist.author) or user
|
||||
)
|
||||
|
||||
is_different_user = len({playlist.author, user_to_query.id, ctx.author.id}) != 1
|
||||
is_different_guild = True if guild_to_query is None else ctx.guild.id != guild_to_query.id
|
||||
|
||||
if is_owner:
|
||||
has_perms = True
|
||||
elif playlist.scope == PlaylistScope.USER.value:
|
||||
if not is_different_user:
|
||||
has_perms = True
|
||||
elif playlist.scope == PlaylistScope.GUILD.value and not is_different_guild:
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if (
|
||||
guild.owner_id == ctx.author.id
|
||||
or (dj_enabled and await self._has_dj_role(ctx, ctx.author))
|
||||
or (await self.bot.is_mod(ctx.author))
|
||||
or (not dj_enabled and not is_different_user)
|
||||
):
|
||||
has_perms = True
|
||||
|
||||
if has_perms is False:
|
||||
if hasattr(playlist, "name"):
|
||||
msg = _(
|
||||
"You do not have the permissions to manage {name} (`{id}`) [**{scope}**]."
|
||||
).format(
|
||||
user=playlist_author,
|
||||
name=playlist.name,
|
||||
id=playlist.id,
|
||||
scope=self.humanize_scope(
|
||||
playlist.scope,
|
||||
ctx=guild_to_query
|
||||
if playlist.scope == PlaylistScope.GUILD.value
|
||||
else playlist_author
|
||||
if playlist.scope == PlaylistScope.USER.value
|
||||
else None,
|
||||
),
|
||||
)
|
||||
elif playlist.scope == PlaylistScope.GUILD.value and (
|
||||
is_different_guild or dj_enabled
|
||||
):
|
||||
msg = _(
|
||||
"You do not have the permissions to manage that playlist in {guild}."
|
||||
).format(guild=guild_to_query)
|
||||
elif (
|
||||
playlist.scope in [PlaylistScope.GUILD.value, PlaylistScope.USER.value]
|
||||
and is_different_user
|
||||
):
|
||||
msg = _(
|
||||
"You do not have the permissions to manage playlist owned by {user}."
|
||||
).format(user=playlist_author)
|
||||
else:
|
||||
msg = _(
|
||||
"You do not have the permissions to manage playlists in {scope} scope."
|
||||
).format(scope=self.humanize_scope(scope, the=True))
|
||||
|
||||
await self.send_embed_msg(ctx, title=_("No access to playlist."), description=msg)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def get_playlist_match(
|
||||
self,
|
||||
context: commands.Context,
|
||||
matches: MutableMapping,
|
||||
scope: str,
|
||||
author: discord.User,
|
||||
guild: discord.Guild,
|
||||
specified_user: bool = False,
|
||||
) -> Tuple[Optional[Playlist], str, str]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
context: commands.Context
|
||||
The context in which this is being called.
|
||||
matches: dict
|
||||
A dict of the matches found where key is scope and value is matches.
|
||||
scope:str
|
||||
The custom config scope. A value from :code:`PlaylistScope`.
|
||||
author: discord.User
|
||||
The user.
|
||||
guild: discord.Guild
|
||||
The guild.
|
||||
specified_user: bool
|
||||
Whether or not a user ID was specified via argparse.
|
||||
Returns
|
||||
-------
|
||||
Tuple[Optional[Playlist], str, str]
|
||||
Tuple of Playlist or None if none found, original user input and scope.
|
||||
Raises
|
||||
------
|
||||
`TooManyMatches`
|
||||
When more than 10 matches are found or
|
||||
When multiple matches are found but none is selected.
|
||||
|
||||
"""
|
||||
correct_scope_matches: List[Playlist]
|
||||
original_input = matches.get("arg")
|
||||
lazy_match = False
|
||||
if scope is None:
|
||||
correct_scope_matches_temp: MutableMapping = matches.get("all")
|
||||
lazy_match = True
|
||||
else:
|
||||
correct_scope_matches_temp: MutableMapping = matches.get(scope)
|
||||
guild_to_query = guild.id
|
||||
user_to_query = author.id
|
||||
correct_scope_matches_user = []
|
||||
correct_scope_matches_guild = []
|
||||
correct_scope_matches_global = []
|
||||
if not correct_scope_matches_temp:
|
||||
return None, original_input, scope or PlaylistScope.GUILD.value
|
||||
if lazy_match or (scope == PlaylistScope.USER.value):
|
||||
correct_scope_matches_user = [
|
||||
p for p in matches.get(PlaylistScope.USER.value) if user_to_query == p.scope_id
|
||||
]
|
||||
if lazy_match or (scope == PlaylistScope.GUILD.value and not correct_scope_matches_user):
|
||||
if specified_user:
|
||||
correct_scope_matches_guild = [
|
||||
p
|
||||
for p in matches.get(PlaylistScope.GUILD.value)
|
||||
if guild_to_query == p.scope_id and p.author == user_to_query
|
||||
]
|
||||
else:
|
||||
correct_scope_matches_guild = [
|
||||
p
|
||||
for p in matches.get(PlaylistScope.GUILD.value)
|
||||
if guild_to_query == p.scope_id
|
||||
]
|
||||
if lazy_match or (
|
||||
scope == PlaylistScope.GLOBAL.value
|
||||
and not correct_scope_matches_user
|
||||
and not correct_scope_matches_guild
|
||||
):
|
||||
if specified_user:
|
||||
correct_scope_matches_global = [
|
||||
p for p in matches.get(PlaylistScope.GLOBAL.value) if p.author == user_to_query
|
||||
]
|
||||
else:
|
||||
correct_scope_matches_global = [p for p in matches.get(PlaylistScope.GLOBAL.value)]
|
||||
|
||||
correct_scope_matches = [
|
||||
*correct_scope_matches_global,
|
||||
*correct_scope_matches_guild,
|
||||
*correct_scope_matches_user,
|
||||
]
|
||||
match_count = len(correct_scope_matches)
|
||||
if match_count > 1:
|
||||
correct_scope_matches2 = [
|
||||
p for p in correct_scope_matches if p.name == str(original_input).strip()
|
||||
]
|
||||
if correct_scope_matches2:
|
||||
correct_scope_matches = correct_scope_matches2
|
||||
elif original_input.isnumeric():
|
||||
arg = int(original_input)
|
||||
correct_scope_matches3 = [p for p in correct_scope_matches if p.id == arg]
|
||||
if correct_scope_matches3:
|
||||
correct_scope_matches = correct_scope_matches3
|
||||
match_count = len(correct_scope_matches)
|
||||
# We done all the trimming we can with the info available time to ask the user
|
||||
if match_count > 10:
|
||||
if original_input.isnumeric():
|
||||
arg = int(original_input)
|
||||
correct_scope_matches = [p for p in correct_scope_matches if p.id == arg]
|
||||
if match_count > 10:
|
||||
raise TooManyMatches(
|
||||
_(
|
||||
"{match_count} playlists match {original_input}: "
|
||||
"Please try to be more specific, or use the playlist ID."
|
||||
).format(match_count=match_count, original_input=original_input)
|
||||
)
|
||||
elif match_count == 1:
|
||||
return correct_scope_matches[0], original_input, correct_scope_matches[0].scope
|
||||
elif match_count == 0:
|
||||
return None, original_input, scope or PlaylistScope.GUILD.value
|
||||
|
||||
# TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged
|
||||
pos_len = 3
|
||||
playlists = f"{'#':{pos_len}}\n"
|
||||
number = 0
|
||||
correct_scope_matches = sorted(correct_scope_matches, key=lambda x: x.name.lower())
|
||||
async for number, playlist in AsyncIter(correct_scope_matches).enumerate(start=1):
|
||||
author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown")
|
||||
line = _(
|
||||
"{number}."
|
||||
" <{playlist.name}>\n"
|
||||
" - Scope: < {scope} >\n"
|
||||
" - ID: < {playlist.id} >\n"
|
||||
" - Tracks: < {tracks} >\n"
|
||||
" - Author: < {author} >\n\n"
|
||||
).format(
|
||||
number=number,
|
||||
playlist=playlist,
|
||||
scope=self.humanize_scope(playlist.scope),
|
||||
tracks=len(playlist.tracks),
|
||||
author=author,
|
||||
)
|
||||
playlists += line
|
||||
|
||||
embed = discord.Embed(
|
||||
title=_("{playlists} playlists found, which one would you like?").format(
|
||||
playlists=number
|
||||
),
|
||||
description=box(playlists, lang="md"),
|
||||
colour=await context.embed_colour(),
|
||||
)
|
||||
msg = await context.send(embed=embed)
|
||||
avaliable_emojis = ReactionPredicate.NUMBER_EMOJIS[1:]
|
||||
avaliable_emojis.append("🔟")
|
||||
emojis = avaliable_emojis[: len(correct_scope_matches)]
|
||||
emojis.append("\N{CROSS MARK}")
|
||||
start_adding_reactions(msg, emojis)
|
||||
pred = ReactionPredicate.with_emojis(emojis, msg, user=context.author)
|
||||
try:
|
||||
await context.bot.wait_for("reaction_add", check=pred, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await msg.delete()
|
||||
raise TooManyMatches(
|
||||
_("Too many matches found and you did not select which one you wanted.")
|
||||
)
|
||||
if emojis[pred.result] == "\N{CROSS MARK}":
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await msg.delete()
|
||||
raise TooManyMatches(
|
||||
_("Too many matches found and you did not select which one you wanted.")
|
||||
)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await msg.delete()
|
||||
return (
|
||||
correct_scope_matches[pred.result],
|
||||
original_input,
|
||||
correct_scope_matches[pred.result].scope,
|
||||
)
|
||||
|
||||
async def _build_playlist_list_page(
|
||||
self, ctx: commands.Context, page_num: int, abc_names: List, scope: Optional[str]
|
||||
) -> discord.Embed:
|
||||
plist_num_pages = math.ceil(len(abc_names) / 5)
|
||||
plist_idx_start = (page_num - 1) * 5
|
||||
plist_idx_end = plist_idx_start + 5
|
||||
plist = ""
|
||||
async for i, playlist_info in AsyncIter(
|
||||
abc_names[plist_idx_start:plist_idx_end]
|
||||
).enumerate(start=plist_idx_start):
|
||||
item_idx = i + 1
|
||||
plist += "`{}.` {}".format(item_idx, playlist_info)
|
||||
if scope is None:
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Playlists you can access in this server:"),
|
||||
description=plist,
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Playlists for {scope}:").format(scope=scope),
|
||||
description=plist,
|
||||
)
|
||||
embed.set_footer(
|
||||
text=_("Page {page_num}/{total_pages} | {num} playlists.").format(
|
||||
page_num=page_num, total_pages=plist_num_pages, num=len(abc_names)
|
||||
)
|
||||
)
|
||||
return embed
|
||||
|
||||
async def _load_v3_playlist(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
scope: str,
|
||||
uploaded_playlist_name: str,
|
||||
uploaded_playlist_url: str,
|
||||
track_list: List,
|
||||
author: Union[discord.User, discord.Member],
|
||||
guild: Union[discord.Guild],
|
||||
) -> None:
|
||||
embed1 = discord.Embed(title=_("Please wait, adding tracks..."))
|
||||
playlist_msg = await self.send_embed_msg(ctx, embed=embed1)
|
||||
track_count = len(track_list)
|
||||
uploaded_track_count = len(track_list)
|
||||
await asyncio.sleep(1)
|
||||
embed2 = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Loading track {num}/{total}...").format(
|
||||
num=track_count, total=uploaded_track_count
|
||||
),
|
||||
)
|
||||
await playlist_msg.edit(embed=embed2)
|
||||
|
||||
playlist = await create_playlist(
|
||||
ctx,
|
||||
self.playlist_api,
|
||||
scope,
|
||||
uploaded_playlist_name,
|
||||
uploaded_playlist_url,
|
||||
track_list,
|
||||
author,
|
||||
guild,
|
||||
)
|
||||
scope_name = self.humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
if not track_count:
|
||||
msg = _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format(
|
||||
name=playlist.name, id=playlist.id, scope=scope_name
|
||||
)
|
||||
elif uploaded_track_count != track_count:
|
||||
bad_tracks = uploaded_track_count - track_count
|
||||
msg = _(
|
||||
"Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) "
|
||||
"could not be loaded."
|
||||
).format(num=track_count, playlist_name=playlist.name, num_bad=bad_tracks)
|
||||
else:
|
||||
msg = _("Added {num} tracks from the {playlist_name} playlist.").format(
|
||||
num=track_count, playlist_name=playlist.name
|
||||
)
|
||||
embed3 = discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg
|
||||
)
|
||||
await playlist_msg.edit(embed=embed3)
|
||||
database_entries = []
|
||||
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
async for t in AsyncIter(track_list):
|
||||
uri = t.get("info", {}).get("uri")
|
||||
if uri:
|
||||
t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri}
|
||||
data = json.dumps(t)
|
||||
if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]):
|
||||
database_entries.append(
|
||||
{
|
||||
"query": uri,
|
||||
"data": data,
|
||||
"last_updated": time_now,
|
||||
"last_fetched": time_now,
|
||||
}
|
||||
)
|
||||
if database_entries:
|
||||
await self.api_interface.local_cache_api.lavalink.insert(database_entries)
|
||||
|
||||
async def _load_v2_playlist(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
uploaded_track_list,
|
||||
player: lavalink.player_manager.Player,
|
||||
playlist_url: str,
|
||||
uploaded_playlist_name: str,
|
||||
scope: str,
|
||||
author: Union[discord.User, discord.Member],
|
||||
guild: Union[discord.Guild],
|
||||
):
|
||||
track_list = []
|
||||
successful_count = 0
|
||||
uploaded_track_count = len(uploaded_track_list)
|
||||
|
||||
embed1 = discord.Embed(title=_("Please wait, adding tracks..."))
|
||||
playlist_msg = await self.send_embed_msg(ctx, embed=embed1)
|
||||
notifier = Notifier(ctx, playlist_msg, {"playlist": _("Loading track {num}/{total}...")})
|
||||
async for track_count, song_url in AsyncIter(uploaded_track_list).enumerate(start=1):
|
||||
try:
|
||||
try:
|
||||
result, called_api = await self.api_interface.fetch_track(
|
||||
ctx, player, Query.process_input(song_url, self.local_folder_current_path)
|
||||
)
|
||||
except TrackEnqueueError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Get Track"),
|
||||
description=_(
|
||||
"I'm unable get a track from Lavalink at the moment, "
|
||||
"try again in a few minutes."
|
||||
),
|
||||
)
|
||||
|
||||
track = result.tracks[0]
|
||||
except Exception as err:
|
||||
debug_exc_log(log, err, f"Failed to get track for {song_url}")
|
||||
continue
|
||||
try:
|
||||
track_obj = self.get_track_json(player, other_track=track)
|
||||
track_list.append(track_obj)
|
||||
successful_count += 1
|
||||
except Exception as err:
|
||||
debug_exc_log(log, err, f"Failed to create track for {track}")
|
||||
continue
|
||||
if (track_count % 2 == 0) or (track_count == len(uploaded_track_list)):
|
||||
await notifier.notify_user(
|
||||
current=track_count, total=len(uploaded_track_list), key="playlist"
|
||||
)
|
||||
playlist = await create_playlist(
|
||||
ctx,
|
||||
self.playlist_api,
|
||||
scope,
|
||||
uploaded_playlist_name,
|
||||
playlist_url,
|
||||
track_list,
|
||||
author,
|
||||
guild,
|
||||
)
|
||||
scope_name = self.humanize_scope(
|
||||
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
|
||||
)
|
||||
if not successful_count:
|
||||
msg = _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format(
|
||||
name=playlist.name, id=playlist.id, scope=scope_name
|
||||
)
|
||||
elif uploaded_track_count != successful_count:
|
||||
bad_tracks = uploaded_track_count - successful_count
|
||||
msg = _(
|
||||
"Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) "
|
||||
"could not be loaded."
|
||||
).format(num=successful_count, playlist_name=playlist.name, num_bad=bad_tracks)
|
||||
else:
|
||||
msg = _("Added {num} tracks from the {playlist_name} playlist.").format(
|
||||
num=successful_count, playlist_name=playlist.name
|
||||
)
|
||||
embed3 = discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg
|
||||
)
|
||||
await playlist_msg.edit(embed=embed3)
|
||||
|
||||
async def _maybe_update_playlist(
|
||||
self, ctx: commands.Context, player: lavalink.player_manager.Player, playlist: Playlist
|
||||
) -> Tuple[List[lavalink.Track], List[lavalink.Track], Playlist]:
|
||||
if playlist.url is None:
|
||||
return [], [], playlist
|
||||
results = {}
|
||||
updated_tracks = await self.fetch_playlist_tracks(
|
||||
ctx,
|
||||
player,
|
||||
Query.process_input(playlist.url, self.local_folder_current_path),
|
||||
skip_cache=True,
|
||||
)
|
||||
if isinstance(updated_tracks, discord.Message):
|
||||
return [], [], playlist
|
||||
if not updated_tracks:
|
||||
# No Tracks available on url Lets set it to none to avoid repeated calls here
|
||||
results["url"] = None
|
||||
if updated_tracks: # Tracks have been updated
|
||||
results["tracks"] = updated_tracks
|
||||
|
||||
old_tracks = playlist.tracks_obj
|
||||
new_tracks = [lavalink.Track(data=track) for track in updated_tracks]
|
||||
removed = list(set(old_tracks) - set(new_tracks))
|
||||
added = list(set(new_tracks) - set(old_tracks))
|
||||
if removed or added:
|
||||
await playlist.edit(results)
|
||||
|
||||
return added, removed, playlist
|
||||
|
||||
async def _playlist_check(self, ctx: commands.Context) -> bool:
|
||||
if not self._player_check(ctx):
|
||||
if self.lavalink_connection_aborted:
|
||||
msg = _("Connection to Lavalink has failed")
|
||||
desc = EmptyEmbed
|
||||
if await self.bot.is_owner(ctx.author):
|
||||
desc = _("Please check your console or logs for details.")
|
||||
await self.send_embed_msg(ctx, title=msg, description=desc)
|
||||
return False
|
||||
try:
|
||||
if (
|
||||
not ctx.author.voice.channel.permissions_for(ctx.me).connect
|
||||
or not ctx.author.voice.channel.permissions_for(ctx.me).move_members
|
||||
and self.is_vc_full(ctx.author.voice.channel)
|
||||
):
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Playlists"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
return False
|
||||
await lavalink.connect(ctx.author.voice.channel)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
player.store("connect", datetime.datetime.utcnow())
|
||||
except IndexError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Playlists"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
return False
|
||||
except AttributeError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Playlists"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
return False
|
||||
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
player.store("channel", ctx.channel.id)
|
||||
player.store("guild", ctx.guild.id)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not await self._can_instaskip(ctx, ctx.author):
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Playlists"),
|
||||
description=_("You must be in the voice channel to use the playlist command."),
|
||||
)
|
||||
return False
|
||||
await self._eq_check(ctx, player)
|
||||
await self.set_player_settings(ctx)
|
||||
return True
|
||||
|
||||
async def fetch_playlist_tracks(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
player: lavalink.player_manager.Player,
|
||||
query: Query,
|
||||
skip_cache: bool = False,
|
||||
) -> Union[discord.Message, None, List[MutableMapping]]:
|
||||
search = query.is_search
|
||||
tracklist = []
|
||||
|
||||
if query.is_spotify:
|
||||
try:
|
||||
if self.play_lock[ctx.message.guild.id]:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Get Tracks"),
|
||||
description=_("Wait until the playlist has finished loading."),
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
tracks = await self._get_spotify_tracks(ctx, query, forced=skip_cache)
|
||||
|
||||
if isinstance(tracks, discord.Message):
|
||||
return None
|
||||
|
||||
if not tracks:
|
||||
embed = discord.Embed(title=_("Nothing found."))
|
||||
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
|
||||
embed = discord.Embed(title=_("Track is not playable."))
|
||||
embed.description = _(
|
||||
"**{suffix}** is not a fully supported format and some "
|
||||
"tracks may not play."
|
||||
).format(suffix=query.suffix)
|
||||
return await self.send_embed_msg(ctx, embed=embed)
|
||||
async for track in AsyncIter(tracks):
|
||||
track_obj = self.get_track_json(player, other_track=track)
|
||||
tracklist.append(track_obj)
|
||||
self.update_player_lock(ctx, False)
|
||||
elif query.is_search:
|
||||
try:
|
||||
result, called_api = await self.api_interface.fetch_track(
|
||||
ctx, player, query, forced=skip_cache
|
||||
)
|
||||
except TrackEnqueueError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Get Track"),
|
||||
description=_(
|
||||
"I'm unable get a track from Lavalink at the moment, try again in a few "
|
||||
"minutes."
|
||||
),
|
||||
)
|
||||
|
||||
tracks = result.tracks
|
||||
if not tracks:
|
||||
embed = discord.Embed(title=_("Nothing found."))
|
||||
if query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT:
|
||||
embed = discord.Embed(title=_("Track is not playable."))
|
||||
embed.description = _(
|
||||
"**{suffix}** is not a fully supported format and some "
|
||||
"tracks may not play."
|
||||
).format(suffix=query.suffix)
|
||||
return await self.send_embed_msg(ctx, embed=embed)
|
||||
else:
|
||||
try:
|
||||
result, called_api = await self.api_interface.fetch_track(
|
||||
ctx, player, query, forced=skip_cache
|
||||
)
|
||||
except TrackEnqueueError:
|
||||
self.update_player_lock(ctx, False)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Get Track"),
|
||||
description=_(
|
||||
"I'm unable get a track from Lavalink at the moment, try again in a few "
|
||||
"minutes."
|
||||
),
|
||||
)
|
||||
|
||||
tracks = result.tracks
|
||||
|
||||
if not search and len(tracklist) == 0:
|
||||
async for track in AsyncIter(tracks):
|
||||
track_obj = self.get_track_json(player, other_track=track)
|
||||
tracklist.append(track_obj)
|
||||
elif len(tracklist) == 0:
|
||||
track_obj = self.get_track_json(player, other_track=tracks[0])
|
||||
tracklist.append(track_obj)
|
||||
return tracklist
|
||||
|
||||
def humanize_scope(
|
||||
self, scope: str, ctx: Union[discord.Guild, discord.abc.User, str] = None, the: bool = None
|
||||
) -> Optional[str]:
|
||||
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
return _("the Global") if the else _("Global")
|
||||
elif scope == PlaylistScope.GUILD.value:
|
||||
return ctx.name if ctx else _("the Server") if the else _("Server")
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
return str(ctx) if ctx else _("the User") if the else _("User")
|
||||
165
redbot/cogs/audio/core/utilities/queue.py
Normal file
165
redbot/cogs/audio/core/utilities/queue.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import List, Tuple
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from fuzzywuzzy import process
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import humanize_number
|
||||
|
||||
from ...audio_dataclasses import LocalPath, Query
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.queue")
|
||||
|
||||
|
||||
class QueueUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def _build_queue_page(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
queue: list,
|
||||
player: lavalink.player_manager.Player,
|
||||
page_num: int,
|
||||
) -> discord.Embed:
|
||||
shuffle = await self.config.guild(ctx.guild).shuffle()
|
||||
repeat = await self.config.guild(ctx.guild).repeat()
|
||||
autoplay = await self.config.guild(ctx.guild).auto_play()
|
||||
|
||||
queue_num_pages = math.ceil(len(queue) / 10)
|
||||
queue_idx_start = (page_num - 1) * 10
|
||||
queue_idx_end = queue_idx_start + 10
|
||||
if len(player.queue) > 500:
|
||||
queue_list = _("__Too many songs in the queue, only showing the first 500__.\n\n")
|
||||
else:
|
||||
queue_list = ""
|
||||
|
||||
arrow = await self.draw_time(ctx)
|
||||
pos = self.format_time(player.position)
|
||||
|
||||
if player.current.is_stream:
|
||||
dur = "LIVE"
|
||||
else:
|
||||
dur = self.format_time(player.current.length)
|
||||
|
||||
query = Query.process_input(player.current, self.local_folder_current_path)
|
||||
current_track_description = self.get_track_description(
|
||||
player.current, self.local_folder_current_path
|
||||
)
|
||||
if query.is_stream:
|
||||
queue_list += _("**Currently livestreaming:**\n")
|
||||
queue_list += f"{current_track_description}\n"
|
||||
queue_list += _("Requested by: **{user}**").format(user=player.current.requester)
|
||||
queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n"
|
||||
else:
|
||||
queue_list += _("Playing: ")
|
||||
queue_list += f"{current_track_description}\n"
|
||||
queue_list += _("Requested by: **{user}**").format(user=player.current.requester)
|
||||
queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n"
|
||||
|
||||
async for i, track in AsyncIter(queue[queue_idx_start:queue_idx_end]).enumerate(
|
||||
start=queue_idx_start
|
||||
):
|
||||
req_user = track.requester
|
||||
track_idx = i + 1
|
||||
track_description = self.get_track_description(
|
||||
track, self.local_folder_current_path, shorten=True
|
||||
)
|
||||
queue_list += f"`{track_idx}.` {track_description}, "
|
||||
queue_list += _("requested by **{user}**\n").format(user=req_user)
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Queue for __{guild_name}__").format(guild_name=ctx.guild.name),
|
||||
description=queue_list,
|
||||
)
|
||||
if await self.config.guild(ctx.guild).thumbnail() and player.current.thumbnail:
|
||||
embed.set_thumbnail(url=player.current.thumbnail)
|
||||
queue_dur = await self.queue_duration(ctx)
|
||||
queue_total_duration = self.format_time(queue_dur)
|
||||
text = _(
|
||||
"Page {page_num}/{total_pages} | {num_tracks} tracks, {num_remaining} remaining\n"
|
||||
).format(
|
||||
page_num=humanize_number(page_num),
|
||||
total_pages=humanize_number(queue_num_pages),
|
||||
num_tracks=len(player.queue),
|
||||
num_remaining=queue_total_duration,
|
||||
)
|
||||
text += (
|
||||
_("Auto-Play")
|
||||
+ ": "
|
||||
+ ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}")
|
||||
)
|
||||
text += (
|
||||
(" | " if text else "")
|
||||
+ _("Shuffle")
|
||||
+ ": "
|
||||
+ ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}")
|
||||
)
|
||||
text += (
|
||||
(" | " if text else "")
|
||||
+ _("Repeat")
|
||||
+ ": "
|
||||
+ ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}")
|
||||
)
|
||||
embed.set_footer(text=text)
|
||||
return embed
|
||||
|
||||
async def _build_queue_search_list(
|
||||
self, queue_list: List[lavalink.Track], search_words: str
|
||||
) -> List[Tuple[int, str]]:
|
||||
track_list = []
|
||||
async for queue_idx, track in AsyncIter(queue_list).enumerate(start=1):
|
||||
if not self.match_url(track.uri):
|
||||
query = Query.process_input(track, self.local_folder_current_path)
|
||||
if (
|
||||
query.is_local
|
||||
and query.local_track_path is not None
|
||||
and track.title == "Unknown title"
|
||||
):
|
||||
track_title = query.local_track_path.to_string_user()
|
||||
else:
|
||||
track_title = "{} - {}".format(track.author, track.title)
|
||||
else:
|
||||
track_title = track.title
|
||||
|
||||
song_info = {str(queue_idx): track_title}
|
||||
track_list.append(song_info)
|
||||
search_results = process.extract(search_words, track_list, limit=50)
|
||||
search_list = []
|
||||
async for search, percent_match in AsyncIter(search_results):
|
||||
async for queue_position, title in AsyncIter(search.items()):
|
||||
if percent_match > 89:
|
||||
search_list.append((queue_position, title))
|
||||
return search_list
|
||||
|
||||
async def _build_queue_search_page(
|
||||
self, ctx: commands.Context, page_num: int, search_list: List[Tuple[int, str]]
|
||||
) -> discord.Embed:
|
||||
search_num_pages = math.ceil(len(search_list) / 10)
|
||||
search_idx_start = (page_num - 1) * 10
|
||||
search_idx_end = search_idx_start + 10
|
||||
track_match = ""
|
||||
async for i, track in AsyncIter(search_list[search_idx_start:search_idx_end]).enumerate(
|
||||
start=search_idx_start
|
||||
):
|
||||
track_idx = i + 1
|
||||
if type(track) is str:
|
||||
track_location = LocalPath(track, self.local_folder_current_path).to_string_user()
|
||||
track_match += "`{}.` **{}**\n".format(track_idx, track_location)
|
||||
else:
|
||||
track_match += "`{}.` **{}**\n".format(track[0], track[1])
|
||||
embed = discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=_("Matching Tracks:"), description=track_match
|
||||
)
|
||||
embed.set_footer(
|
||||
text=_("Page {page_num}/{total_pages} | {num_tracks} tracks").format(
|
||||
page_num=humanize_number(page_num),
|
||||
total_pages=humanize_number(search_num_pages),
|
||||
num_tracks=len(search_list),
|
||||
)
|
||||
)
|
||||
return embed
|
||||
82
redbot/cogs/audio/core/utilities/validation.py
Normal file
82
redbot/cogs/audio/core/utilities/validation.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Final, List, Set, Pattern
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import discord
|
||||
|
||||
from redbot.core import Config
|
||||
|
||||
from ...audio_dataclasses import Query
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Utilities.validation")
|
||||
|
||||
_RE_YT_LIST_PLAYLIST: Final[Pattern] = re.compile(
|
||||
r"^(https?://)?(www\.)?(youtube\.com|youtu\.?be)(/playlist\?).*(list=)(.*)(&|$)"
|
||||
)
|
||||
|
||||
|
||||
class ValidationUtilities(MixinMeta, metaclass=CompositeMetaClass):
|
||||
def match_url(self, url: str) -> bool:
|
||||
try:
|
||||
query_url = urlparse(url)
|
||||
return all([query_url.scheme, query_url.netloc, query_url.path])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def match_yt_playlist(self, url: str) -> bool:
|
||||
if _RE_YT_LIST_PLAYLIST.match(url):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_url_allowed(self, url: str) -> bool:
|
||||
valid_tld = [
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"soundcloud.com",
|
||||
"bandcamp.com",
|
||||
"vimeo.com",
|
||||
"beam.pro",
|
||||
"mixer.com",
|
||||
"twitch.tv",
|
||||
"spotify.com",
|
||||
"localtracks",
|
||||
]
|
||||
query_url = urlparse(url)
|
||||
url_domain = ".".join(query_url.netloc.split(".")[-2:])
|
||||
if not query_url.netloc:
|
||||
url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:])
|
||||
return True if url_domain in valid_tld else False
|
||||
|
||||
def is_vc_full(self, channel: discord.VoiceChannel) -> bool:
|
||||
return not (channel.user_limit == 0 or channel.user_limit > len(channel.members))
|
||||
|
||||
async def is_query_allowed(
|
||||
self, config: Config, guild: discord.Guild, query: str, query_obj: Query = None
|
||||
) -> bool:
|
||||
"""Checks if the query is allowed in this server or globally"""
|
||||
|
||||
query = query.lower().strip()
|
||||
if query_obj is not None:
|
||||
query = query_obj.lavalink_query.replace("ytsearch:", "youtubesearch").replace(
|
||||
"scsearch:", "soundcloudsearch"
|
||||
)
|
||||
global_whitelist = set(await config.url_keyword_whitelist())
|
||||
global_whitelist = [i.lower() for i in global_whitelist]
|
||||
if global_whitelist:
|
||||
return any(i in query for i in global_whitelist)
|
||||
global_blacklist = set(await config.url_keyword_blacklist())
|
||||
global_blacklist = [i.lower() for i in global_blacklist]
|
||||
if any(i in query for i in global_blacklist):
|
||||
return False
|
||||
if guild is not None:
|
||||
whitelist_unique: Set[str] = set(await config.guild(guild).url_keyword_whitelist())
|
||||
whitelist: List[str] = [i.lower() for i in whitelist_unique]
|
||||
if whitelist:
|
||||
return any(i in query for i in whitelist)
|
||||
blacklist_unique: Set[str] = set(await config.guild(guild).url_keyword_blacklist())
|
||||
blacklist: List[str] = [i.lower() for i in blacklist_unique]
|
||||
return not any(i in query for i in blacklist)
|
||||
return True
|
||||
Reference in New Issue
Block a user