Merge V3/feature/audio into V3/develop (a.k.a. audio refactor) (#3459)

This commit is contained in:
Draper
2020-05-20 21:30:06 +01:00
committed by GitHub
parent ef76affd77
commit 8fa47cb789
53 changed files with 12372 additions and 10144 deletions

View 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"""

View 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

View 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

View 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

View 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)

View 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

View 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")

View 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

View 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