mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-07 03:38:53 -05:00
Merge V3/feature/audio into V3/develop (a.k.a. audio refactor) (#3459)
This commit is contained in:
parent
ef76affd77
commit
8fa47cb789
@ -1,8 +1,9 @@
|
||||
from redbot.core import commands
|
||||
from redbot.core.bot import Red
|
||||
|
||||
from .audio import Audio
|
||||
from .core import Audio
|
||||
|
||||
|
||||
def setup(bot: commands.Bot):
|
||||
def setup(bot: Red):
|
||||
cog = Audio(bot)
|
||||
bot.add_cog(cog)
|
||||
cog.start_up_task()
|
||||
|
||||
10
redbot/cogs/audio/apis/__init__.py
Normal file
10
redbot/cogs/audio/apis/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
from . import (
|
||||
api_utils,
|
||||
global_db,
|
||||
interface,
|
||||
local_db,
|
||||
playlist_interface,
|
||||
playlist_wrapper,
|
||||
spotify,
|
||||
youtube,
|
||||
)
|
||||
140
redbot/cogs/audio/apis/api_utils.py
Normal file
140
redbot/cogs/audio/apis/api_utils.py
Normal file
@ -0,0 +1,140 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, MutableMapping, Optional, Union
|
||||
|
||||
import discord
|
||||
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.utils.chat_formatting import humanize_list
|
||||
|
||||
from ..errors import InvalidPlaylistScope, MissingAuthor, MissingGuild
|
||||
from ..utils import PlaylistScope
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.api.utils")
|
||||
|
||||
|
||||
@dataclass
|
||||
class YouTubeCacheFetchResult:
|
||||
query: Optional[str]
|
||||
last_updated: int
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.last_updated, int):
|
||||
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpotifyCacheFetchResult:
|
||||
query: Optional[str]
|
||||
last_updated: int
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.last_updated, int):
|
||||
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LavalinkCacheFetchResult:
|
||||
query: Optional[MutableMapping]
|
||||
last_updated: int
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.last_updated, int):
|
||||
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
|
||||
|
||||
if isinstance(self.query, str):
|
||||
self.query = json.loads(self.query)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LavalinkCacheFetchForGlobalResult:
|
||||
query: str
|
||||
data: MutableMapping
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.data, str):
|
||||
self.data_string = str(self.data)
|
||||
self.data = json.loads(self.data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistFetchResult:
|
||||
playlist_id: int
|
||||
playlist_name: str
|
||||
scope_id: int
|
||||
author_id: int
|
||||
playlist_url: Optional[str] = None
|
||||
tracks: List[MutableMapping] = field(default_factory=lambda: [])
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.tracks, str):
|
||||
self.tracks = json.loads(self.tracks)
|
||||
|
||||
|
||||
def standardize_scope(scope: str) -> str:
|
||||
"""Convert any of the used scopes into one we are expecting"""
|
||||
scope = scope.upper()
|
||||
valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"]
|
||||
|
||||
if scope in PlaylistScope.list():
|
||||
return scope
|
||||
elif scope not in valid_scopes:
|
||||
raise InvalidPlaylistScope(
|
||||
f'"{scope}" is not a valid playlist scope.'
|
||||
f" Scope needs to be one of the following: {humanize_list(valid_scopes)}"
|
||||
)
|
||||
|
||||
if scope in ["GLOBAL", "BOT"]:
|
||||
scope = PlaylistScope.GLOBAL.value
|
||||
elif scope in ["GUILD", "SERVER"]:
|
||||
scope = PlaylistScope.GUILD.value
|
||||
elif scope in ["USER", "MEMBER", "AUTHOR"]:
|
||||
scope = PlaylistScope.USER.value
|
||||
|
||||
return scope
|
||||
|
||||
|
||||
def prepare_config_scope(
|
||||
bot: Red,
|
||||
scope,
|
||||
author: Union[discord.abc.User, int] = None,
|
||||
guild: Union[discord.Guild, int] = None,
|
||||
):
|
||||
"""Return the scope used by Playlists"""
|
||||
scope = standardize_scope(scope)
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
config_scope = [PlaylistScope.GLOBAL.value, bot.user.id]
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
if author is None:
|
||||
raise MissingAuthor("Invalid author for user scope.")
|
||||
config_scope = [PlaylistScope.USER.value, int(getattr(author, "id", author))]
|
||||
else:
|
||||
if guild is None:
|
||||
raise MissingGuild("Invalid guild for guild scope.")
|
||||
config_scope = [PlaylistScope.GUILD.value, int(getattr(guild, "id", guild))]
|
||||
return config_scope
|
||||
|
||||
|
||||
def prepare_config_scope_for_migration23( # TODO: remove me in a future version ?
|
||||
scope, author: Union[discord.abc.User, int] = None, guild: discord.Guild = None
|
||||
):
|
||||
"""Return the scope used by Playlists"""
|
||||
scope = standardize_scope(scope)
|
||||
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
config_scope = [PlaylistScope.GLOBAL.value]
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
if author is None:
|
||||
raise MissingAuthor("Invalid author for user scope.")
|
||||
config_scope = [PlaylistScope.USER.value, str(getattr(author, "id", author))]
|
||||
else:
|
||||
if guild is None:
|
||||
raise MissingGuild("Invalid guild for guild scope.")
|
||||
config_scope = [PlaylistScope.GUILD.value, str(getattr(guild, "id", guild))]
|
||||
return config_scope
|
||||
|
||||
|
||||
FakePlaylist = namedtuple("Playlist", "author scope")
|
||||
42
redbot/cogs/audio/apis/global_db.py
Normal file
42
redbot/cogs/audio/apis/global_db.py
Normal file
@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import urllib.parse
|
||||
from typing import Mapping, Optional, TYPE_CHECKING, Union
|
||||
|
||||
import aiohttp
|
||||
from lavalink.rest_api import LoadResult
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands import Cog
|
||||
|
||||
from ..audio_dataclasses import Query
|
||||
from ..audio_logging import IS_DEBUG, debug_exc_log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Audio
|
||||
|
||||
_API_URL = "https://redbot.app/"
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.api.GlobalDB")
|
||||
|
||||
|
||||
class GlobalCacheWrapper:
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, session: aiohttp.ClientSession, cog: Union["Audio", Cog]
|
||||
):
|
||||
# Place Holder for the Global Cache PR
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.api_key = None
|
||||
self._handshake_token = ""
|
||||
self.can_write = False
|
||||
self._handshake_token = ""
|
||||
self.has_api_key = None
|
||||
self._token: Mapping[str, str] = {}
|
||||
self.cog = cog
|
||||
|
||||
def update_token(self, new_token: Mapping[str, str]):
|
||||
self._token = new_token
|
||||
File diff suppressed because it is too large
Load Diff
372
redbot/cogs/audio/apis/local_db.py
Normal file
372
redbot/cogs/audio/apis/local_db.py
Normal file
@ -0,0 +1,372 @@
|
||||
import concurrent
|
||||
import contextlib
|
||||
import datetime
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Callable, List, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union
|
||||
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands import Cog
|
||||
from redbot.core.utils.dbtools import APSWConnectionWrapper
|
||||
|
||||
from ..audio_logging import debug_exc_log
|
||||
from ..sql_statements import (
|
||||
LAVALINK_CREATE_INDEX,
|
||||
LAVALINK_CREATE_TABLE,
|
||||
LAVALINK_DELETE_OLD_ENTRIES,
|
||||
LAVALINK_FETCH_ALL_ENTRIES_GLOBAL,
|
||||
LAVALINK_QUERY,
|
||||
LAVALINK_QUERY_ALL,
|
||||
LAVALINK_QUERY_LAST_FETCHED_RANDOM,
|
||||
LAVALINK_UPDATE,
|
||||
LAVALINK_UPSERT,
|
||||
SPOTIFY_CREATE_INDEX,
|
||||
SPOTIFY_CREATE_TABLE,
|
||||
SPOTIFY_DELETE_OLD_ENTRIES,
|
||||
SPOTIFY_QUERY,
|
||||
SPOTIFY_QUERY_ALL,
|
||||
SPOTIFY_QUERY_LAST_FETCHED_RANDOM,
|
||||
SPOTIFY_UPDATE,
|
||||
SPOTIFY_UPSERT,
|
||||
YOUTUBE_CREATE_INDEX,
|
||||
YOUTUBE_CREATE_TABLE,
|
||||
YOUTUBE_DELETE_OLD_ENTRIES,
|
||||
YOUTUBE_QUERY,
|
||||
YOUTUBE_QUERY_ALL,
|
||||
YOUTUBE_QUERY_LAST_FETCHED_RANDOM,
|
||||
YOUTUBE_UPDATE,
|
||||
YOUTUBE_UPSERT,
|
||||
PRAGMA_FETCH_user_version,
|
||||
PRAGMA_SET_journal_mode,
|
||||
PRAGMA_SET_read_uncommitted,
|
||||
PRAGMA_SET_temp_store,
|
||||
PRAGMA_SET_user_version,
|
||||
)
|
||||
from .api_utils import (
|
||||
LavalinkCacheFetchForGlobalResult,
|
||||
LavalinkCacheFetchResult,
|
||||
SpotifyCacheFetchResult,
|
||||
YouTubeCacheFetchResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Audio
|
||||
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.api.LocalDB")
|
||||
|
||||
_SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
class BaseWrapper:
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
|
||||
):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.database = conn
|
||||
self.statement = SimpleNamespace()
|
||||
self.statement.pragma_temp_store = PRAGMA_SET_temp_store
|
||||
self.statement.pragma_journal_mode = PRAGMA_SET_journal_mode
|
||||
self.statement.pragma_read_uncommitted = PRAGMA_SET_read_uncommitted
|
||||
self.statement.set_user_version = PRAGMA_SET_user_version
|
||||
self.statement.get_user_version = PRAGMA_FETCH_user_version
|
||||
self.fetch_result: Optional[Callable] = None
|
||||
self.cog = cog
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Initialize the local cache"""
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.cursor().execute, self.statement.pragma_temp_store)
|
||||
executor.submit(self.database.cursor().execute, self.statement.pragma_journal_mode)
|
||||
executor.submit(self.database.cursor().execute, self.statement.pragma_read_uncommitted)
|
||||
executor.submit(self.maybe_migrate)
|
||||
executor.submit(self.database.cursor().execute, LAVALINK_CREATE_TABLE)
|
||||
executor.submit(self.database.cursor().execute, LAVALINK_CREATE_INDEX)
|
||||
executor.submit(self.database.cursor().execute, YOUTUBE_CREATE_TABLE)
|
||||
executor.submit(self.database.cursor().execute, YOUTUBE_CREATE_INDEX)
|
||||
executor.submit(self.database.cursor().execute, SPOTIFY_CREATE_TABLE)
|
||||
executor.submit(self.database.cursor().execute, SPOTIFY_CREATE_INDEX)
|
||||
await self.clean_up_old_entries()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection with the local cache"""
|
||||
with contextlib.suppress(Exception):
|
||||
self.database.close()
|
||||
|
||||
async def clean_up_old_entries(self) -> None:
|
||||
"""Delete entries older than x in the local cache tables"""
|
||||
max_age = await self.config.cache_age()
|
||||
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
|
||||
maxage_int = int(time.mktime(maxage.timetuple()))
|
||||
values = {"maxage": maxage_int}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.cursor().execute, LAVALINK_DELETE_OLD_ENTRIES, values)
|
||||
executor.submit(self.database.cursor().execute, YOUTUBE_DELETE_OLD_ENTRIES, values)
|
||||
executor.submit(self.database.cursor().execute, SPOTIFY_DELETE_OLD_ENTRIES, values)
|
||||
|
||||
def maybe_migrate(self) -> None:
|
||||
"""Maybe migrate Database schema for the local cache"""
|
||||
current_version = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[executor.submit(self.database.cursor().execute, self.statement.get_user_version)]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
current_version = row_result.fetchone()
|
||||
break
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed fetch from database")
|
||||
if isinstance(current_version, tuple):
|
||||
current_version = current_version[0]
|
||||
if current_version == _SCHEMA_VERSION:
|
||||
return
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.set_user_version,
|
||||
{"version": _SCHEMA_VERSION},
|
||||
)
|
||||
|
||||
async def insert(self, values: List[MutableMapping]) -> None:
|
||||
"""Insert an entry into the local cache"""
|
||||
try:
|
||||
with self.database.transaction() as transaction:
|
||||
transaction.executemany(self.statement.upsert, values)
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Error during table insert")
|
||||
|
||||
async def update(self, values: MutableMapping) -> None:
|
||||
"""Update an entry of the local cache"""
|
||||
|
||||
try:
|
||||
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
values["last_fetched"] = time_now
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.cursor().execute, self.statement.update, values)
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Error during table update")
|
||||
|
||||
async def _fetch_one(
|
||||
self, values: MutableMapping
|
||||
) -> Optional[
|
||||
Union[LavalinkCacheFetchResult, SpotifyCacheFetchResult, YouTubeCacheFetchResult]
|
||||
]:
|
||||
"""Get an entry from the local cache"""
|
||||
max_age = await self.config.cache_age()
|
||||
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
|
||||
maxage_int = int(time.mktime(maxage.timetuple()))
|
||||
values.update({"maxage": maxage_int})
|
||||
row = None
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[executor.submit(self.database.cursor().execute, self.statement.get_one, values)]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
row = row_result.fetchone()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed fetch from database")
|
||||
if not row:
|
||||
return None
|
||||
if self.fetch_result is None:
|
||||
return None
|
||||
return self.fetch_result(*row)
|
||||
|
||||
async def _fetch_all(
|
||||
self, values: MutableMapping
|
||||
) -> List[Union[LavalinkCacheFetchResult, SpotifyCacheFetchResult, YouTubeCacheFetchResult]]:
|
||||
"""Get all entries from the local cache"""
|
||||
output = []
|
||||
row_result = []
|
||||
if self.fetch_result is None:
|
||||
return []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[executor.submit(self.database.cursor().execute, self.statement.get_all, values)]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed fetch from database")
|
||||
async for row in AsyncIter(row_result):
|
||||
output.append(self.fetch_result(*row))
|
||||
return output
|
||||
|
||||
async def _fetch_random(
|
||||
self, values: MutableMapping
|
||||
) -> Optional[
|
||||
Union[LavalinkCacheFetchResult, SpotifyCacheFetchResult, YouTubeCacheFetchResult]
|
||||
]:
|
||||
"""Get a random entry from the local cache"""
|
||||
row = None
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[
|
||||
executor.submit(
|
||||
self.database.cursor().execute, self.statement.get_random, values
|
||||
)
|
||||
]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
rows = row_result.fetchall()
|
||||
if rows:
|
||||
row = random.choice(rows)
|
||||
else:
|
||||
row = None
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed random fetch from database")
|
||||
if not row:
|
||||
return None
|
||||
if self.fetch_result is None:
|
||||
return None
|
||||
return self.fetch_result(*row)
|
||||
|
||||
|
||||
class YouTubeTableWrapper(BaseWrapper):
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
|
||||
):
|
||||
super().__init__(bot, config, conn, cog)
|
||||
self.statement.upsert = YOUTUBE_UPSERT
|
||||
self.statement.update = YOUTUBE_UPDATE
|
||||
self.statement.get_one = YOUTUBE_QUERY
|
||||
self.statement.get_all = YOUTUBE_QUERY_ALL
|
||||
self.statement.get_random = YOUTUBE_QUERY_LAST_FETCHED_RANDOM
|
||||
self.fetch_result = YouTubeCacheFetchResult
|
||||
|
||||
async def fetch_one(
|
||||
self, values: MutableMapping
|
||||
) -> Tuple[Optional[str], Optional[datetime.datetime]]:
|
||||
"""Get an entry from the Youtube table"""
|
||||
result = await self._fetch_one(values)
|
||||
if not result or not isinstance(result.query, str):
|
||||
return None, None
|
||||
return result.query, result.updated_on
|
||||
|
||||
async def fetch_all(self, values: MutableMapping) -> List[YouTubeCacheFetchResult]:
|
||||
"""Get all entries from the Youtube table"""
|
||||
result = await self._fetch_all(values)
|
||||
if result and isinstance(result[0], YouTubeCacheFetchResult):
|
||||
return result
|
||||
return []
|
||||
|
||||
async def fetch_random(self, values: MutableMapping) -> Optional[str]:
|
||||
"""Get a random entry from the Youtube table"""
|
||||
result = await self._fetch_random(values)
|
||||
if not result or not isinstance(result.query, str):
|
||||
return None
|
||||
return result.query
|
||||
|
||||
|
||||
class SpotifyTableWrapper(BaseWrapper):
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
|
||||
):
|
||||
super().__init__(bot, config, conn, cog)
|
||||
self.statement.upsert = SPOTIFY_UPSERT
|
||||
self.statement.update = SPOTIFY_UPDATE
|
||||
self.statement.get_one = SPOTIFY_QUERY
|
||||
self.statement.get_all = SPOTIFY_QUERY_ALL
|
||||
self.statement.get_random = SPOTIFY_QUERY_LAST_FETCHED_RANDOM
|
||||
self.fetch_result = SpotifyCacheFetchResult
|
||||
|
||||
async def fetch_one(
|
||||
self, values: MutableMapping
|
||||
) -> Tuple[Optional[str], Optional[datetime.datetime]]:
|
||||
"""Get an entry from the Spotify table"""
|
||||
result = await self._fetch_one(values)
|
||||
if not result or not isinstance(result.query, str):
|
||||
return None, None
|
||||
return result.query, result.updated_on
|
||||
|
||||
async def fetch_all(self, values: MutableMapping) -> List[SpotifyCacheFetchResult]:
|
||||
"""Get all entries from the Spotify table"""
|
||||
result = await self._fetch_all(values)
|
||||
if result and isinstance(result[0], SpotifyCacheFetchResult):
|
||||
return result
|
||||
return []
|
||||
|
||||
async def fetch_random(self, values: MutableMapping) -> Optional[str]:
|
||||
"""Get a random entry from the Spotify table"""
|
||||
result = await self._fetch_random(values)
|
||||
if not result or not isinstance(result.query, str):
|
||||
return None
|
||||
return result.query
|
||||
|
||||
|
||||
class LavalinkTableWrapper(BaseWrapper):
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
|
||||
):
|
||||
super().__init__(bot, config, conn, cog)
|
||||
self.statement.upsert = LAVALINK_UPSERT
|
||||
self.statement.update = LAVALINK_UPDATE
|
||||
self.statement.get_one = LAVALINK_QUERY
|
||||
self.statement.get_all = LAVALINK_QUERY_ALL
|
||||
self.statement.get_random = LAVALINK_QUERY_LAST_FETCHED_RANDOM
|
||||
self.statement.get_all_global = LAVALINK_FETCH_ALL_ENTRIES_GLOBAL
|
||||
self.fetch_result = LavalinkCacheFetchResult
|
||||
self.fetch_for_global: Optional[Callable] = None
|
||||
|
||||
async def fetch_one(
|
||||
self, values: MutableMapping
|
||||
) -> Tuple[Optional[MutableMapping], Optional[datetime.datetime]]:
|
||||
"""Get an entry from the Lavalink table"""
|
||||
result = await self._fetch_one(values)
|
||||
if not result or not isinstance(result.query, dict):
|
||||
return None, None
|
||||
return result.query, result.updated_on
|
||||
|
||||
async def fetch_all(self, values: MutableMapping) -> List[LavalinkCacheFetchResult]:
|
||||
"""Get all entries from the Lavalink table"""
|
||||
result = await self._fetch_all(values)
|
||||
if result and isinstance(result[0], LavalinkCacheFetchResult):
|
||||
return result
|
||||
return []
|
||||
|
||||
async def fetch_random(self, values: MutableMapping) -> Optional[MutableMapping]:
|
||||
"""Get a random entry from the Lavalink table"""
|
||||
result = await self._fetch_random(values)
|
||||
if not result or not isinstance(result.query, dict):
|
||||
return None
|
||||
return result.query
|
||||
|
||||
async def fetch_all_for_global(self) -> List[LavalinkCacheFetchForGlobalResult]:
|
||||
"""Get all entries from the Lavalink table"""
|
||||
output: List[LavalinkCacheFetchForGlobalResult] = []
|
||||
row_result = []
|
||||
if self.fetch_for_global is None:
|
||||
return []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[executor.submit(self.database.cursor().execute, self.statement.get_all_global)]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed fetch from database")
|
||||
async for row in AsyncIter(row_result):
|
||||
output.append(self.fetch_for_global(*row))
|
||||
return output
|
||||
|
||||
|
||||
class LocalCacheWrapper:
|
||||
"""Wraps all table apis into 1 object representing the local cache"""
|
||||
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, conn: APSWConnectionWrapper, cog: Union["Audio", Cog]
|
||||
):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.database = conn
|
||||
self.cog = cog
|
||||
self.lavalink: LavalinkTableWrapper = LavalinkTableWrapper(bot, config, conn, self.cog)
|
||||
self.spotify: SpotifyTableWrapper = SpotifyTableWrapper(bot, config, conn, self.cog)
|
||||
self.youtube: YouTubeTableWrapper = YouTubeTableWrapper(bot, config, conn, self.cog)
|
||||
@ -1,259 +1,19 @@
|
||||
import asyncio
|
||||
from collections import namedtuple
|
||||
from typing import List, MutableMapping, Optional, Union, TYPE_CHECKING
|
||||
import logging
|
||||
from typing import List, MutableMapping, Optional, Union
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import Config, commands
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.i18n import Translator
|
||||
from redbot.core.utils.chat_formatting import humanize_list
|
||||
|
||||
from .databases import PlaylistFetchResult, PlaylistInterface
|
||||
from .errors import InvalidPlaylistScope, MissingAuthor, MissingGuild, NotAllowed
|
||||
from .utils import PlaylistScope
|
||||
from ..errors import NotAllowed
|
||||
from ..utils import PlaylistScope
|
||||
from .api_utils import PlaylistFetchResult, prepare_config_scope, standardize_scope
|
||||
from .playlist_wrapper import PlaylistWrapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
database: PlaylistInterface
|
||||
_bot: Red
|
||||
_config: Config
|
||||
else:
|
||||
database = None
|
||||
_bot = None
|
||||
_config = None
|
||||
|
||||
__all__ = [
|
||||
"Playlist",
|
||||
"get_playlist",
|
||||
"get_all_playlist",
|
||||
"create_playlist",
|
||||
"reset_playlist",
|
||||
"delete_playlist",
|
||||
"standardize_scope",
|
||||
"FakePlaylist",
|
||||
"get_all_playlist_for_migration23",
|
||||
"database",
|
||||
"get_all_playlist_converter",
|
||||
"get_playlist_database",
|
||||
]
|
||||
|
||||
FakePlaylist = namedtuple("Playlist", "author scope")
|
||||
|
||||
_ = Translator("Audio", __file__)
|
||||
|
||||
|
||||
def _pass_config_to_playlist(config: Config, bot: Red):
|
||||
global _config, _bot, database
|
||||
if _config is None:
|
||||
_config = config
|
||||
if _bot is None:
|
||||
_bot = bot
|
||||
if database is None:
|
||||
database = PlaylistInterface()
|
||||
|
||||
|
||||
def get_playlist_database() -> Optional[PlaylistInterface]:
|
||||
global database
|
||||
return database
|
||||
|
||||
|
||||
def standardize_scope(scope: str) -> str:
|
||||
scope = scope.upper()
|
||||
valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"]
|
||||
|
||||
if scope in PlaylistScope.list():
|
||||
return scope
|
||||
elif scope not in valid_scopes:
|
||||
raise InvalidPlaylistScope(
|
||||
f'"{scope}" is not a valid playlist scope.'
|
||||
f" Scope needs to be one of the following: {humanize_list(valid_scopes)}"
|
||||
)
|
||||
|
||||
if scope in ["GLOBAL", "BOT"]:
|
||||
scope = PlaylistScope.GLOBAL.value
|
||||
elif scope in ["GUILD", "SERVER"]:
|
||||
scope = PlaylistScope.GUILD.value
|
||||
elif scope in ["USER", "MEMBER", "AUTHOR"]:
|
||||
scope = PlaylistScope.USER.value
|
||||
|
||||
return scope
|
||||
|
||||
|
||||
def _prepare_config_scope(
|
||||
scope, author: Union[discord.abc.User, int] = None, guild: Union[discord.Guild, int] = None
|
||||
):
|
||||
scope = standardize_scope(scope)
|
||||
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
config_scope = [PlaylistScope.GLOBAL.value, _bot.user.id]
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
if author is None:
|
||||
raise MissingAuthor("Invalid author for user scope.")
|
||||
config_scope = [PlaylistScope.USER.value, int(getattr(author, "id", author))]
|
||||
else:
|
||||
if guild is None:
|
||||
raise MissingGuild("Invalid guild for guild scope.")
|
||||
config_scope = [PlaylistScope.GUILD.value, int(getattr(guild, "id", guild))]
|
||||
return config_scope
|
||||
|
||||
|
||||
def _prepare_config_scope_for_migration23( # TODO: remove me in a future version ?
|
||||
scope, author: Union[discord.abc.User, int] = None, guild: discord.Guild = None
|
||||
):
|
||||
scope = standardize_scope(scope)
|
||||
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
config_scope = [PlaylistScope.GLOBAL.value]
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
if author is None:
|
||||
raise MissingAuthor("Invalid author for user scope.")
|
||||
config_scope = [PlaylistScope.USER.value, str(getattr(author, "id", author))]
|
||||
else:
|
||||
if guild is None:
|
||||
raise MissingGuild("Invalid guild for guild scope.")
|
||||
config_scope = [PlaylistScope.GUILD.value, str(getattr(guild, "id", guild))]
|
||||
return config_scope
|
||||
|
||||
|
||||
class PlaylistMigration23: # TODO: remove me in a future version ?
|
||||
"""A single playlist."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: str,
|
||||
author: int,
|
||||
playlist_id: int,
|
||||
name: str,
|
||||
playlist_url: Optional[str] = None,
|
||||
tracks: Optional[List[MutableMapping]] = None,
|
||||
guild: Union[discord.Guild, int, None] = None,
|
||||
):
|
||||
self.guild = guild
|
||||
self.scope = standardize_scope(scope)
|
||||
self.author = author
|
||||
self.id = playlist_id
|
||||
self.name = name
|
||||
self.url = playlist_url
|
||||
self.tracks = tracks or []
|
||||
|
||||
@classmethod
|
||||
async def from_json(
|
||||
cls, scope: str, playlist_number: int, data: MutableMapping, **kwargs
|
||||
) -> "PlaylistMigration23":
|
||||
"""Get a Playlist object from the provided information.
|
||||
Parameters
|
||||
----------
|
||||
scope:str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
playlist_number: int
|
||||
The playlist's number.
|
||||
data: dict
|
||||
The JSON representation of the playlist to be gotten.
|
||||
**kwargs
|
||||
Extra attributes for the Playlist instance which override values
|
||||
in the data dict. These should be complete objects and not
|
||||
IDs, where possible.
|
||||
Returns
|
||||
-------
|
||||
Playlist
|
||||
The playlist object for the requested playlist.
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
`MissingGuild`
|
||||
Trying to access the Guild scope without a guild.
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
guild = data.get("guild") or kwargs.get("guild")
|
||||
author: int = data.get("author") or 0
|
||||
playlist_id = data.get("id") or playlist_number
|
||||
name = data.get("name", "Unnamed")
|
||||
playlist_url = data.get("playlist_url", None)
|
||||
tracks = data.get("tracks", [])
|
||||
|
||||
return cls(
|
||||
guild=guild,
|
||||
scope=scope,
|
||||
author=author,
|
||||
playlist_id=playlist_id,
|
||||
name=name,
|
||||
playlist_url=playlist_url,
|
||||
tracks=tracks,
|
||||
)
|
||||
|
||||
async def save(self):
|
||||
"""Saves a Playlist to SQL."""
|
||||
scope, scope_id = _prepare_config_scope(self.scope, self.author, self.guild)
|
||||
database.upsert(
|
||||
scope,
|
||||
playlist_id=int(self.id),
|
||||
playlist_name=self.name,
|
||||
scope_id=scope_id,
|
||||
author_id=self.author,
|
||||
playlist_url=self.url,
|
||||
tracks=self.tracks,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_playlist_for_migration23( # TODO: remove me in a future version ?
|
||||
scope: str, guild: Union[discord.Guild, int] = None
|
||||
) -> List[PlaylistMigration23]:
|
||||
"""
|
||||
Gets all playlist for the specified scope.
|
||||
Parameters
|
||||
----------
|
||||
scope: str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
guild: discord.Guild
|
||||
The guild to get the playlist from if scope is GUILDPLAYLIST.
|
||||
Returns
|
||||
-------
|
||||
list
|
||||
A list of all playlists for the specified scope
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
`MissingGuild`
|
||||
Trying to access the Guild scope without a guild.
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
playlists = await _config.custom(scope).all()
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
return [
|
||||
await PlaylistMigration23.from_json(
|
||||
scope,
|
||||
playlist_number,
|
||||
playlist_data,
|
||||
guild=guild,
|
||||
author=int(playlist_data.get("author", 0)),
|
||||
)
|
||||
for playlist_number, playlist_data in playlists.items()
|
||||
]
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
return [
|
||||
await PlaylistMigration23.from_json(
|
||||
scope, playlist_number, playlist_data, guild=guild, author=int(user_id)
|
||||
)
|
||||
for user_id, scopedata in playlists.items()
|
||||
for playlist_number, playlist_data in scopedata.items()
|
||||
]
|
||||
else:
|
||||
return [
|
||||
await PlaylistMigration23.from_json(
|
||||
scope,
|
||||
playlist_number,
|
||||
playlist_data,
|
||||
guild=int(guild_id),
|
||||
author=int(playlist_data.get("author", 0)),
|
||||
)
|
||||
for guild_id, scopedata in playlists.items()
|
||||
for playlist_number, playlist_data in scopedata.items()
|
||||
]
|
||||
log = logging.getLogger("red.cogs.Audio.api.PlaylistsInterface")
|
||||
|
||||
|
||||
class Playlist:
|
||||
@ -262,6 +22,7 @@ class Playlist:
|
||||
def __init__(
|
||||
self,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
author: int,
|
||||
playlist_id: int,
|
||||
@ -273,7 +34,7 @@ class Playlist:
|
||||
self.bot = bot
|
||||
self.guild = guild
|
||||
self.scope = standardize_scope(scope)
|
||||
self.config_scope = _prepare_config_scope(self.scope, author, guild)
|
||||
self.config_scope = prepare_config_scope(self.bot, self.scope, author, guild)
|
||||
self.scope_id = self.config_scope[-1]
|
||||
self.author = author
|
||||
self.author_id = getattr(self.author, "id", self.author)
|
||||
@ -285,6 +46,7 @@ class Playlist:
|
||||
self.url = playlist_url
|
||||
self.tracks = tracks or []
|
||||
self.tracks_obj = [lavalink.Track(data=track) for track in self.tracks]
|
||||
self.playlist_api = playlist_api
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
@ -313,7 +75,7 @@ class Playlist:
|
||||
async def save(self):
|
||||
"""Saves a Playlist."""
|
||||
scope, scope_id = self.config_scope
|
||||
database.upsert(
|
||||
await self.playlist_api.upsert(
|
||||
scope,
|
||||
playlist_id=int(self.id),
|
||||
playlist_name=self.name,
|
||||
@ -343,13 +105,21 @@ class Playlist:
|
||||
|
||||
@classmethod
|
||||
async def from_json(
|
||||
cls, bot: Red, scope: str, playlist_number: int, data: PlaylistFetchResult, **kwargs
|
||||
cls,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
playlist_number: int,
|
||||
data: PlaylistFetchResult,
|
||||
**kwargs,
|
||||
) -> "Playlist":
|
||||
"""Get a Playlist object from the provided information.
|
||||
Parameters
|
||||
----------
|
||||
bot: Red
|
||||
The bot's instance. Needed to get the target user.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
scope:str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
playlist_number: int
|
||||
@ -382,6 +152,7 @@ class Playlist:
|
||||
|
||||
return cls(
|
||||
bot=bot,
|
||||
playlist_api=playlist_api,
|
||||
guild=guild,
|
||||
scope=scope,
|
||||
author=author,
|
||||
@ -392,10 +163,189 @@ class Playlist:
|
||||
)
|
||||
|
||||
|
||||
class PlaylistCompat23:
|
||||
"""A single playlist, migrating from Schema 2 to Schema 3"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
author: int,
|
||||
playlist_id: int,
|
||||
name: str,
|
||||
playlist_url: Optional[str] = None,
|
||||
tracks: Optional[List[MutableMapping]] = None,
|
||||
guild: Union[discord.Guild, int, None] = None,
|
||||
):
|
||||
|
||||
self.bot = bot
|
||||
self.guild = guild
|
||||
self.scope = standardize_scope(scope)
|
||||
self.author = author
|
||||
self.id = playlist_id
|
||||
self.name = name
|
||||
self.url = playlist_url
|
||||
self.tracks = tracks or []
|
||||
|
||||
self.playlist_api = playlist_api
|
||||
|
||||
@classmethod
|
||||
async def from_json(
|
||||
cls,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
playlist_number: int,
|
||||
data: MutableMapping,
|
||||
**kwargs,
|
||||
) -> "PlaylistCompat23":
|
||||
"""Get a Playlist object from the provided information.
|
||||
Parameters
|
||||
----------
|
||||
bot: Red
|
||||
The Bot instance.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
scope:str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
playlist_number: int
|
||||
The playlist's number.
|
||||
data: MutableMapping
|
||||
The JSON representation of the playlist to be gotten.
|
||||
**kwargs
|
||||
Extra attributes for the Playlist instance which override values
|
||||
in the data dict. These should be complete objects and not
|
||||
IDs, where possible.
|
||||
Returns
|
||||
-------
|
||||
Playlist
|
||||
The playlist object for the requested playlist.
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
`MissingGuild`
|
||||
Trying to access the Guild scope without a guild.
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
guild = data.get("guild") or kwargs.get("guild")
|
||||
author: int = data.get("author") or 0
|
||||
playlist_id = data.get("id") or playlist_number
|
||||
name = data.get("name", "Unnamed")
|
||||
playlist_url = data.get("playlist_url", None)
|
||||
tracks = data.get("tracks", [])
|
||||
|
||||
return cls(
|
||||
bot=bot,
|
||||
playlist_api=playlist_api,
|
||||
guild=guild,
|
||||
scope=scope,
|
||||
author=author,
|
||||
playlist_id=playlist_id,
|
||||
name=name,
|
||||
playlist_url=playlist_url,
|
||||
tracks=tracks,
|
||||
)
|
||||
|
||||
async def save(self):
|
||||
"""Saves a Playlist to SQL."""
|
||||
scope, scope_id = prepare_config_scope(self.bot, self.scope, self.author, self.guild)
|
||||
await self.playlist_api.upsert(
|
||||
scope,
|
||||
playlist_id=int(self.id),
|
||||
playlist_name=self.name,
|
||||
scope_id=scope_id,
|
||||
author_id=self.author,
|
||||
playlist_url=self.url,
|
||||
tracks=self.tracks,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_playlist_for_migration23(
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
config: Config,
|
||||
scope: str,
|
||||
guild: Union[discord.Guild, int] = None,
|
||||
) -> List[PlaylistCompat23]:
|
||||
"""
|
||||
Gets all playlist for the specified scope.
|
||||
Parameters
|
||||
----------
|
||||
bot: Red
|
||||
The Bot instance.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
config: Config
|
||||
The Audio cog Config instance.
|
||||
scope: str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
guild: discord.Guild
|
||||
The guild to get the playlist from if scope is GUILDPLAYLIST.
|
||||
Returns
|
||||
-------
|
||||
list
|
||||
A list of all playlists for the specified scope
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
`MissingGuild`
|
||||
Trying to access the Guild scope without a guild.
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
playlists = await config.custom(scope).all()
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
return [
|
||||
await PlaylistCompat23.from_json(
|
||||
bot,
|
||||
playlist_api,
|
||||
scope,
|
||||
playlist_number,
|
||||
playlist_data,
|
||||
guild=guild,
|
||||
author=int(playlist_data.get("author", 0)),
|
||||
)
|
||||
async for playlist_number, playlist_data in AsyncIter(playlists.items())
|
||||
]
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
return [
|
||||
await PlaylistCompat23.from_json(
|
||||
bot,
|
||||
playlist_api,
|
||||
scope,
|
||||
playlist_number,
|
||||
playlist_data,
|
||||
guild=guild,
|
||||
author=int(user_id),
|
||||
)
|
||||
async for user_id, scopedata in AsyncIter(playlists.items())
|
||||
async for playlist_number, playlist_data in AsyncIter(scopedata.items())
|
||||
]
|
||||
else:
|
||||
return [
|
||||
await PlaylistCompat23.from_json(
|
||||
bot,
|
||||
playlist_api,
|
||||
scope,
|
||||
playlist_number,
|
||||
playlist_data,
|
||||
guild=int(guild_id),
|
||||
author=int(playlist_data.get("author", 0)),
|
||||
)
|
||||
async for guild_id, scopedata in AsyncIter(playlists.items())
|
||||
async for playlist_number, playlist_data in AsyncIter(scopedata.items())
|
||||
]
|
||||
|
||||
|
||||
async def get_playlist(
|
||||
playlist_number: int,
|
||||
scope: str,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
guild: Union[discord.Guild, int] = None,
|
||||
author: Union[discord.abc.User, int] = None,
|
||||
) -> Playlist:
|
||||
@ -405,6 +355,8 @@ async def get_playlist(
|
||||
----------
|
||||
playlist_number: int
|
||||
The playlist number for the playlist to get.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
scope: str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
guild: discord.Guild
|
||||
@ -428,19 +380,26 @@ async def get_playlist(
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
scope_standard, scope_id = _prepare_config_scope(scope, author, guild)
|
||||
playlist_data = database.fetch(scope_standard, playlist_number, scope_id)
|
||||
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
|
||||
playlist_data = await playlist_api.fetch(scope_standard, playlist_number, scope_id)
|
||||
|
||||
if not (playlist_data and playlist_data.playlist_id):
|
||||
raise RuntimeError(f"That playlist does not exist for the following scope: {scope}")
|
||||
return await Playlist.from_json(
|
||||
bot, scope_standard, playlist_number, playlist_data, guild=guild, author=author
|
||||
bot,
|
||||
playlist_api,
|
||||
scope_standard,
|
||||
playlist_number,
|
||||
playlist_data,
|
||||
guild=guild,
|
||||
author=author,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_playlist(
|
||||
scope: str,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
guild: Union[discord.Guild, int] = None,
|
||||
author: Union[discord.abc.User, int] = None,
|
||||
specified_user: bool = False,
|
||||
@ -457,13 +416,15 @@ async def get_all_playlist(
|
||||
The ID of the user to get the playlist from if scope is USERPLAYLIST.
|
||||
bot: Red
|
||||
The bot's instance
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
specified_user:bool
|
||||
Whether or not user ID was passed as an argparse.
|
||||
Returns
|
||||
-------
|
||||
list
|
||||
A list of all playlists for the specified scope
|
||||
Raises
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
@ -472,28 +433,34 @@ async def get_all_playlist(
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
scope_standard, scope_id = _prepare_config_scope(scope, author, guild)
|
||||
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
|
||||
|
||||
if specified_user:
|
||||
user_id = getattr(author, "id", author)
|
||||
playlists = await database.fetch_all(scope_standard, scope_id, author_id=user_id)
|
||||
playlists = await playlist_api.fetch_all(scope_standard, scope_id, author_id=user_id)
|
||||
else:
|
||||
playlists = await database.fetch_all(scope_standard, scope_id)
|
||||
playlists = await playlist_api.fetch_all(scope_standard, scope_id)
|
||||
|
||||
playlist_list = []
|
||||
for playlist in playlists:
|
||||
async for playlist in AsyncIter(playlists):
|
||||
playlist_list.append(
|
||||
await Playlist.from_json(
|
||||
bot, scope, playlist.playlist_id, playlist, guild=guild, author=author
|
||||
bot,
|
||||
playlist_api,
|
||||
scope,
|
||||
playlist.playlist_id,
|
||||
playlist,
|
||||
guild=guild,
|
||||
author=author,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
return playlist_list
|
||||
|
||||
|
||||
async def get_all_playlist_converter(
|
||||
scope: str,
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
arg: str,
|
||||
guild: Union[discord.Guild, int] = None,
|
||||
author: Union[discord.abc.User, int] = None,
|
||||
@ -512,11 +479,13 @@ async def get_all_playlist_converter(
|
||||
The bot's instance
|
||||
arg:str
|
||||
The value to lookup.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
Returns
|
||||
-------
|
||||
list
|
||||
A list of all playlists for the specified scope
|
||||
Raises
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
@ -525,23 +494,29 @@ async def get_all_playlist_converter(
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
scope_standard, scope_id = _prepare_config_scope(scope, author, guild)
|
||||
playlists = await database.fetch_all_converter(
|
||||
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
|
||||
playlists = await playlist_api.fetch_all_converter(
|
||||
scope_standard, playlist_name=arg, playlist_id=arg
|
||||
)
|
||||
playlist_list = []
|
||||
for playlist in playlists:
|
||||
async for playlist in AsyncIter(playlists):
|
||||
playlist_list.append(
|
||||
await Playlist.from_json(
|
||||
bot, scope, playlist.playlist_id, playlist, guild=guild, author=author
|
||||
bot,
|
||||
playlist_api,
|
||||
scope,
|
||||
playlist.playlist_id,
|
||||
playlist,
|
||||
guild=guild,
|
||||
author=author,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
return playlist_list
|
||||
|
||||
|
||||
async def create_playlist(
|
||||
ctx: commands.Context,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
playlist_name: str,
|
||||
playlist_url: Optional[str] = None,
|
||||
@ -570,6 +545,8 @@ async def create_playlist(
|
||||
guild: discord.Guild
|
||||
The guild to create this playlist under.
|
||||
This is only used when creating a playlist in the Guild scope
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
|
||||
Raises
|
||||
------
|
||||
@ -583,6 +560,7 @@ async def create_playlist(
|
||||
|
||||
playlist = Playlist(
|
||||
ctx.bot,
|
||||
playlist_api,
|
||||
scope,
|
||||
author.id if author else None,
|
||||
ctx.message.id,
|
||||
@ -596,6 +574,8 @@ async def create_playlist(
|
||||
|
||||
|
||||
async def reset_playlist(
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
guild: Union[discord.Guild, int] = None,
|
||||
author: Union[discord.abc.User, int] = None,
|
||||
@ -604,14 +584,18 @@ async def reset_playlist(
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bot: Red
|
||||
The bot's instance
|
||||
scope: str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
guild: discord.Guild
|
||||
The guild to get the playlist from if scope is GUILDPLAYLIST.
|
||||
author: int
|
||||
The ID of the user to get the playlist from if scope is USERPLAYLIST.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
|
||||
Raises
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
@ -620,12 +604,14 @@ async def reset_playlist(
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
scope, scope_id = _prepare_config_scope(scope, author, guild)
|
||||
database.drop(scope)
|
||||
database.create_table(scope)
|
||||
scope, scope_id = prepare_config_scope(bot, scope, author, guild)
|
||||
await playlist_api.drop(scope)
|
||||
await playlist_api.create_table()
|
||||
|
||||
|
||||
async def delete_playlist(
|
||||
bot: Red,
|
||||
playlist_api: PlaylistWrapper,
|
||||
scope: str,
|
||||
playlist_id: Union[str, int],
|
||||
guild: discord.Guild,
|
||||
@ -635,6 +621,8 @@ async def delete_playlist(
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bot: Red
|
||||
The bot's instance
|
||||
scope: str
|
||||
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
|
||||
playlist_id: Union[str, int]
|
||||
@ -643,8 +631,10 @@ async def delete_playlist(
|
||||
The guild to get the playlist from if scope is GUILDPLAYLIST.
|
||||
author: int
|
||||
The ID of the user to get the playlist from if scope is USERPLAYLIST.
|
||||
playlist_api: PlaylistWrapper
|
||||
The Playlist API interface.
|
||||
|
||||
Raises
|
||||
Raises
|
||||
------
|
||||
`InvalidPlaylistScope`
|
||||
Passing a scope that is not supported.
|
||||
@ -653,5 +643,5 @@ async def delete_playlist(
|
||||
`MissingAuthor`
|
||||
Trying to access the User scope without an user id.
|
||||
"""
|
||||
scope, scope_id = _prepare_config_scope(scope, author, guild)
|
||||
database.delete(scope, int(playlist_id), scope_id)
|
||||
scope, scope_id = prepare_config_scope(bot, scope, author, guild)
|
||||
await playlist_api.delete(scope, int(playlist_id), scope_id)
|
||||
249
redbot/cogs/audio/apis/playlist_wrapper.py
Normal file
249
redbot/cogs/audio/apis/playlist_wrapper.py
Normal file
@ -0,0 +1,249 @@
|
||||
import concurrent
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import List, MutableMapping, Optional
|
||||
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.utils.dbtools import APSWConnectionWrapper
|
||||
|
||||
from ..audio_logging import debug_exc_log
|
||||
from ..sql_statements import (
|
||||
PLAYLIST_CREATE_INDEX,
|
||||
PLAYLIST_CREATE_TABLE,
|
||||
PLAYLIST_DELETE,
|
||||
PLAYLIST_DELETE_SCHEDULED,
|
||||
PLAYLIST_DELETE_SCOPE,
|
||||
PLAYLIST_FETCH,
|
||||
PLAYLIST_FETCH_ALL,
|
||||
PLAYLIST_FETCH_ALL_CONVERTER,
|
||||
PLAYLIST_FETCH_ALL_WITH_FILTER,
|
||||
PLAYLIST_UPSERT,
|
||||
PRAGMA_FETCH_user_version,
|
||||
PRAGMA_SET_journal_mode,
|
||||
PRAGMA_SET_read_uncommitted,
|
||||
PRAGMA_SET_temp_store,
|
||||
PRAGMA_SET_user_version,
|
||||
)
|
||||
from ..utils import PlaylistScope
|
||||
from .api_utils import PlaylistFetchResult
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.api.Playlists")
|
||||
|
||||
|
||||
class PlaylistWrapper:
|
||||
def __init__(self, bot: Red, config: Config, conn: APSWConnectionWrapper):
|
||||
self.bot = bot
|
||||
self.database = conn
|
||||
self.config = config
|
||||
self.statement = SimpleNamespace()
|
||||
self.statement.pragma_temp_store = PRAGMA_SET_temp_store
|
||||
self.statement.pragma_journal_mode = PRAGMA_SET_journal_mode
|
||||
self.statement.pragma_read_uncommitted = PRAGMA_SET_read_uncommitted
|
||||
self.statement.set_user_version = PRAGMA_SET_user_version
|
||||
self.statement.get_user_version = PRAGMA_FETCH_user_version
|
||||
self.statement.create_table = PLAYLIST_CREATE_TABLE
|
||||
self.statement.create_index = PLAYLIST_CREATE_INDEX
|
||||
|
||||
self.statement.upsert = PLAYLIST_UPSERT
|
||||
self.statement.delete = PLAYLIST_DELETE
|
||||
self.statement.delete_scope = PLAYLIST_DELETE_SCOPE
|
||||
self.statement.delete_scheduled = PLAYLIST_DELETE_SCHEDULED
|
||||
|
||||
self.statement.get_one = PLAYLIST_FETCH
|
||||
self.statement.get_all = PLAYLIST_FETCH_ALL
|
||||
self.statement.get_all_with_filter = PLAYLIST_FETCH_ALL_WITH_FILTER
|
||||
self.statement.get_all_converter = PLAYLIST_FETCH_ALL_CONVERTER
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Initialize the Playlist table"""
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.cursor().execute, self.statement.pragma_temp_store)
|
||||
executor.submit(self.database.cursor().execute, self.statement.pragma_journal_mode)
|
||||
executor.submit(self.database.cursor().execute, self.statement.pragma_read_uncommitted)
|
||||
executor.submit(self.database.cursor().execute, self.statement.create_table)
|
||||
executor.submit(self.database.cursor().execute, self.statement.create_index)
|
||||
|
||||
@staticmethod
|
||||
def get_scope_type(scope: str) -> int:
|
||||
"""Convert a scope to a numerical identifier"""
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
table = 1
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
table = 3
|
||||
else:
|
||||
table = 2
|
||||
return table
|
||||
|
||||
async def fetch(self, scope: str, playlist_id: int, scope_id: int) -> PlaylistFetchResult:
|
||||
"""Fetch a single playlist"""
|
||||
scope_type = self.get_scope_type(scope)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.get_one,
|
||||
(
|
||||
{
|
||||
"playlist_id": playlist_id,
|
||||
"scope_id": scope_id,
|
||||
"scope_type": scope_type,
|
||||
}
|
||||
),
|
||||
)
|
||||
]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed playlist fetch from database")
|
||||
row = row_result.fetchone()
|
||||
if row:
|
||||
row = PlaylistFetchResult(*row)
|
||||
return row
|
||||
|
||||
async def fetch_all(
|
||||
self, scope: str, scope_id: int, author_id=None
|
||||
) -> List[PlaylistFetchResult]:
|
||||
"""Fetch all playlists"""
|
||||
scope_type = self.get_scope_type(scope)
|
||||
output = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
if author_id is not None:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.get_all_with_filter,
|
||||
(
|
||||
{
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"author_id": author_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed playlist fetch from database")
|
||||
return []
|
||||
else:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.get_all,
|
||||
({"scope_type": scope_type, "scope_id": scope_id}),
|
||||
)
|
||||
]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed playlist fetch from database")
|
||||
return []
|
||||
async for row in AsyncIter(row_result):
|
||||
output.append(PlaylistFetchResult(*row))
|
||||
return output
|
||||
|
||||
async def fetch_all_converter(
|
||||
self, scope: str, playlist_name, playlist_id
|
||||
) -> List[PlaylistFetchResult]:
|
||||
"""Fetch all playlists with the specified filter"""
|
||||
scope_type = self.get_scope_type(scope)
|
||||
try:
|
||||
playlist_id = int(playlist_id)
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed converting playlist_id to int")
|
||||
playlist_id = -1
|
||||
|
||||
output = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.get_all_converter,
|
||||
(
|
||||
{
|
||||
"scope_type": scope_type,
|
||||
"playlist_name": playlist_name,
|
||||
"playlist_id": playlist_id,
|
||||
}
|
||||
),
|
||||
)
|
||||
]
|
||||
):
|
||||
try:
|
||||
row_result = future.result()
|
||||
except Exception as exc:
|
||||
debug_exc_log(log, exc, "Failed to completed fetch from database")
|
||||
|
||||
async for row in AsyncIter(row_result):
|
||||
output.append(PlaylistFetchResult(*row))
|
||||
return output
|
||||
|
||||
async def delete(self, scope: str, playlist_id: int, scope_id: int):
|
||||
"""Deletes a single playlists"""
|
||||
scope_type = self.get_scope_type(scope)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.delete,
|
||||
({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}),
|
||||
)
|
||||
|
||||
async def delete_scheduled(self):
|
||||
"""Clean up database from all deleted playlists"""
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.cursor().execute, self.statement.delete_scheduled)
|
||||
|
||||
async def drop(self, scope: str):
|
||||
"""Delete all playlists in a scope"""
|
||||
scope_type = self.get_scope_type(scope)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.delete_scope,
|
||||
({"scope_type": scope_type}),
|
||||
)
|
||||
|
||||
async def create_table(self):
|
||||
"""Create the playlist table"""
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.cursor().execute, PLAYLIST_CREATE_TABLE)
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
scope: str,
|
||||
playlist_id: int,
|
||||
playlist_name: str,
|
||||
scope_id: int,
|
||||
author_id: int,
|
||||
playlist_url: Optional[str],
|
||||
tracks: List[MutableMapping],
|
||||
):
|
||||
"""Insert or update a playlist into the database"""
|
||||
scope_type = self.get_scope_type(scope)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
self.database.cursor().execute,
|
||||
self.statement.upsert,
|
||||
{
|
||||
"scope_type": str(scope_type),
|
||||
"playlist_id": int(playlist_id),
|
||||
"playlist_name": str(playlist_name),
|
||||
"scope_id": int(scope_id),
|
||||
"author_id": int(author_id),
|
||||
"playlist_url": playlist_url,
|
||||
"tracks": json.dumps(tracks),
|
||||
},
|
||||
)
|
||||
189
redbot/cogs/audio/apis/spotify.py
Normal file
189
redbot/cogs/audio/apis/spotify.py
Normal file
@ -0,0 +1,189 @@
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Mapping, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union
|
||||
|
||||
import aiohttp
|
||||
from redbot.core.i18n import Translator
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands import Cog, Context
|
||||
|
||||
from ..errors import SpotifyFetchError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Audio
|
||||
|
||||
_ = Translator("Audio", __file__)
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.api.Spotify")
|
||||
|
||||
|
||||
CATEGORY_ENDPOINT = "https://api.spotify.com/v1/browse/categories"
|
||||
TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"
|
||||
ALBUMS_ENDPOINT = "https://api.spotify.com/v1/albums"
|
||||
TRACKS_ENDPOINT = "https://api.spotify.com/v1/tracks"
|
||||
PLAYLISTS_ENDPOINT = "https://api.spotify.com/v1/playlists"
|
||||
|
||||
|
||||
class SpotifyWrapper:
|
||||
"""Wrapper for the Spotify API."""
|
||||
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, session: aiohttp.ClientSession, cog: Union["Audio", Cog]
|
||||
):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.spotify_token: Optional[MutableMapping] = None
|
||||
self.client_id: Optional[str] = None
|
||||
self.client_secret: Optional[str] = None
|
||||
self._token: Mapping[str, str] = {}
|
||||
self.cog = cog
|
||||
|
||||
@staticmethod
|
||||
def spotify_format_call(query_type: str, key: str) -> Tuple[str, MutableMapping]:
|
||||
"""Format the spotify endpoint"""
|
||||
params: MutableMapping = {}
|
||||
if query_type == "album":
|
||||
query = f"{ALBUMS_ENDPOINT}/{key}/tracks"
|
||||
elif query_type == "track":
|
||||
query = f"{TRACKS_ENDPOINT}/{key}"
|
||||
else:
|
||||
query = f"{PLAYLISTS_ENDPOINT}/{key}/tracks"
|
||||
return query, params
|
||||
|
||||
async def get_spotify_track_info(
|
||||
self, track_data: MutableMapping, ctx: Context
|
||||
) -> Tuple[str, ...]:
|
||||
"""Extract track info from spotify response"""
|
||||
prefer_lyrics = await self.cog.get_lyrics_status(ctx)
|
||||
track_name = track_data["name"]
|
||||
if prefer_lyrics:
|
||||
track_name = f"{track_name} - lyrics"
|
||||
artist_name = track_data["artists"][0]["name"]
|
||||
track_info = f"{track_name} {artist_name}"
|
||||
song_url = track_data.get("external_urls", {}).get("spotify")
|
||||
uri = track_data["uri"]
|
||||
_id = track_data["id"]
|
||||
_type = track_data["type"]
|
||||
|
||||
return song_url, track_info, uri, artist_name, track_name, _id, _type
|
||||
|
||||
@staticmethod
|
||||
async def is_access_token_valid(token: MutableMapping) -> bool:
|
||||
"""Check if current token is not too old"""
|
||||
return (token["expires_at"] - int(time.time())) < 60
|
||||
|
||||
@staticmethod
|
||||
def make_auth_header(
|
||||
client_id: Optional[str], client_secret: Optional[str]
|
||||
) -> MutableMapping[str, Union[str, int]]:
|
||||
"""Make Authorization header for spotify token"""
|
||||
if client_id is None:
|
||||
client_id = ""
|
||||
if client_secret is None:
|
||||
client_secret = ""
|
||||
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode("ascii"))
|
||||
return {"Authorization": f"Basic {auth_header.decode('ascii')}"}
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: MutableMapping = None, params: MutableMapping = None
|
||||
) -> MutableMapping[str, str]:
|
||||
"""Make a GET request to the spotify API"""
|
||||
if params is None:
|
||||
params = {}
|
||||
async with self.session.request("GET", url, params=params, headers=headers) as r:
|
||||
data = await r.json()
|
||||
if r.status != 200:
|
||||
log.debug(f"Issue making GET request to {url}: [{r.status}] {data}")
|
||||
return data
|
||||
|
||||
def update_token(self, new_token: Mapping[str, str]):
|
||||
self._token = new_token
|
||||
|
||||
async def get_token(self) -> None:
|
||||
"""Get the stored spotify tokens"""
|
||||
if not self._token:
|
||||
self._token = await self.bot.get_shared_api_tokens("spotify")
|
||||
|
||||
self.client_id = self._token.get("client_id", "")
|
||||
self.client_secret = self._token.get("client_secret", "")
|
||||
|
||||
async def get_country_code(self, ctx: Context = None) -> str:
|
||||
return await self.config.guild(ctx.guild).country_code() if ctx else "US"
|
||||
|
||||
async def request_access_token(self) -> MutableMapping:
|
||||
"""Make a spotify call to get the auth token"""
|
||||
await self.get_token()
|
||||
payload = {"grant_type": "client_credentials"}
|
||||
headers = self.make_auth_header(self.client_id, self.client_secret)
|
||||
r = await self.post(TOKEN_ENDPOINT, payload=payload, headers=headers)
|
||||
return r
|
||||
|
||||
async def get_access_token(self) -> Optional[str]:
|
||||
"""Get the access_token"""
|
||||
if self.spotify_token and not await self.is_access_token_valid(self.spotify_token):
|
||||
return self.spotify_token["access_token"]
|
||||
token = await self.request_access_token()
|
||||
if token is None:
|
||||
log.debug("Requested a token from Spotify, did not end up getting one.")
|
||||
try:
|
||||
token["expires_at"] = int(time.time()) + int(token["expires_in"])
|
||||
except KeyError:
|
||||
return None
|
||||
self.spotify_token = token
|
||||
log.debug(f"Created a new access token for Spotify: {token}")
|
||||
return self.spotify_token["access_token"]
|
||||
|
||||
async def post(
|
||||
self, url: str, payload: MutableMapping, headers: MutableMapping = None
|
||||
) -> MutableMapping:
|
||||
"""Make a POST call to spotify"""
|
||||
async with self.session.post(url, data=payload, headers=headers) as r:
|
||||
data = await r.json()
|
||||
if r.status != 200:
|
||||
log.debug(f"Issue making POST request to {url}: [{r.status}] {data}")
|
||||
return data
|
||||
|
||||
async def make_get_call(self, url: str, params: MutableMapping) -> MutableMapping:
|
||||
"""Make a Get call to spotify"""
|
||||
token = await self.get_access_token()
|
||||
return await self.get(url, params=params, headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
async def get_categories(self, ctx: Context = None) -> List[MutableMapping]:
|
||||
"""Get the spotify categories"""
|
||||
country_code = await self.get_country_code(ctx=ctx)
|
||||
params: MutableMapping = {"country": country_code} if country_code else {}
|
||||
result = await self.make_get_call(CATEGORY_ENDPOINT, params=params)
|
||||
with contextlib.suppress(KeyError):
|
||||
if result["error"]["status"] == 401:
|
||||
raise SpotifyFetchError(
|
||||
message=_(
|
||||
"The Spotify API key or client secret has not been set properly. "
|
||||
"\nUse `{prefix}audioset spotifyapi` for instructions."
|
||||
)
|
||||
)
|
||||
categories = result.get("categories", {}).get("items", [])
|
||||
return [{c["name"]: c["id"]} for c in categories if c]
|
||||
|
||||
async def get_playlist_from_category(self, category: str, ctx: Context = None):
|
||||
"""Get spotify playlists for the specified category"""
|
||||
url = f"{CATEGORY_ENDPOINT}/{category}/playlists"
|
||||
country_code = await self.get_country_code(ctx=ctx)
|
||||
params: MutableMapping = {"country": country_code} if country_code else {}
|
||||
result = await self.make_get_call(url, params=params)
|
||||
playlists = result.get("playlists", {}).get("items", [])
|
||||
return [
|
||||
{
|
||||
"name": c["name"],
|
||||
"uri": c["uri"],
|
||||
"url": c.get("external_urls", {}).get("spotify"),
|
||||
"tracks": c.get("tracks", {}).get("total", "Unknown"),
|
||||
}
|
||||
async for c in AsyncIter(playlists)
|
||||
if c
|
||||
]
|
||||
65
redbot/cogs/audio/apis/youtube.py
Normal file
65
redbot/cogs/audio/apis/youtube.py
Normal file
@ -0,0 +1,65 @@
|
||||
import logging
|
||||
from typing import Mapping, Optional, TYPE_CHECKING, Union
|
||||
|
||||
import aiohttp
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands import Cog
|
||||
|
||||
from ..errors import YouTubeApiError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Audio
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.api.YouTube")
|
||||
|
||||
SEARCH_ENDPOINT = "https://www.googleapis.com/youtube/v3/search"
|
||||
|
||||
|
||||
class YouTubeWrapper:
|
||||
"""Wrapper for the YouTube Data API."""
|
||||
|
||||
def __init__(
|
||||
self, bot: Red, config: Config, session: aiohttp.ClientSession, cog: Union["Audio", Cog]
|
||||
):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.api_key: Optional[str] = None
|
||||
self._token: Mapping[str, str] = {}
|
||||
self.cog = cog
|
||||
|
||||
def update_token(self, new_token: Mapping[str, str]):
|
||||
self._token = new_token
|
||||
|
||||
async def _get_api_key(self,) -> str:
|
||||
"""Get the stored youtube token"""
|
||||
if not self._token:
|
||||
self._token = await self.bot.get_shared_api_tokens("youtube")
|
||||
self.api_key = self._token.get("api_key", "")
|
||||
return self.api_key if self.api_key is not None else ""
|
||||
|
||||
async def get_call(self, query: str) -> Optional[str]:
|
||||
"""Make a Get call to youtube data api"""
|
||||
params = {
|
||||
"q": query,
|
||||
"part": "id",
|
||||
"key": await self._get_api_key(),
|
||||
"maxResults": 1,
|
||||
"type": "video",
|
||||
}
|
||||
async with self.session.request("GET", SEARCH_ENDPOINT, params=params) as r:
|
||||
if r.status in [400, 404]:
|
||||
return None
|
||||
elif r.status in [403, 429]:
|
||||
if r.reason == "quotaExceeded":
|
||||
raise YouTubeApiError("Your YouTube Data API quota has been reached.")
|
||||
return None
|
||||
else:
|
||||
search_response = await r.json()
|
||||
for search_result in search_response.get("items", []):
|
||||
if search_result["id"]["kind"] == "youtube#video":
|
||||
return f"https://www.youtube.com/watch?v={search_result['id']['videoId']}"
|
||||
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,36 +1,38 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import glob
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
from pathlib import Path, PosixPath, WindowsPath
|
||||
from typing import List, Optional, Union, MutableMapping, Iterator, AsyncIterator
|
||||
from typing import (
|
||||
AsyncIterator,
|
||||
Final,
|
||||
Iterator,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
Callable,
|
||||
Pattern,
|
||||
)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import lavalink
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.i18n import Translator
|
||||
_RE_REMOVE_START: Final[Pattern] = re.compile(r"^(sc|list) ")
|
||||
_RE_YOUTUBE_TIMESTAMP: Final[Pattern] = re.compile(r"[&|?]t=(\d+)s?")
|
||||
_RE_YOUTUBE_INDEX: Final[Pattern] = re.compile(r"&index=(\d+)")
|
||||
_RE_SPOTIFY_URL: Final[Pattern] = re.compile(r"(http[s]?://)?(open.spotify.com)/")
|
||||
_RE_SPOTIFY_TIMESTAMP: Final[Pattern] = re.compile(r"#(\d+):(\d+)")
|
||||
_RE_SOUNDCLOUD_TIMESTAMP: Final[Pattern] = re.compile(r"#t=(\d+):(\d+)s?")
|
||||
_RE_TWITCH_TIMESTAMP: Final[Pattern] = re.compile(r"\?t=(\d+)h(\d+)m(\d+)s")
|
||||
_PATH_SEPS: Final[Tuple[str, str]] = (posixpath.sep, ntpath.sep)
|
||||
|
||||
_config: Optional[Config] = None
|
||||
_bot: Optional[Red] = None
|
||||
_localtrack_folder: Optional[str] = None
|
||||
_ = Translator("Audio", __file__)
|
||||
|
||||
_RE_REMOVE_START = re.compile(r"^(sc|list) ")
|
||||
_RE_YOUTUBE_TIMESTAMP = re.compile(r"&t=(\d+)s?")
|
||||
_RE_YOUTUBE_INDEX = re.compile(r"&index=(\d+)")
|
||||
_RE_SPOTIFY_URL = re.compile(r"(http[s]?://)?(open.spotify.com)/")
|
||||
_RE_SPOTIFY_TIMESTAMP = re.compile(r"#(\d+):(\d+)")
|
||||
_RE_SOUNDCLOUD_TIMESTAMP = re.compile(r"#t=(\d+):(\d+)s?")
|
||||
_RE_TWITCH_TIMESTAMP = re.compile(r"\?t=(\d+)h(\d+)m(\d+)s")
|
||||
_PATH_SEPS = [posixpath.sep, ntpath.sep]
|
||||
|
||||
_FULLY_SUPPORTED_MUSIC_EXT = (".mp3", ".flac", ".ogg")
|
||||
_PARTIALLY_SUPPORTED_MUSIC_EXT = (
|
||||
_FULLY_SUPPORTED_MUSIC_EXT: Final[Tuple[str, ...]] = (".mp3", ".flac", ".ogg")
|
||||
_PARTIALLY_SUPPORTED_MUSIC_EXT: Tuple[str, ...] = (
|
||||
".m3u",
|
||||
".m4a",
|
||||
".aac",
|
||||
@ -49,7 +51,7 @@ _PARTIALLY_SUPPORTED_MUSIC_EXT = (
|
||||
# ".voc",
|
||||
# ".dsf",
|
||||
)
|
||||
_PARTIALLY_SUPPORTED_VIDEO_EXT = (
|
||||
_PARTIALLY_SUPPORTED_VIDEO_EXT: Tuple[str, ...] = (
|
||||
".mp4",
|
||||
".mov",
|
||||
".flv",
|
||||
@ -72,25 +74,20 @@ _PARTIALLY_SUPPORTED_VIDEO_EXT = (
|
||||
_PARTIALLY_SUPPORTED_MUSIC_EXT += _PARTIALLY_SUPPORTED_VIDEO_EXT
|
||||
|
||||
|
||||
def _pass_config_to_dataclasses(config: Config, bot: Red, folder: str):
|
||||
global _config, _bot, _localtrack_folder
|
||||
if _config is None:
|
||||
_config = config
|
||||
if _bot is None:
|
||||
_bot = bot
|
||||
_localtrack_folder = folder
|
||||
log = logging.getLogger("red.cogs.Audio.audio_dataclasses")
|
||||
|
||||
|
||||
class LocalPath:
|
||||
"""Local tracks class.
|
||||
|
||||
Used to handle system dir trees in a cross system manner. The only use of this class is for
|
||||
`localtracks`.
|
||||
Used to handle system dir trees in a cross system manner.
|
||||
The only use of this class is for `localtracks`.
|
||||
"""
|
||||
|
||||
_all_music_ext = _FULLY_SUPPORTED_MUSIC_EXT + _PARTIALLY_SUPPORTED_MUSIC_EXT
|
||||
|
||||
def __init__(self, path, **kwargs):
|
||||
def __init__(self, path, localtrack_folder, **kwargs):
|
||||
self._localtrack_folder = localtrack_folder
|
||||
self._path = path
|
||||
if isinstance(path, (Path, WindowsPath, PosixPath, LocalPath)):
|
||||
path = str(path.absolute())
|
||||
@ -98,9 +95,8 @@ class LocalPath:
|
||||
path = str(path)
|
||||
|
||||
self.cwd = Path.cwd()
|
||||
_lt_folder = Path(_localtrack_folder) if _localtrack_folder else self.cwd
|
||||
_lt_folder = Path(self._localtrack_folder) if self._localtrack_folder else self.cwd
|
||||
_path = Path(path) if path else self.cwd
|
||||
|
||||
if _lt_folder.parts[-1].lower() == "localtracks" and not kwargs.get("forced"):
|
||||
self.localtrack_folder = _lt_folder
|
||||
elif kwargs.get("forced"):
|
||||
@ -165,46 +161,44 @@ class LocalPath:
|
||||
return self._path
|
||||
|
||||
@classmethod
|
||||
def joinpath(cls, *args):
|
||||
modified = cls(None)
|
||||
def joinpath(cls, localpath, *args):
|
||||
modified = cls(None, localpath)
|
||||
modified.path = modified.path.joinpath(*args)
|
||||
return modified
|
||||
|
||||
def rglob(self, pattern, folder=False) -> Iterator[str]:
|
||||
if folder:
|
||||
return glob.iglob(f"{self.path}{os.sep}**{os.sep}", recursive=True)
|
||||
return glob.iglob(f"{glob.escape(self.path)}{os.sep}**{os.sep}", recursive=True)
|
||||
else:
|
||||
return glob.iglob(f"{self.path}{os.sep}**{os.sep}{pattern}", recursive=True)
|
||||
return glob.iglob(
|
||||
f"{glob.escape(self.path)}{os.sep}**{os.sep}*{pattern}", recursive=True
|
||||
)
|
||||
|
||||
def glob(self, pattern, folder=False) -> Iterator[str]:
|
||||
if folder:
|
||||
return glob.iglob(f"{self.path}{os.sep}*{os.sep}", recursive=False)
|
||||
return glob.iglob(f"{glob.escape(self.path)}{os.sep}*{os.sep}", recursive=False)
|
||||
else:
|
||||
return glob.iglob(f"{self.path}{os.sep}*{pattern}", recursive=False)
|
||||
return glob.iglob(f"{glob.escape(self.path)}{os.sep}*{pattern}", recursive=False)
|
||||
|
||||
async def _multiglob(self, pattern: str, folder: bool, method: Callable):
|
||||
async for rp in AsyncIter(method(pattern)):
|
||||
rp_local = LocalPath(rp, self._localtrack_folder)
|
||||
if (
|
||||
(folder and rp_local.is_dir() and rp_local.exists())
|
||||
or (not folder and rp_local.suffix in self._all_music_ext and rp_local.is_file())
|
||||
and rp_local.exists()
|
||||
):
|
||||
yield rp_local
|
||||
|
||||
async def multiglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
|
||||
for p in patterns:
|
||||
for rp in self.glob(p):
|
||||
rp = LocalPath(rp)
|
||||
if folder and rp.is_dir() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
else:
|
||||
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
async for p in AsyncIter(patterns):
|
||||
async for path in self._multiglob(p, folder, self.glob):
|
||||
yield path
|
||||
|
||||
async def multirglob(self, *patterns, folder=False) -> AsyncIterator["LocalPath"]:
|
||||
for p in patterns:
|
||||
for rp in self.rglob(p):
|
||||
rp = LocalPath(rp)
|
||||
if folder and rp.is_dir() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
else:
|
||||
if rp.suffix in self._all_music_ext and rp.is_file() and rp.exists():
|
||||
yield rp
|
||||
await asyncio.sleep(0)
|
||||
async for p in AsyncIter(patterns):
|
||||
async for path in self._multiglob(p, folder, self.rglob):
|
||||
yield path
|
||||
|
||||
def __str__(self):
|
||||
return self.to_string()
|
||||
@ -238,7 +232,7 @@ class LocalPath:
|
||||
if track.path.parent != self.localtrack_folder and track.path.relative_to(
|
||||
self.path
|
||||
):
|
||||
tracks.append(Query.process_input(track))
|
||||
tracks.append(Query.process_input(track, self._localtrack_folder))
|
||||
return sorted(tracks, key=lambda x: x.to_string_user().lower())
|
||||
|
||||
async def subfolders_in_tree(self):
|
||||
@ -247,6 +241,7 @@ class LocalPath:
|
||||
with contextlib.suppress(ValueError):
|
||||
if (
|
||||
f not in return_folders
|
||||
and f.is_dir()
|
||||
and f.path != self.localtrack_folder
|
||||
and f.path.relative_to(self.path)
|
||||
):
|
||||
@ -260,7 +255,7 @@ class LocalPath:
|
||||
if track.path.parent != self.localtrack_folder and track.path.relative_to(
|
||||
self.path
|
||||
):
|
||||
tracks.append(Query.process_input(track))
|
||||
tracks.append(Query.process_input(track, self._localtrack_folder))
|
||||
return sorted(tracks, key=lambda x: x.to_string_user().lower())
|
||||
|
||||
async def subfolders(self):
|
||||
@ -321,18 +316,14 @@ class LocalPath:
|
||||
class Query:
|
||||
"""Query data class.
|
||||
|
||||
Use: Query.process_input(query) to generate the Query object.
|
||||
Use: Query.process_input(query, localtrack_folder) to generate the Query object.
|
||||
"""
|
||||
|
||||
def __init__(self, query: Union[LocalPath, str], **kwargs):
|
||||
def __init__(self, query: Union[LocalPath, str], local_folder_current_path: Path, **kwargs):
|
||||
query = kwargs.get("queryforced", query)
|
||||
self._raw: Union[LocalPath, str] = query
|
||||
|
||||
_localtrack: LocalPath = LocalPath(query)
|
||||
|
||||
self.track: Union[LocalPath, str] = _localtrack if (
|
||||
(_localtrack.is_file() or _localtrack.is_dir()) and _localtrack.exists()
|
||||
) else query
|
||||
self._local_folder_current_path = local_folder_current_path
|
||||
_localtrack: LocalPath = LocalPath(query, local_folder_current_path)
|
||||
|
||||
self.valid: bool = query != "InvalidQueryPlaceHolderName"
|
||||
self.is_local: bool = kwargs.get("local", False)
|
||||
@ -364,6 +355,15 @@ class Query:
|
||||
self.is_youtube = False
|
||||
self.is_soundcloud = True
|
||||
|
||||
if (_localtrack.is_file() or _localtrack.is_dir()) and _localtrack.exists():
|
||||
self.local_track_path: Optional[LocalPath] = _localtrack
|
||||
self.track: str = str(_localtrack.absolute())
|
||||
self.is_local: bool = True
|
||||
self.uri = self.track
|
||||
else:
|
||||
self.local_track_path: Optional[LocalPath] = None
|
||||
self.track: str = str(query)
|
||||
|
||||
self.lavalink_query: str = self._get_query()
|
||||
|
||||
if self.is_playlist or self.is_album:
|
||||
@ -397,14 +397,21 @@ class Query:
|
||||
return str(self.lavalink_query)
|
||||
|
||||
@classmethod
|
||||
def process_input(cls, query: Union[LocalPath, lavalink.Track, "Query", str], **kwargs):
|
||||
"""A replacement for :code:`lavalink.Player.load_tracks`. This will try to get a valid
|
||||
cached entry first if not found or if in valid it will then call the lavalink API.
|
||||
def process_input(
|
||||
cls,
|
||||
query: Union[LocalPath, lavalink.Track, "Query", str],
|
||||
_local_folder_current_path: Path,
|
||||
**kwargs,
|
||||
) -> "Query":
|
||||
"""
|
||||
Process the input query into its type
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query : Union[Query, LocalPath, lavalink.Track, str]
|
||||
The query string or LocalPath object.
|
||||
_local_folder_current_path: Path
|
||||
The Current Local Track folder
|
||||
Returns
|
||||
-------
|
||||
Query
|
||||
@ -430,12 +437,13 @@ class Query:
|
||||
query = query.uri
|
||||
|
||||
possible_values.update(dict(**kwargs))
|
||||
possible_values.update(cls._parse(query, **kwargs))
|
||||
return cls(query, **possible_values)
|
||||
possible_values.update(cls._parse(query, _local_folder_current_path, **kwargs))
|
||||
return cls(query, _local_folder_current_path, **possible_values)
|
||||
|
||||
@staticmethod
|
||||
def _parse(track, **kwargs) -> MutableMapping:
|
||||
returning = {}
|
||||
def _parse(track, _local_folder_current_path: Path, **kwargs) -> MutableMapping:
|
||||
"""Parse a track into all the relevant metadata"""
|
||||
returning: MutableMapping = {}
|
||||
if (
|
||||
type(track) == type(LocalPath)
|
||||
and (track.is_file() or track.is_dir())
|
||||
@ -475,7 +483,7 @@ class Query:
|
||||
track = _RE_REMOVE_START.sub("", track, 1)
|
||||
returning["queryforced"] = track
|
||||
|
||||
_localtrack = LocalPath(track)
|
||||
_localtrack = LocalPath(track, _local_folder_current_path)
|
||||
if _localtrack.exists():
|
||||
if _localtrack.is_file():
|
||||
returning["local"] = True
|
||||
@ -498,7 +506,7 @@ class Query:
|
||||
if url_domain in ["youtube.com", "youtu.be"]:
|
||||
returning["youtube"] = True
|
||||
_has_index = "&index=" in track
|
||||
if "&t=" in track:
|
||||
if "&t=" in track or "?t=" in track:
|
||||
match = re.search(_RE_YOUTUBE_TIMESTAMP, track)
|
||||
if match:
|
||||
returning["start_time"] = int(match.group(1))
|
||||
@ -599,7 +607,7 @@ class Query:
|
||||
|
||||
def _get_query(self):
|
||||
if self.is_local:
|
||||
return self.track.to_string()
|
||||
return self.local_track_path.to_string()
|
||||
elif self.is_spotify:
|
||||
return self.spotify_uri
|
||||
elif self.is_search and self.is_youtube:
|
||||
@ -610,13 +618,13 @@ class Query:
|
||||
|
||||
def to_string_user(self):
|
||||
if self.is_local:
|
||||
return str(self.track.to_string_user())
|
||||
return str(self.local_track_path.to_string_user())
|
||||
return str(self._raw)
|
||||
|
||||
@property
|
||||
def suffix(self):
|
||||
if self.is_local:
|
||||
return self.track.suffix
|
||||
return self.local_track_path.suffix
|
||||
return None
|
||||
|
||||
def __eq__(self, other):
|
||||
|
||||
17
redbot/cogs/audio/audio_logging.py
Normal file
17
redbot/cogs/audio/audio_logging.py
Normal file
@ -0,0 +1,17 @@
|
||||
import logging
|
||||
import sys
|
||||
from typing import Final
|
||||
|
||||
IS_DEBUG: Final[bool] = "--debug" in sys.argv
|
||||
|
||||
|
||||
def is_debug() -> bool:
|
||||
return IS_DEBUG
|
||||
|
||||
|
||||
def debug_exc_log(lg: logging.Logger, exc: Exception, msg: str = None) -> None:
|
||||
"""Logs an exception if logging is set to DEBUG level"""
|
||||
if lg.getEffectiveLevel() <= logging.DEBUG:
|
||||
if msg is None:
|
||||
msg = f"{exc}"
|
||||
lg.exception(msg, exc_info=exc)
|
||||
@ -1,31 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from redbot.core import Config, commands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
_config: Config
|
||||
else:
|
||||
_config = None
|
||||
|
||||
|
||||
def _pass_config_to_checks(config: Config):
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = config
|
||||
|
||||
|
||||
def roomlocked():
|
||||
"""Deny the command if the bot has been room locked."""
|
||||
|
||||
async def predicate(ctx: commands.Context):
|
||||
if ctx.guild is None:
|
||||
return False
|
||||
if await ctx.bot.is_mod(member=ctx.author):
|
||||
return True
|
||||
|
||||
room_id = await _config.guild(ctx.guild).room_lock()
|
||||
if room_id is None or ctx.channel.id == room_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
return commands.check(predicate)
|
||||
@ -1,18 +0,0 @@
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
|
||||
from .apis import _pass_config_to_apis
|
||||
from .audio_dataclasses import _pass_config_to_dataclasses
|
||||
from .converters import _pass_config_to_converters
|
||||
from .databases import _pass_config_to_databases
|
||||
from .playlists import _pass_config_to_playlist
|
||||
from .utils import _pass_config_to_utils
|
||||
|
||||
|
||||
def pass_config_to_dependencies(config: Config, bot: Red, localtracks_folder: str):
|
||||
_pass_config_to_databases(config, bot)
|
||||
_pass_config_to_utils(config, bot)
|
||||
_pass_config_to_dataclasses(config, bot, localtracks_folder)
|
||||
_pass_config_to_apis(config, bot)
|
||||
_pass_config_to_playlist(config, bot)
|
||||
_pass_config_to_converters(config, bot)
|
||||
@ -1,16 +1,18 @@
|
||||
import argparse
|
||||
import functools
|
||||
import re
|
||||
from typing import Optional, Tuple, Union, MutableMapping, TYPE_CHECKING
|
||||
from typing import Final, MutableMapping, Optional, Tuple, Union, Pattern
|
||||
|
||||
import discord
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import Config, commands
|
||||
from redbot.core import commands
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.i18n import Translator
|
||||
|
||||
from .apis.api_utils import standardize_scope
|
||||
from .apis.playlist_interface import get_all_playlist_converter
|
||||
from .errors import NoMatchesFound, TooManyMatches
|
||||
from .playlists import get_all_playlist_converter, standardize_scope
|
||||
from .utils import PlaylistScope
|
||||
|
||||
_ = Translator("Audio", __file__)
|
||||
@ -25,50 +27,47 @@ __all__ = [
|
||||
"get_playlist_converter",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
_bot: Red
|
||||
_config: Config
|
||||
else:
|
||||
_bot = None
|
||||
_config = None
|
||||
T_ = _
|
||||
_ = lambda s: s
|
||||
|
||||
_SCOPE_HELP = """
|
||||
_SCOPE_HELP: Final[str] = _(
|
||||
"""
|
||||
Scope must be a valid version of one of the following:
|
||||
Global
|
||||
Guild
|
||||
User
|
||||
"""
|
||||
_USER_HELP = """
|
||||
)
|
||||
_USER_HELP: Final[str] = _(
|
||||
"""
|
||||
Author must be a valid version of one of the following:
|
||||
User ID
|
||||
User Mention
|
||||
User Name#123
|
||||
"""
|
||||
_GUILD_HELP = """
|
||||
)
|
||||
_GUILD_HELP: Final[str] = _(
|
||||
"""
|
||||
Guild must be a valid version of one of the following:
|
||||
Guild ID
|
||||
Exact guild name
|
||||
"""
|
||||
)
|
||||
|
||||
MENTION_RE = re.compile(r"^<?(?:(?:@[!&]?)?|#)(\d{15,21})>?$")
|
||||
_ = T_
|
||||
|
||||
|
||||
def _pass_config_to_converters(config: Config, bot: Red):
|
||||
global _config, _bot
|
||||
if _config is None:
|
||||
_config = config
|
||||
if _bot is None:
|
||||
_bot = bot
|
||||
MENTION_RE: Final[Pattern] = re.compile(r"^<?(?:(?:@[!&]?)?|#)(\d{15,21})>?$")
|
||||
|
||||
|
||||
def _match_id(arg: str) -> Optional[int]:
|
||||
m = MENTION_RE.match(arg)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
async def global_unique_guild_finder(ctx: commands.Context, arg: str) -> discord.Guild:
|
||||
bot: commands.Bot = ctx.bot
|
||||
bot: Red = ctx.bot
|
||||
_id = _match_id(arg)
|
||||
|
||||
if _id is not None:
|
||||
@ -77,7 +76,7 @@ async def global_unique_guild_finder(ctx: commands.Context, arg: str) -> discord
|
||||
return guild
|
||||
|
||||
maybe_matches = []
|
||||
for obj in bot.guilds:
|
||||
async for obj in AsyncIter(bot.guilds):
|
||||
if obj.name == arg or str(obj) == arg:
|
||||
maybe_matches.append(obj)
|
||||
|
||||
@ -102,7 +101,7 @@ async def global_unique_guild_finder(ctx: commands.Context, arg: str) -> discord
|
||||
async def global_unique_user_finder(
|
||||
ctx: commands.Context, arg: str, guild: discord.guild = None
|
||||
) -> discord.abc.User:
|
||||
bot: commands.Bot = ctx.bot
|
||||
bot: Red = ctx.bot
|
||||
guild = guild or ctx.guild
|
||||
_id = _match_id(arg)
|
||||
|
||||
@ -111,17 +110,15 @@ async def global_unique_user_finder(
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
objects = bot.users
|
||||
|
||||
maybe_matches = []
|
||||
for obj in objects:
|
||||
if obj.name == arg or str(obj) == arg:
|
||||
maybe_matches.append(obj)
|
||||
async for user in AsyncIter(bot.users).filter(lambda u: u.name == arg or f"{u}" == arg):
|
||||
maybe_matches.append(user)
|
||||
|
||||
if guild is not None:
|
||||
for member in guild.members:
|
||||
if member.nick == arg and not any(obj.id == member.id for obj in maybe_matches):
|
||||
maybe_matches.append(member)
|
||||
async for member in AsyncIter(guild.members).filter(
|
||||
lambda m: m.nick == arg and not any(obj.id == m.id for obj in maybe_matches)
|
||||
):
|
||||
maybe_matches.append(member)
|
||||
|
||||
if not maybe_matches:
|
||||
raise NoMatchesFound(
|
||||
@ -143,15 +140,36 @@ async def global_unique_user_finder(
|
||||
|
||||
class PlaylistConverter(commands.Converter):
|
||||
async def convert(self, ctx: commands.Context, arg: str) -> MutableMapping:
|
||||
global_matches = await get_all_playlist_converter(
|
||||
PlaylistScope.GLOBAL.value, _bot, arg, guild=ctx.guild, author=ctx.author
|
||||
)
|
||||
guild_matches = await get_all_playlist_converter(
|
||||
PlaylistScope.GUILD.value, _bot, arg, guild=ctx.guild, author=ctx.author
|
||||
)
|
||||
user_matches = await get_all_playlist_converter(
|
||||
PlaylistScope.USER.value, _bot, arg, guild=ctx.guild, author=ctx.author
|
||||
)
|
||||
"""Get playlist for all scopes that match the argument user provided"""
|
||||
cog = ctx.cog
|
||||
user_matches = []
|
||||
guild_matches = []
|
||||
global_matches = []
|
||||
if cog:
|
||||
global_matches = await get_all_playlist_converter(
|
||||
PlaylistScope.GLOBAL.value,
|
||||
ctx.bot,
|
||||
cog.playlist_api,
|
||||
arg,
|
||||
guild=ctx.guild,
|
||||
author=ctx.author,
|
||||
)
|
||||
guild_matches = await get_all_playlist_converter(
|
||||
PlaylistScope.GUILD.value,
|
||||
ctx.bot,
|
||||
cog.playlist_api,
|
||||
arg,
|
||||
guild=ctx.guild,
|
||||
author=ctx.author,
|
||||
)
|
||||
user_matches = await get_all_playlist_converter(
|
||||
PlaylistScope.USER.value,
|
||||
ctx.bot,
|
||||
cog.playlist_api,
|
||||
arg,
|
||||
guild=ctx.guild,
|
||||
author=ctx.author,
|
||||
)
|
||||
if not user_matches and not guild_matches and not global_matches:
|
||||
raise commands.BadArgument(_("Could not match '{}' to a playlist.").format(arg))
|
||||
return {
|
||||
@ -184,7 +202,7 @@ class ScopeParser(commands.Converter):
|
||||
if arguments:
|
||||
argument = " -- ".join(arguments)
|
||||
else:
|
||||
command = None
|
||||
command = ""
|
||||
|
||||
parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False)
|
||||
parser.add_argument("--scope", nargs="*", dest="scope", default=[])
|
||||
@ -215,10 +233,10 @@ class ScopeParser(commands.Converter):
|
||||
"BOT",
|
||||
]
|
||||
if scope not in valid_scopes:
|
||||
raise commands.ArgParserFailure("--scope", scope_raw, custom_help=_SCOPE_HELP)
|
||||
raise commands.ArgParserFailure("--scope", scope_raw, custom_help=_(_SCOPE_HELP))
|
||||
target_scope = standardize_scope(scope)
|
||||
elif "--scope" in argument and not vals["scope"]:
|
||||
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_SCOPE_HELP)
|
||||
raise commands.ArgParserFailure("--scope", _("Nothing"), custom_help=_(_SCOPE_HELP))
|
||||
|
||||
is_owner = await ctx.bot.is_owner(ctx.author)
|
||||
guild = vals.get("guild", None) or vals.get("server", None)
|
||||
@ -234,13 +252,13 @@ class ScopeParser(commands.Converter):
|
||||
server_error = f"{err}\n"
|
||||
if target_guild is None:
|
||||
raise commands.ArgParserFailure(
|
||||
"--guild", guild_raw, custom_help=f"{server_error}{_GUILD_HELP}"
|
||||
"--guild", guild_raw, custom_help=f"{server_error}{_(_GUILD_HELP)}"
|
||||
)
|
||||
|
||||
elif not is_owner and (guild or any(x in argument for x in ["--guild", "--server"])):
|
||||
raise commands.BadArgument("You cannot use `--guild`")
|
||||
raise commands.BadArgument(_("You cannot use `--guild`"))
|
||||
elif any(x in argument for x in ["--guild", "--server"]):
|
||||
raise commands.ArgParserFailure("--guild", "Nothing", custom_help=_GUILD_HELP)
|
||||
raise commands.ArgParserFailure("--guild", _("Nothing"), custom_help=_(_GUILD_HELP))
|
||||
|
||||
author = vals.get("author", None) or vals.get("user", None) or vals.get("member", None)
|
||||
if author:
|
||||
@ -257,12 +275,12 @@ class ScopeParser(commands.Converter):
|
||||
|
||||
if target_user is None:
|
||||
raise commands.ArgParserFailure(
|
||||
"--author", user_raw, custom_help=f"{user_error}{_USER_HELP}"
|
||||
"--author", user_raw, custom_help=f"{user_error}{_(_USER_HELP)}"
|
||||
)
|
||||
elif any(x in argument for x in ["--author", "--user", "--member"]):
|
||||
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP)
|
||||
raise commands.ArgParserFailure("--scope", _("Nothing"), custom_help=_(_USER_HELP))
|
||||
|
||||
target_scope: str = target_scope or None
|
||||
target_scope: Optional[str] = target_scope or None
|
||||
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
|
||||
target_guild: discord.Guild = target_guild or ctx.guild
|
||||
|
||||
@ -299,7 +317,7 @@ class ComplexScopeParser(commands.Converter):
|
||||
if arguments:
|
||||
argument = " -- ".join(arguments)
|
||||
else:
|
||||
command = None
|
||||
command = ""
|
||||
|
||||
parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False)
|
||||
|
||||
@ -345,7 +363,7 @@ class ComplexScopeParser(commands.Converter):
|
||||
)
|
||||
target_scope = standardize_scope(to_scope)
|
||||
elif "--to-scope" in argument and not vals["to_scope"]:
|
||||
raise commands.ArgParserFailure("--to-scope", "Nothing", custom_help=_SCOPE_HELP)
|
||||
raise commands.ArgParserFailure("--to-scope", _("Nothing"), custom_help=_(_SCOPE_HELP))
|
||||
|
||||
if vals["from_scope"]:
|
||||
from_scope_raw = " ".join(vals["from_scope"]).strip()
|
||||
@ -357,7 +375,7 @@ class ComplexScopeParser(commands.Converter):
|
||||
)
|
||||
source_scope = standardize_scope(from_scope)
|
||||
elif "--from-scope" in argument and not vals["to_scope"]:
|
||||
raise commands.ArgParserFailure("--to-scope", "Nothing", custom_help=_SCOPE_HELP)
|
||||
raise commands.ArgParserFailure("--to-scope", _("Nothing"), custom_help=_(_SCOPE_HELP))
|
||||
|
||||
to_guild = vals.get("to_guild", None) or vals.get("to_server", None)
|
||||
if is_owner and to_guild:
|
||||
@ -372,20 +390,24 @@ class ComplexScopeParser(commands.Converter):
|
||||
target_server_error = f"{err}\n"
|
||||
if target_guild is None:
|
||||
raise commands.ArgParserFailure(
|
||||
"--to-guild", to_guild_raw, custom_help=f"{target_server_error}{_GUILD_HELP}"
|
||||
"--to-guild",
|
||||
to_guild_raw,
|
||||
custom_help=f"{target_server_error}{_(_GUILD_HELP)}",
|
||||
)
|
||||
elif not is_owner and (
|
||||
to_guild or any(x in argument for x in ["--to-guild", "--to-server"])
|
||||
):
|
||||
raise commands.BadArgument("You cannot use `--to-server`")
|
||||
raise commands.BadArgument(_("You cannot use `--to-server`"))
|
||||
elif any(x in argument for x in ["--to-guild", "--to-server"]):
|
||||
raise commands.ArgParserFailure("--to-server", "Nothing", custom_help=_GUILD_HELP)
|
||||
raise commands.ArgParserFailure(
|
||||
"--to-server", _("Nothing"), custom_help=_(_GUILD_HELP)
|
||||
)
|
||||
|
||||
from_guild = vals.get("from_guild", None) or vals.get("from_server", None)
|
||||
if is_owner and from_guild:
|
||||
source_server_error = ""
|
||||
source_guild = None
|
||||
from_guild_raw = " ".join(to_guild).strip()
|
||||
from_guild_raw = " ".join(from_guild).strip()
|
||||
try:
|
||||
source_guild = await global_unique_guild_finder(ctx, from_guild_raw)
|
||||
except TooManyMatches as err:
|
||||
@ -396,14 +418,16 @@ class ComplexScopeParser(commands.Converter):
|
||||
raise commands.ArgParserFailure(
|
||||
"--from-guild",
|
||||
from_guild_raw,
|
||||
custom_help=f"{source_server_error}{_GUILD_HELP}",
|
||||
custom_help=f"{source_server_error}{_(_GUILD_HELP)}",
|
||||
)
|
||||
elif not is_owner and (
|
||||
from_guild or any(x in argument for x in ["--from-guild", "--from-server"])
|
||||
):
|
||||
raise commands.BadArgument("You cannot use `--from-server`")
|
||||
raise commands.BadArgument(_("You cannot use `--from-server`"))
|
||||
elif any(x in argument for x in ["--from-guild", "--from-server"]):
|
||||
raise commands.ArgParserFailure("--from-server", "Nothing", custom_help=_GUILD_HELP)
|
||||
raise commands.ArgParserFailure(
|
||||
"--from-server", _("Nothing"), custom_help=_(_GUILD_HELP)
|
||||
)
|
||||
|
||||
to_author = (
|
||||
vals.get("to_author", None) or vals.get("to_user", None) or vals.get("to_member", None)
|
||||
@ -421,10 +445,10 @@ class ComplexScopeParser(commands.Converter):
|
||||
target_user_error = f"{err}\n"
|
||||
if target_user is None:
|
||||
raise commands.ArgParserFailure(
|
||||
"--to-author", to_user_raw, custom_help=f"{target_user_error}{_USER_HELP}"
|
||||
"--to-author", to_user_raw, custom_help=f"{target_user_error}{_(_USER_HELP)}"
|
||||
)
|
||||
elif any(x in argument for x in ["--to-author", "--to-user", "--to-member"]):
|
||||
raise commands.ArgParserFailure("--to-user", "Nothing", custom_help=_USER_HELP)
|
||||
raise commands.ArgParserFailure("--to-user", _("Nothing"), custom_help=_(_USER_HELP))
|
||||
|
||||
from_author = (
|
||||
vals.get("from_author", None)
|
||||
@ -434,7 +458,7 @@ class ComplexScopeParser(commands.Converter):
|
||||
if from_author:
|
||||
source_user_error = ""
|
||||
source_user = None
|
||||
from_user_raw = " ".join(to_author).strip()
|
||||
from_user_raw = " ".join(from_author).strip()
|
||||
try:
|
||||
source_user = await global_unique_user_finder(
|
||||
ctx, from_user_raw, guild=target_guild
|
||||
@ -446,18 +470,20 @@ class ComplexScopeParser(commands.Converter):
|
||||
source_user_error = f"{err}\n"
|
||||
if source_user is None:
|
||||
raise commands.ArgParserFailure(
|
||||
"--from-author", from_user_raw, custom_help=f"{source_user_error}{_USER_HELP}"
|
||||
"--from-author",
|
||||
from_user_raw,
|
||||
custom_help=f"{source_user_error}{_(_USER_HELP)}",
|
||||
)
|
||||
elif any(x in argument for x in ["--from-author", "--from-user", "--from-member"]):
|
||||
raise commands.ArgParserFailure("--from-user", "Nothing", custom_help=_USER_HELP)
|
||||
raise commands.ArgParserFailure("--from-user", _("Nothing"), custom_help=_(_USER_HELP))
|
||||
|
||||
target_scope: str = target_scope or PlaylistScope.GUILD.value
|
||||
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
|
||||
target_guild: discord.Guild = target_guild or ctx.guild
|
||||
target_scope = target_scope or PlaylistScope.GUILD.value
|
||||
target_user = target_user or ctx.author
|
||||
target_guild = target_guild or ctx.guild
|
||||
|
||||
source_scope: str = source_scope or PlaylistScope.GUILD.value
|
||||
source_user: Union[discord.Member, discord.User] = source_user or ctx.author
|
||||
source_guild: discord.Guild = source_guild or ctx.guild
|
||||
source_scope = source_scope or PlaylistScope.GUILD.value
|
||||
source_user = source_user or ctx.author
|
||||
source_guild = source_guild or ctx.guild
|
||||
|
||||
return (
|
||||
source_scope,
|
||||
|
||||
121
redbot/cogs/audio/core/__init__.py
Normal file
121
redbot/cogs/audio/core/__init__.py
Normal file
@ -0,0 +1,121 @@
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from typing import Mapping
|
||||
|
||||
import aiohttp
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands import Cog
|
||||
from redbot.core.data_manager import cog_data_path
|
||||
from redbot.core.i18n import cog_i18n
|
||||
|
||||
from ..utils import PlaylistScope
|
||||
from . import abc, cog_utils, commands, events, tasks, utilities
|
||||
from .cog_utils import CompositeMetaClass, _
|
||||
|
||||
|
||||
@cog_i18n(_)
|
||||
class Audio(
|
||||
commands.Commands,
|
||||
events.Events,
|
||||
tasks.Tasks,
|
||||
utilities.Utilities,
|
||||
Cog,
|
||||
metaclass=CompositeMetaClass,
|
||||
):
|
||||
"""Play audio through voice channels."""
|
||||
|
||||
_default_lavalink_settings = {
|
||||
"host": "localhost",
|
||||
"rest_port": 2333,
|
||||
"ws_port": 2333,
|
||||
"password": "youshallnotpass",
|
||||
}
|
||||
|
||||
def __init__(self, bot: Red):
|
||||
super().__init__()
|
||||
self.bot = bot
|
||||
self.config = Config.get_conf(self, 2711759130, force_registration=True)
|
||||
|
||||
self.api_interface = None
|
||||
self.player_manager = None
|
||||
self.playlist_api = None
|
||||
self.local_folder_current_path = None
|
||||
self.db_conn = None
|
||||
|
||||
self._error_counter = Counter()
|
||||
self._error_timer = {}
|
||||
self._disconnected_players = {}
|
||||
self._daily_playlist_cache = {}
|
||||
self._daily_global_playlist_cache = {}
|
||||
self._dj_status_cache = {}
|
||||
self._dj_role_cache = {}
|
||||
self.skip_votes = {}
|
||||
self.play_lock = {}
|
||||
|
||||
self.lavalink_connect_task = None
|
||||
self.player_automated_timer_task = None
|
||||
self.cog_cleaned_up = False
|
||||
self.lavalink_connection_aborted = False
|
||||
|
||||
self.session = aiohttp.ClientSession()
|
||||
self.cog_ready_event = asyncio.Event()
|
||||
self.cog_init_task = None
|
||||
|
||||
default_global = dict(
|
||||
schema_version=1,
|
||||
cache_level=0,
|
||||
cache_age=365,
|
||||
daily_playlists=False,
|
||||
global_db_enabled=False,
|
||||
global_db_get_timeout=5, # Here as a placeholder in case we want to enable the command
|
||||
status=False,
|
||||
use_external_lavalink=False,
|
||||
restrict=True,
|
||||
localpath=str(cog_data_path(raw_name="Audio")),
|
||||
url_keyword_blacklist=[],
|
||||
url_keyword_whitelist=[],
|
||||
**self._default_lavalink_settings,
|
||||
)
|
||||
|
||||
default_guild = dict(
|
||||
auto_play=False,
|
||||
autoplaylist={"enabled": False, "id": None, "name": None, "scope": None},
|
||||
disconnect=False,
|
||||
dj_enabled=False,
|
||||
dj_role=None,
|
||||
daily_playlists=False,
|
||||
emptydc_enabled=False,
|
||||
emptydc_timer=0,
|
||||
emptypause_enabled=False,
|
||||
emptypause_timer=0,
|
||||
jukebox=False,
|
||||
jukebox_price=0,
|
||||
maxlength=0,
|
||||
notify=False,
|
||||
prefer_lyrics=False,
|
||||
repeat=False,
|
||||
shuffle=False,
|
||||
shuffle_bumped=True,
|
||||
thumbnail=False,
|
||||
volume=100,
|
||||
vote_enabled=False,
|
||||
vote_percent=0,
|
||||
room_lock=None,
|
||||
url_keyword_blacklist=[],
|
||||
url_keyword_whitelist=[],
|
||||
country_code="US",
|
||||
)
|
||||
_playlist: Mapping = dict(id=None, author=None, name=None, playlist_url=None, tracks=[])
|
||||
|
||||
self.config.init_custom("EQUALIZER", 1)
|
||||
self.config.register_custom("EQUALIZER", eq_bands=[], eq_presets={})
|
||||
self.config.init_custom(PlaylistScope.GLOBAL.value, 1)
|
||||
self.config.register_custom(PlaylistScope.GLOBAL.value, **_playlist)
|
||||
self.config.init_custom(PlaylistScope.GUILD.value, 2)
|
||||
self.config.register_custom(PlaylistScope.GUILD.value, **_playlist)
|
||||
self.config.init_custom(PlaylistScope.USER.value, 2)
|
||||
self.config.register_custom(PlaylistScope.USER.value, **_playlist)
|
||||
self.config.register_guild(**default_guild)
|
||||
self.config.register_global(**default_global)
|
||||
504
redbot/cogs/audio/core/abc.py
Normal file
504
redbot/cogs/audio/core/abc.py
Normal file
@ -0,0 +1,504 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Mapping, MutableMapping, Optional, Tuple, Union, TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
import lavalink
|
||||
|
||||
from redbot.core import Config, commands
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands import Context
|
||||
from redbot.core.utils.dbtools import APSWConnectionWrapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..apis.interface import AudioAPIInterface
|
||||
from ..apis.playlist_interface import Playlist
|
||||
from ..apis.playlist_wrapper import PlaylistWrapper
|
||||
from ..audio_dataclasses import LocalPath, Query
|
||||
from ..equalizer import Equalizer
|
||||
from ..manager import ServerManager
|
||||
|
||||
|
||||
class MixinMeta(ABC):
|
||||
"""
|
||||
Base class for well behaved type hint detection with composite class.
|
||||
|
||||
Basically, to keep developers sane when not all attributes are defined in each mixin.
|
||||
"""
|
||||
|
||||
bot: Red
|
||||
config: Config
|
||||
api_interface: Optional["AudioAPIInterface"]
|
||||
player_manager: Optional["ServerManager"]
|
||||
playlist_api: Optional["PlaylistWrapper"]
|
||||
local_folder_current_path: Optional[Path]
|
||||
db_conn: Optional[APSWConnectionWrapper]
|
||||
session: aiohttp.ClientSession
|
||||
|
||||
skip_votes: MutableMapping[discord.Guild, List[discord.Member]]
|
||||
play_lock: MutableMapping[int, bool]
|
||||
_daily_playlist_cache: MutableMapping[int, bool]
|
||||
_daily_global_playlist_cache: MutableMapping[int, bool]
|
||||
_dj_status_cache: MutableMapping[int, Optional[bool]]
|
||||
_dj_role_cache: MutableMapping[int, Optional[int]]
|
||||
_error_timer: MutableMapping[int, float]
|
||||
_disconnected_players: MutableMapping[int, bool]
|
||||
|
||||
cog_cleaned_up: bool
|
||||
lavalink_connection_aborted: bool
|
||||
|
||||
_error_counter: Counter
|
||||
|
||||
lavalink_connect_task: Optional[asyncio.Task]
|
||||
player_automated_timer_task: Optional[asyncio.Task]
|
||||
cog_init_task: Optional[asyncio.Task]
|
||||
cog_ready_event: asyncio.Event
|
||||
|
||||
_default_lavalink_settings: Mapping
|
||||
|
||||
@abstractmethod
|
||||
async def command_llsetup(self, ctx: commands.Context):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def maybe_reset_error_counter(self, player: lavalink.Player) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def update_bot_presence(self, track: lavalink.Track, playing_servers: int) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_active_player_count(self) -> Tuple[str, int]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def increase_error_counter(self, player: lavalink.Player) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _close_database(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def maybe_run_pending_db_tasks(self, ctx: commands.Context) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def update_player_lock(self, ctx: commands.Context, true_or_false: bool) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def data_schema_migration(self, from_version: int, to_version: int) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def lavalink_restart_connect(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def lavalink_attempt_connect(self, timeout: int = 50) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def player_automated_timer(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def lavalink_event_handler(
|
||||
self, player: lavalink.Player, event_type: lavalink.LavalinkEvents, extra
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _clear_react(
|
||||
self, message: discord.Message, emoji: MutableMapping = None
|
||||
) -> asyncio.Task:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def remove_react(
|
||||
self,
|
||||
message: discord.Message,
|
||||
react_emoji: Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str],
|
||||
react_user: discord.abc.User,
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_equalizer(self, ctx: commands.Context):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _eq_msg_clear(self, eq_message: discord.Message) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def _player_check(self, ctx: commands.Context) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def maybe_charge_requester(self, ctx: commands.Context, jukebox_price: int) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _can_instaskip(self, ctx: commands.Context, member: discord.Member) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_search(self, ctx: commands.Context, *, query: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def is_query_allowed(
|
||||
self, config: Config, guild: discord.Guild, query: str, query_obj: "Query" = None
|
||||
) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def is_track_too_long(self, track: Union[lavalink.Track, int], maxlength: int) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_track_description(
|
||||
self,
|
||||
track: Union[lavalink.rest_api.Track, "Query"],
|
||||
local_folder_current_path: Path,
|
||||
shorten: bool = False,
|
||||
) -> Optional[str]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_track_description_unformatted(
|
||||
self, track: Union[lavalink.rest_api.Track, "Query"], local_folder_current_path: Path
|
||||
) -> Optional[str]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def humanize_scope(
|
||||
self, scope: str, ctx: Union[discord.Guild, discord.abc.User, str] = None, the: bool = None
|
||||
) -> Optional[str]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def draw_time(self, ctx) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def rsetattr(self, obj, attr, val) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def rgetattr(self, obj, attr, *args) -> Any:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _check_api_tokens(self) -> MutableMapping:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def send_embed_msg(
|
||||
self, ctx: commands.Context, author: Mapping[str, str] = None, **kwargs
|
||||
) -> discord.Message:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def update_external_status(self) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_track_json(
|
||||
self,
|
||||
player: lavalink.Player,
|
||||
position: Union[int, str] = None,
|
||||
other_track: lavalink.Track = None,
|
||||
) -> MutableMapping:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def track_to_json(self, track: lavalink.Track) -> MutableMapping:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def time_convert(self, length: Union[int, str]) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def queue_duration(self, ctx: commands.Context) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def track_remaining_duration(self, ctx: commands.Context) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def get_time_string(self, seconds: int) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def set_player_settings(self, ctx: commands.Context) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
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]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def is_requester_alone(self, ctx: commands.Context) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def is_requester(self, ctx: commands.Context, member: discord.Member) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _skip_action(self, ctx: commands.Context, skip_to_track: int = None) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def is_vc_full(self, channel: discord.VoiceChannel) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _has_dj_role(self, ctx: commands.Context, member: discord.Member) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def match_url(self, url: str) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _playlist_check(self, ctx: commands.Context) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def can_manage_playlist(
|
||||
self, scope: str, playlist: "Playlist", ctx: commands.Context, user, guild
|
||||
) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _maybe_update_playlist(
|
||||
self, ctx: commands.Context, player: lavalink.player_manager.Player, playlist: "Playlist"
|
||||
) -> Tuple[List[lavalink.Track], List[lavalink.Track], "Playlist"]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def is_url_allowed(self, url: str) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _eq_check(self, ctx: commands.Context, player: lavalink.Player) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _enqueue_tracks(
|
||||
self, ctx: commands.Context, query: Union["Query", list], enqueue: bool = True
|
||||
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _eq_interact(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
player: lavalink.Player,
|
||||
eq: "Equalizer",
|
||||
message: discord.Message,
|
||||
selected: int,
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _apply_gains(self, guild_id: int, gains: List[float]) -> None:
|
||||
NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _apply_gain(self, guild_id: int, band: int, gain: float) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _get_spotify_tracks(
|
||||
self, ctx: commands.Context, query: "Query", forced: bool = False
|
||||
) -> Union[discord.Message, List[lavalink.Track], lavalink.Track]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _genre_search_button_action(
|
||||
self, ctx: commands.Context, options: List, emoji: str, page: int, playlist: bool = False
|
||||
) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_genre_search_page(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
tracks: List,
|
||||
page_num: int,
|
||||
title: str,
|
||||
playlist: bool = False,
|
||||
) -> discord.Embed:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_audioset_autoplay_toggle(self, ctx: commands.Context):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _search_button_action(
|
||||
self, ctx: commands.Context, tracks: List, emoji: str, page: int
|
||||
):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def get_localtrack_folder_tracks(
|
||||
self, ctx, player: lavalink.player_manager.Player, query: "Query"
|
||||
) -> List[lavalink.rest_api.Track]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def get_localtrack_folder_list(
|
||||
self, ctx: commands.Context, query: "Query"
|
||||
) -> List["Query"]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _local_play_all(
|
||||
self, ctx: commands.Context, query: "Query", from_search: bool = False
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_search_page(
|
||||
self, ctx: commands.Context, tracks: List, page_num: int
|
||||
) -> discord.Embed:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_play(self, ctx: commands.Context, *, query: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def localtracks_folder_exists(self, ctx: commands.Context) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def get_localtracks_folders(
|
||||
self, ctx: commands.Context, search_subfolders: bool = False
|
||||
) -> List[Union[Path, "LocalPath"]]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_local_search_list(
|
||||
self, to_search: List["Query"], search_words: str
|
||||
) -> List[str]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_stop(self, ctx: commands.Context):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_queue_page(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
queue: list,
|
||||
player: lavalink.player_manager.Player,
|
||||
page_num: int,
|
||||
) -> discord.Embed:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_pause(self, ctx: commands.Context):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_queue_search_list(
|
||||
self, queue_list: List[lavalink.Track], search_words: str
|
||||
) -> List[Tuple[int, str]]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_queue_search_page(
|
||||
self, ctx: commands.Context, page_num: int, search_list: List[Tuple[int, str]]
|
||||
) -> discord.Embed:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
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]]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def _build_playlist_list_page(
|
||||
self, ctx: commands.Context, page_num: int, abc_names: List, scope: Optional[str]
|
||||
) -> discord.Embed:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def match_yt_playlist(self, url: str) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
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:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
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],
|
||||
):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def format_time(self, time: int) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def get_lyrics_status(self, ctx: Context) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_skip(self, ctx: commands.Context, skip_to_track: int = None):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def command_prev(self, ctx: commands.Context):
|
||||
raise NotImplementedError()
|
||||
28
redbot/cogs/audio/core/cog_utils.py
Normal file
28
redbot/cogs/audio/core/cog_utils.py
Normal file
@ -0,0 +1,28 @@
|
||||
from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from redbot import VersionInfo
|
||||
from redbot.core import commands
|
||||
from redbot.core.i18n import Translator
|
||||
|
||||
from ..converters import get_lazy_converter, get_playlist_converter
|
||||
|
||||
__version__ = VersionInfo.from_json({"major": 2, "minor": 0, "micro": 0, "releaselevel": "final"})
|
||||
|
||||
__author__ = ["aikaterna", "Draper"]
|
||||
|
||||
_ = Translator("Audio", Path(__file__).parent)
|
||||
_SCHEMA_VERSION: Final[int] = 3
|
||||
|
||||
LazyGreedyConverter = get_lazy_converter("--")
|
||||
PlaylistConverter = get_playlist_converter()
|
||||
|
||||
|
||||
class CompositeMetaClass(type(commands.Cog), type(ABC)):
|
||||
"""
|
||||
This allows the metaclass used for proper type detection to
|
||||
coexist with discord.py's metaclass
|
||||
"""
|
||||
|
||||
pass
|
||||
25
redbot/cogs/audio/core/commands/__init__.py
Normal file
25
redbot/cogs/audio/core/commands/__init__.py
Normal file
@ -0,0 +1,25 @@
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
from .audioset import AudioSetCommands
|
||||
from .controller import PlayerControllerCommands
|
||||
from .equalizer import EqualizerCommands
|
||||
from .llset import LavalinkSetupCommands
|
||||
from .localtracks import LocalTrackCommands
|
||||
from .miscellaneous import MiscellaneousCommands
|
||||
from .player import PlayerCommands
|
||||
from .playlists import PlaylistCommands
|
||||
from .queue import QueueCommands
|
||||
|
||||
|
||||
class Commands(
|
||||
AudioSetCommands,
|
||||
PlayerControllerCommands,
|
||||
EqualizerCommands,
|
||||
LavalinkSetupCommands,
|
||||
LocalTrackCommands,
|
||||
MiscellaneousCommands,
|
||||
PlayerCommands,
|
||||
PlaylistCommands,
|
||||
QueueCommands,
|
||||
metaclass=CompositeMetaClass,
|
||||
):
|
||||
"""Class joining all command subclasses"""
|
||||
1307
redbot/cogs/audio/core/commands/audioset.py
Normal file
1307
redbot/cogs/audio/core/commands/audioset.py
Normal file
File diff suppressed because it is too large
Load Diff
841
redbot/cogs/audio/core/commands/controller.py
Normal file
841
redbot/cogs/audio/core/commands/controller.py
Normal file
@ -0,0 +1,841 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import humanize_number
|
||||
from redbot.core.utils.menus import start_adding_reactions
|
||||
from redbot.core.utils.predicates import ReactionPredicate
|
||||
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.player_controller")
|
||||
|
||||
|
||||
class PlayerControllerCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.command(name="disconnect")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_disconnect(self, ctx: commands.Context):
|
||||
"""Disconnect from the voice channel."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
else:
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if (
|
||||
(vote_enabled or (vote_enabled and dj_enabled))
|
||||
and not can_skip
|
||||
and not await self.is_requester_alone(ctx)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Disconnect"),
|
||||
description=_("There are other people listening - vote to skip instead."),
|
||||
)
|
||||
if dj_enabled and not vote_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Disconnect"),
|
||||
description=_("You need the DJ role to disconnect."),
|
||||
)
|
||||
if dj_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable to Disconnect"),
|
||||
description=_("You need the DJ role to disconnect."),
|
||||
)
|
||||
|
||||
await self.send_embed_msg(ctx, title=_("Disconnecting..."))
|
||||
self.bot.dispatch("red_audio_audio_disconnect", ctx.guild)
|
||||
self.update_player_lock(ctx, False)
|
||||
eq = player.fetch("eq")
|
||||
player.queue = []
|
||||
player.store("playing_song", None)
|
||||
if eq:
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
|
||||
await player.stop()
|
||||
await player.disconnect()
|
||||
|
||||
@commands.command(name="now")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
|
||||
async def command_now(self, ctx: commands.Context):
|
||||
"""Now playing."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
expected: Union[Tuple[str, ...]] = ("⏮", "⏹", "⏯", "⏭", "\N{CROSS MARK}")
|
||||
emoji = {"prev": "⏮", "stop": "⏹", "pause": "⏯", "next": "⏭", "close": "\N{CROSS MARK}"}
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if player.current:
|
||||
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)
|
||||
song = self.get_track_description(player.current, self.local_folder_current_path) or ""
|
||||
song += _("\n Requested by: **{track.requester}**")
|
||||
song += "\n\n{arrow}`{pos}`/`{dur}`"
|
||||
song = song.format(track=player.current, arrow=arrow, pos=pos, dur=dur)
|
||||
else:
|
||||
song = _("Nothing.")
|
||||
|
||||
if player.fetch("np_message") is not None:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await player.fetch("np_message").delete()
|
||||
embed = discord.Embed(title=_("Now Playing"), description=song)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
|
||||
if guild_data["thumbnail"] and player.current and player.current.thumbnail:
|
||||
embed.set_thumbnail(url=player.current.thumbnail)
|
||||
shuffle = guild_data["shuffle"]
|
||||
repeat = guild_data["repeat"]
|
||||
autoplay = guild_data["auto_play"]
|
||||
text = ""
|
||||
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}")
|
||||
)
|
||||
|
||||
message = await self.send_embed_msg(ctx, embed=embed, footer=text)
|
||||
|
||||
player.store("np_message", message)
|
||||
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
if (
|
||||
(dj_enabled or vote_enabled)
|
||||
and not await self._can_instaskip(ctx, ctx.author)
|
||||
and not await self.is_requester_alone(ctx)
|
||||
):
|
||||
return
|
||||
|
||||
if not player.queue and not autoplay:
|
||||
expected = ("⏹", "⏯", "\N{CROSS MARK}")
|
||||
task: Optional[asyncio.Task]
|
||||
if player.current:
|
||||
task = start_adding_reactions(message, expected[:5])
|
||||
else:
|
||||
task = None
|
||||
|
||||
try:
|
||||
(r, u) = await self.bot.wait_for(
|
||||
"reaction_add",
|
||||
check=ReactionPredicate.with_emojis(expected, message, ctx.author),
|
||||
timeout=30.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return await self._clear_react(message, emoji)
|
||||
else:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
reacts = {v: k for k, v in emoji.items()}
|
||||
react = reacts[r.emoji]
|
||||
if react == "prev":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_prev)
|
||||
elif react == "stop":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_stop)
|
||||
elif react == "pause":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_pause)
|
||||
elif react == "next":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_skip)
|
||||
elif react == "close":
|
||||
await message.delete()
|
||||
|
||||
@commands.command(name="pause")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_pause(self, ctx: commands.Context):
|
||||
"""Pause or resume a playing track."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Manage Tracks"),
|
||||
description=_("You must be in the voice channel to pause or resume."),
|
||||
)
|
||||
if dj_enabled and not can_skip and not await self.is_requester_alone(ctx):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Manage Tracks"),
|
||||
description=_("You need the DJ role to pause or resume tracks."),
|
||||
)
|
||||
|
||||
if not player.current:
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
description = self.get_track_description(player.current, self.local_folder_current_path)
|
||||
|
||||
if player.current and not player.paused:
|
||||
await player.pause()
|
||||
return await self.send_embed_msg(ctx, title=_("Track Paused"), description=description)
|
||||
if player.current and player.paused:
|
||||
await player.pause(False)
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Track Resumed"), description=description
|
||||
)
|
||||
|
||||
await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
|
||||
@commands.command(name="prev")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_prev(self, ctx: commands.Context):
|
||||
"""Skip to the start of the previously played track."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
is_alone = await self.is_requester_alone(ctx)
|
||||
is_requester = await self.is_requester(ctx, ctx.author)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_("You must be in the voice channel to skip the track."),
|
||||
)
|
||||
if (vote_enabled or (vote_enabled and dj_enabled)) and not can_skip and not is_alone:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_("There are other people listening - vote to skip instead."),
|
||||
)
|
||||
if dj_enabled and not vote_enabled and not (can_skip or is_requester) and not is_alone:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_(
|
||||
"You need the DJ role or be the track requester "
|
||||
"to enqueue the previous song tracks."
|
||||
),
|
||||
)
|
||||
|
||||
if player.fetch("prev_song") is None:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Play Tracks"), description=_("No previous track.")
|
||||
)
|
||||
else:
|
||||
track = player.fetch("prev_song")
|
||||
player.add(player.fetch("prev_requester"), track)
|
||||
self.bot.dispatch("red_audio_track_enqueue", player.channel.guild, track, ctx.author)
|
||||
queue_len = len(player.queue)
|
||||
bump_song = player.queue[-1]
|
||||
player.queue.insert(0, bump_song)
|
||||
player.queue.pop(queue_len)
|
||||
await player.skip()
|
||||
description = self.get_track_description(
|
||||
player.current, self.local_folder_current_path
|
||||
)
|
||||
embed = discord.Embed(title=_("Replaying Track"), description=description)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
|
||||
@commands.command(name="seek")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_seek(self, ctx: commands.Context, seconds: Union[int, str]):
|
||||
"""Seek ahead or behind on a track by seconds or a to a specific time.
|
||||
|
||||
Accepts seconds or a value formatted like 00:00:00 (`hh:mm:ss`) or 00:00 (`mm:ss`).
|
||||
"""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
is_alone = await self.is_requester_alone(ctx)
|
||||
is_requester = await self.is_requester(ctx, ctx.author)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Seek Tracks"),
|
||||
description=_("You must be in the voice channel to use seek."),
|
||||
)
|
||||
|
||||
if vote_enabled and not can_skip and not is_alone:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Seek Tracks"),
|
||||
description=_("There are other people listening - vote to skip instead."),
|
||||
)
|
||||
|
||||
if dj_enabled and not (can_skip or is_requester) and not is_alone:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Seek Tracks"),
|
||||
description=_("You need the DJ role or be the track requester to use seek."),
|
||||
)
|
||||
|
||||
if player.current:
|
||||
if player.current.is_stream:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Seek Tracks"), description=_("Can't seek on a stream.")
|
||||
)
|
||||
else:
|
||||
try:
|
||||
int(seconds)
|
||||
abs_position = False
|
||||
except ValueError:
|
||||
abs_position = True
|
||||
seconds = self.time_convert(seconds)
|
||||
if seconds == 0:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Seek Tracks"),
|
||||
description=_("Invalid input for the time to seek."),
|
||||
)
|
||||
if not abs_position:
|
||||
time_sec = int(seconds) * 1000
|
||||
seek = player.position + time_sec
|
||||
if seek <= 0:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Moved {num_seconds}s to 00:00:00").format(
|
||||
num_seconds=seconds
|
||||
),
|
||||
)
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Moved {num_seconds}s to {time}").format(
|
||||
num_seconds=seconds, time=self.format_time(seek)
|
||||
),
|
||||
)
|
||||
await player.seek(seek)
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Moved to {time}").format(time=self.format_time(seconds * 1000)),
|
||||
)
|
||||
await player.seek(seconds * 1000)
|
||||
else:
|
||||
await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
|
||||
@commands.group(name="shuffle", autohelp=False)
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_shuffle(self, ctx: commands.Context):
|
||||
"""Toggle shuffle."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if dj_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Toggle Shuffle"),
|
||||
description=_("You need the DJ role to toggle shuffle."),
|
||||
)
|
||||
if self._player_check(ctx):
|
||||
await self.set_player_settings(ctx)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Toggle Shuffle"),
|
||||
description=_("You must be in the voice channel to toggle shuffle."),
|
||||
)
|
||||
|
||||
shuffle = await self.config.guild(ctx.guild).shuffle()
|
||||
await self.config.guild(ctx.guild).shuffle.set(not shuffle)
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("Shuffle tracks: {true_or_false}.").format(
|
||||
true_or_false=_("Enabled") if not shuffle else _("Disabled")
|
||||
),
|
||||
)
|
||||
if self._player_check(ctx):
|
||||
await self.set_player_settings(ctx)
|
||||
|
||||
@command_shuffle.command(name="bumped")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_shuffle_bumpped(self, ctx: commands.Context):
|
||||
"""Toggle bumped track shuffle.
|
||||
|
||||
Set this to disabled if you wish to avoid bumped songs being shuffled.
|
||||
This takes priority over `[p]shuffle`.
|
||||
"""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if dj_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Toggle Shuffle"),
|
||||
description=_("You need the DJ role to toggle shuffle."),
|
||||
)
|
||||
if self._player_check(ctx):
|
||||
await self.set_player_settings(ctx)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Toggle Shuffle"),
|
||||
description=_("You must be in the voice channel to toggle shuffle."),
|
||||
)
|
||||
|
||||
bumped = await self.config.guild(ctx.guild).shuffle_bumped()
|
||||
await self.config.guild(ctx.guild).shuffle_bumped.set(not bumped)
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("Shuffle bumped tracks: {true_or_false}.").format(
|
||||
true_or_false=_("Enabled") if not bumped else _("Disabled")
|
||||
),
|
||||
)
|
||||
if self._player_check(ctx):
|
||||
await self.set_player_settings(ctx)
|
||||
|
||||
@commands.command(name="skip")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_skip(self, ctx: commands.Context, skip_to_track: int = None):
|
||||
"""Skip to the next track, or to a given track number."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_("You must be in the voice channel to skip the music."),
|
||||
)
|
||||
if not player.current:
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
is_alone = await self.is_requester_alone(ctx)
|
||||
is_requester = await self.is_requester(ctx, ctx.author)
|
||||
if dj_enabled and not vote_enabled:
|
||||
if not (can_skip or is_requester) and not is_alone:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_(
|
||||
"You need the DJ role or be the track requester to skip tracks."
|
||||
),
|
||||
)
|
||||
if (
|
||||
is_requester
|
||||
and not can_skip
|
||||
and isinstance(skip_to_track, int)
|
||||
and skip_to_track > 1
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_("You can only skip the current track."),
|
||||
)
|
||||
|
||||
if vote_enabled:
|
||||
if not can_skip:
|
||||
if skip_to_track is not None:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Skip Tracks"),
|
||||
description=_(
|
||||
"Can't skip to a specific track in vote mode without the DJ role."
|
||||
),
|
||||
)
|
||||
if ctx.author.id in self.skip_votes[ctx.message.guild]:
|
||||
self.skip_votes[ctx.message.guild].remove(ctx.author.id)
|
||||
reply = _("I removed your vote to skip.")
|
||||
else:
|
||||
self.skip_votes[ctx.message.guild].append(ctx.author.id)
|
||||
reply = _("You voted to skip.")
|
||||
|
||||
num_votes = len(self.skip_votes[ctx.message.guild])
|
||||
vote_mods = []
|
||||
for member in player.channel.members:
|
||||
can_skip = await self._can_instaskip(ctx, member)
|
||||
if can_skip:
|
||||
vote_mods.append(member)
|
||||
num_members = len(player.channel.members) - len(vote_mods)
|
||||
vote = int(100 * num_votes / num_members)
|
||||
percent = await self.config.guild(ctx.guild).vote_percent()
|
||||
if vote >= percent:
|
||||
self.skip_votes[ctx.message.guild] = []
|
||||
await self.send_embed_msg(ctx, title=_("Vote threshold met."))
|
||||
return await self._skip_action(ctx)
|
||||
else:
|
||||
reply += _(
|
||||
" Votes: {num_votes}/{num_members}"
|
||||
" ({cur_percent}% out of {required_percent}% needed)"
|
||||
).format(
|
||||
num_votes=humanize_number(num_votes),
|
||||
num_members=humanize_number(num_members),
|
||||
cur_percent=vote,
|
||||
required_percent=percent,
|
||||
)
|
||||
return await self.send_embed_msg(ctx, title=reply)
|
||||
else:
|
||||
return await self._skip_action(ctx, skip_to_track)
|
||||
else:
|
||||
return await self._skip_action(ctx, skip_to_track)
|
||||
|
||||
@commands.command(name="stop")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_stop(self, ctx: commands.Context):
|
||||
"""Stop playback and clear the queue."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
is_alone = await self.is_requester_alone(ctx)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Stop Player"),
|
||||
description=_("You must be in the voice channel to stop the music."),
|
||||
)
|
||||
if (vote_enabled or (vote_enabled and dj_enabled)) and not can_skip and not is_alone:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Stop Player"),
|
||||
description=_("There are other people listening - vote to skip instead."),
|
||||
)
|
||||
if dj_enabled and not vote_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Stop Player"),
|
||||
description=_("You need the DJ role to stop the music."),
|
||||
)
|
||||
if (
|
||||
player.is_playing
|
||||
or (not player.is_playing and player.paused)
|
||||
or player.queue
|
||||
or getattr(player.current, "extras", {}).get("autoplay")
|
||||
):
|
||||
eq = player.fetch("eq")
|
||||
if eq:
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
|
||||
player.queue = []
|
||||
player.store("playing_song", None)
|
||||
player.store("prev_requester", None)
|
||||
player.store("prev_song", None)
|
||||
player.store("requester", None)
|
||||
await player.stop()
|
||||
await self.send_embed_msg(ctx, title=_("Stopping..."))
|
||||
|
||||
@commands.command(name="summon")
|
||||
@commands.guild_only()
|
||||
@commands.cooldown(1, 15, commands.BucketType.guild)
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_summon(self, ctx: commands.Context):
|
||||
"""Summon the bot to a voice channel."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
|
||||
is_alone = await self.is_requester_alone(ctx)
|
||||
is_requester = await self.is_requester(ctx, ctx.author)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if (vote_enabled or (vote_enabled and dj_enabled)) and not can_skip and not is_alone:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Join Voice Channel"),
|
||||
description=_("There are other people listening."),
|
||||
)
|
||||
if dj_enabled and not vote_enabled and not (can_skip or is_requester) and not is_alone:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Join Voice Channel"),
|
||||
description=_("You need the DJ role to summon the bot."),
|
||||
)
|
||||
|
||||
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)
|
||||
):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Join Voice Channel"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
if not self._player_check(ctx):
|
||||
await lavalink.connect(ctx.author.voice.channel)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
player.store("connect", datetime.datetime.utcnow())
|
||||
else:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if ctx.author.voice.channel == player.channel:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return
|
||||
await player.move_to(ctx.author.voice.channel)
|
||||
except AttributeError:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Join Voice Channel"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Join Voice Channel"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
|
||||
@commands.command(name="volume")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_volume(self, ctx: commands.Context, vol: int = None):
|
||||
"""Set the volume, 1% - 150%."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if not vol:
|
||||
vol = await self.config.guild(ctx.guild).volume()
|
||||
embed = discord.Embed(title=_("Current Volume:"), description=str(vol) + "%")
|
||||
if not self._player_check(ctx):
|
||||
embed.set_footer(text=_("Nothing playing."))
|
||||
return await self.send_embed_msg(ctx, embed=embed)
|
||||
if self._player_check(ctx):
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Change Volume"),
|
||||
description=_("You must be in the voice channel to change the volume."),
|
||||
)
|
||||
if dj_enabled and not can_skip and not await self._has_dj_role(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Change Volume"),
|
||||
description=_("You need the DJ role to change the volume."),
|
||||
)
|
||||
if vol < 0:
|
||||
vol = 0
|
||||
if vol > 150:
|
||||
vol = 150
|
||||
await self.config.guild(ctx.guild).volume.set(vol)
|
||||
if self._player_check(ctx):
|
||||
await lavalink.get_player(ctx.guild.id).set_volume(vol)
|
||||
else:
|
||||
await self.config.guild(ctx.guild).volume.set(vol)
|
||||
if self._player_check(ctx):
|
||||
await lavalink.get_player(ctx.guild.id).set_volume(vol)
|
||||
embed = discord.Embed(title=_("Volume:"), description=str(vol) + "%")
|
||||
if not self._player_check(ctx):
|
||||
embed.set_footer(text=_("Nothing playing."))
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
|
||||
@commands.command(name="repeat")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_repeat(self, ctx: commands.Context):
|
||||
"""Toggle repeat."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if dj_enabled and not can_skip and not await self._has_dj_role(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Toggle Repeat"),
|
||||
description=_("You need the DJ role to toggle repeat."),
|
||||
)
|
||||
if self._player_check(ctx):
|
||||
await self.set_player_settings(ctx)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Toggle Repeat"),
|
||||
description=_("You must be in the voice channel to toggle repeat."),
|
||||
)
|
||||
|
||||
autoplay = await self.config.guild(ctx.guild).auto_play()
|
||||
repeat = await self.config.guild(ctx.guild).repeat()
|
||||
msg = ""
|
||||
msg += _("Repeat tracks: {true_or_false}.").format(
|
||||
true_or_false=_("Enabled") if not repeat else _("Disabled")
|
||||
)
|
||||
await self.config.guild(ctx.guild).repeat.set(not repeat)
|
||||
if repeat is not True and autoplay is True:
|
||||
msg += _("\nAuto-play has been disabled.")
|
||||
await self.config.guild(ctx.guild).auto_play.set(False)
|
||||
|
||||
embed = discord.Embed(title=_("Setting Changed"), description=msg)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
if self._player_check(ctx):
|
||||
await self.set_player_settings(ctx)
|
||||
|
||||
@commands.command(name="remove")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_remove(self, ctx: commands.Context, index_or_url: Union[int, str]):
|
||||
"""Remove a specific track number from the queue."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if not player.queue:
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing queued."))
|
||||
if dj_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Modify Queue"),
|
||||
description=_("You need the DJ role to remove tracks."),
|
||||
)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Modify Queue"),
|
||||
description=_("You must be in the voice channel to manage the queue."),
|
||||
)
|
||||
if isinstance(index_or_url, int):
|
||||
if index_or_url > len(player.queue) or index_or_url < 1:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Modify Queue"),
|
||||
description=_(
|
||||
"Song number must be greater than 1 and within the queue limit."
|
||||
),
|
||||
)
|
||||
index_or_url -= 1
|
||||
removed = player.queue.pop(index_or_url)
|
||||
removed_title = self.get_track_description(removed, self.local_folder_current_path)
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Removed track from queue"),
|
||||
description=_("Removed {track} from the queue.").format(track=removed_title),
|
||||
)
|
||||
else:
|
||||
clean_tracks = []
|
||||
removed_tracks = 0
|
||||
async for track in AsyncIter(player.queue):
|
||||
if track.uri != index_or_url:
|
||||
clean_tracks.append(track)
|
||||
else:
|
||||
removed_tracks += 1
|
||||
player.queue = clean_tracks
|
||||
if removed_tracks == 0:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Modify Queue"),
|
||||
description=_("Removed 0 tracks, nothing matches the URL provided."),
|
||||
)
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Removed track from queue"),
|
||||
description=_(
|
||||
"Removed {removed_tracks} tracks from queue "
|
||||
"which matched the URL provided."
|
||||
).format(removed_tracks=removed_tracks),
|
||||
)
|
||||
|
||||
@commands.command(name="bump")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_bump(self, ctx: commands.Context, index: int):
|
||||
"""Bump a track number to the top of the queue."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Bump Track"),
|
||||
description=_("You must be in the voice channel to bump a track."),
|
||||
)
|
||||
if dj_enabled and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Bump Track"),
|
||||
description=_("You need the DJ role to bump tracks."),
|
||||
)
|
||||
if index > len(player.queue) or index < 1:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Bump Track"),
|
||||
description=_("Song number must be greater than 1 and within the queue limit."),
|
||||
)
|
||||
|
||||
bump_index = index - 1
|
||||
bump_song = player.queue[bump_index]
|
||||
bump_song.extras["bumped"] = True
|
||||
player.queue.insert(0, bump_song)
|
||||
removed = player.queue.pop(index)
|
||||
description = self.get_track_description(removed, self.local_folder_current_path)
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Moved track to the top of the queue."), description=description
|
||||
)
|
||||
385
redbot/cogs/audio/core/commands/equalizer.py
Normal file
385
redbot/cogs/audio/core/commands/equalizer.py
Normal file
@ -0,0 +1,385 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import box, humanize_number, pagify
|
||||
from redbot.core.utils.menus import DEFAULT_CONTROLS, menu, start_adding_reactions
|
||||
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate
|
||||
|
||||
from ...equalizer import Equalizer
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.equalizer")
|
||||
|
||||
|
||||
class EqualizerCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.group(name="eq", invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
@commands.cooldown(1, 15, commands.BucketType.guild)
|
||||
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
|
||||
async def command_equalizer(self, ctx: commands.Context):
|
||||
"""Equalizer management."""
|
||||
if not self._player_check(ctx):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
eq = player.fetch("eq", Equalizer())
|
||||
reactions = [
|
||||
"\N{BLACK LEFT-POINTING TRIANGLE}",
|
||||
"\N{LEFTWARDS BLACK ARROW}",
|
||||
"\N{BLACK UP-POINTING DOUBLE TRIANGLE}",
|
||||
"\N{UP-POINTING SMALL RED TRIANGLE}",
|
||||
"\N{DOWN-POINTING SMALL RED TRIANGLE}",
|
||||
"\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}",
|
||||
"\N{BLACK RIGHTWARDS ARROW}",
|
||||
"\N{BLACK RIGHT-POINTING TRIANGLE}",
|
||||
"\N{BLACK CIRCLE FOR RECORD}",
|
||||
"\N{INFORMATION SOURCE}",
|
||||
]
|
||||
await self._eq_msg_clear(player.fetch("eq_message"))
|
||||
eq_message = await ctx.send(box(eq.visualise(), lang="ini"))
|
||||
|
||||
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await eq_message.add_reaction("\N{INFORMATION SOURCE}")
|
||||
else:
|
||||
start_adding_reactions(eq_message, reactions)
|
||||
|
||||
eq_msg_with_reacts = await ctx.fetch_message(eq_message.id)
|
||||
player.store("eq_message", eq_msg_with_reacts)
|
||||
await self._eq_interact(ctx, player, eq, eq_msg_with_reacts, 0)
|
||||
|
||||
@command_equalizer.command(name="delete", aliases=["del", "remove"])
|
||||
async def command_equalizer_delete(self, ctx: commands.Context, eq_preset: str):
|
||||
"""Delete a saved eq preset."""
|
||||
async with self.config.custom("EQUALIZER", ctx.guild.id).eq_presets() as eq_presets:
|
||||
eq_preset = eq_preset.lower()
|
||||
try:
|
||||
if eq_presets[eq_preset][
|
||||
"author"
|
||||
] != ctx.author.id and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Delete Preset"),
|
||||
description=_("You are not the author of that preset setting."),
|
||||
)
|
||||
del eq_presets[eq_preset]
|
||||
except KeyError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Delete Preset"),
|
||||
description=_(
|
||||
"{eq_preset} is not in the eq preset list.".format(
|
||||
eq_preset=eq_preset.capitalize()
|
||||
)
|
||||
),
|
||||
)
|
||||
except TypeError:
|
||||
if await self._can_instaskip(ctx, ctx.author):
|
||||
del eq_presets[eq_preset]
|
||||
else:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Delete Preset"),
|
||||
description=_("You are not the author of that preset setting."),
|
||||
)
|
||||
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("The {preset_name} preset was deleted.".format(preset_name=eq_preset))
|
||||
)
|
||||
|
||||
@command_equalizer.command(name="list")
|
||||
async def command_equalizer_list(self, ctx: commands.Context):
|
||||
"""List saved eq presets."""
|
||||
eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets()
|
||||
if not eq_presets.keys():
|
||||
return await self.send_embed_msg(ctx, title=_("No saved equalizer presets."))
|
||||
|
||||
space = "\N{EN SPACE}"
|
||||
header_name = _("Preset Name")
|
||||
header_author = _("Author")
|
||||
header = box(
|
||||
"[{header_name}]{space}[{header_author}]\n".format(
|
||||
header_name=header_name, space=space * 9, header_author=header_author
|
||||
),
|
||||
lang="ini",
|
||||
)
|
||||
preset_list = ""
|
||||
for preset, bands in eq_presets.items():
|
||||
try:
|
||||
author = self.bot.get_user(bands["author"])
|
||||
except TypeError:
|
||||
author = "None"
|
||||
msg = f"{preset}{space * (22 - len(preset))}{author}\n"
|
||||
preset_list += msg
|
||||
|
||||
page_list = []
|
||||
colour = await ctx.embed_colour()
|
||||
for page in pagify(preset_list, delims=[", "], page_length=1000):
|
||||
formatted_page = box(page, lang="ini")
|
||||
embed = discord.Embed(colour=colour, description=f"{header}\n{formatted_page}")
|
||||
embed.set_footer(
|
||||
text=_("{num} preset(s)").format(num=humanize_number(len(list(eq_presets.keys()))))
|
||||
)
|
||||
page_list.append(embed)
|
||||
await menu(ctx, page_list, DEFAULT_CONTROLS)
|
||||
|
||||
@command_equalizer.command(name="load")
|
||||
async def command_equalizer_load(self, ctx: commands.Context, eq_preset: str):
|
||||
"""Load a saved eq preset."""
|
||||
eq_preset = eq_preset.lower()
|
||||
eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets()
|
||||
try:
|
||||
eq_values = eq_presets[eq_preset]["bands"]
|
||||
except KeyError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("No Preset Found"),
|
||||
description=_(
|
||||
"Preset named {eq_preset} does not exist.".format(eq_preset=eq_preset)
|
||||
),
|
||||
)
|
||||
except TypeError:
|
||||
eq_values = eq_presets[eq_preset]
|
||||
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Load Preset"),
|
||||
description=_("You need the DJ role to load equalizer presets."),
|
||||
)
|
||||
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq_values)
|
||||
await self._eq_check(ctx, player)
|
||||
eq = player.fetch("eq", Equalizer())
|
||||
await self._eq_msg_clear(player.fetch("eq_message"))
|
||||
message = await ctx.send(
|
||||
content=box(eq.visualise(), lang="ini"),
|
||||
embed=discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("The {eq_preset} preset was loaded.".format(eq_preset=eq_preset)),
|
||||
),
|
||||
)
|
||||
player.store("eq_message", message)
|
||||
|
||||
@command_equalizer.command(name="reset")
|
||||
async def command_equalizer_reset(self, ctx: commands.Context):
|
||||
"""Reset the eq to 0 across all bands."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Modify Preset"),
|
||||
description=_("You need the DJ role to reset the equalizer."),
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
eq = player.fetch("eq", Equalizer())
|
||||
|
||||
for band in range(eq.band_count):
|
||||
eq.set_gain(band, 0.0)
|
||||
|
||||
await self._apply_gains(ctx.guild.id, eq.bands)
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
|
||||
player.store("eq", eq)
|
||||
await self._eq_msg_clear(player.fetch("eq_message"))
|
||||
message = await ctx.send(
|
||||
content=box(eq.visualise(), lang="ini"),
|
||||
embed=discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=_("Equalizer values have been reset.")
|
||||
),
|
||||
)
|
||||
player.store("eq_message", message)
|
||||
|
||||
@command_equalizer.command(name="save")
|
||||
@commands.cooldown(1, 15, commands.BucketType.guild)
|
||||
async def command_equalizer_save(self, ctx: commands.Context, eq_preset: str = None):
|
||||
"""Save the current eq settings to a preset."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Save Preset"),
|
||||
description=_("You need the DJ role to save equalizer presets."),
|
||||
)
|
||||
if not eq_preset:
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Please enter a name for this equalizer preset.")
|
||||
)
|
||||
try:
|
||||
eq_name_msg = await self.bot.wait_for(
|
||||
"message",
|
||||
timeout=15.0,
|
||||
check=MessagePredicate.regex(fr"^(?!{re.escape(ctx.prefix)})", ctx),
|
||||
)
|
||||
eq_preset = eq_name_msg.content.split(" ")[0].strip('"').lower()
|
||||
except asyncio.TimeoutError:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Save Preset"),
|
||||
description=_(
|
||||
"No equalizer preset name entered, try the command again later."
|
||||
),
|
||||
)
|
||||
eq_preset = eq_preset or ""
|
||||
eq_exists_msg = None
|
||||
eq_preset = eq_preset.lower().lstrip(ctx.prefix)
|
||||
eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets()
|
||||
eq_list = list(eq_presets.keys())
|
||||
|
||||
if len(eq_preset) > 20:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Save Preset"),
|
||||
description=_("Try the command again with a shorter name."),
|
||||
)
|
||||
if eq_preset in eq_list:
|
||||
eq_exists_msg = await self.send_embed_msg(
|
||||
ctx, title=_("Preset name already exists, do you want to replace it?")
|
||||
)
|
||||
start_adding_reactions(eq_exists_msg, ReactionPredicate.YES_OR_NO_EMOJIS)
|
||||
pred = ReactionPredicate.yes_or_no(eq_exists_msg, ctx.author)
|
||||
await self.bot.wait_for("reaction_add", check=pred)
|
||||
if not pred.result:
|
||||
await self._clear_react(eq_exists_msg)
|
||||
embed2 = discord.Embed(
|
||||
colour=await ctx.embed_colour(), title=_("Not saving preset.")
|
||||
)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await eq_exists_msg.edit(embed=embed2)
|
||||
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
eq = player.fetch("eq", Equalizer())
|
||||
to_append = {eq_preset: {"author": ctx.author.id, "bands": eq.bands}}
|
||||
new_eq_presets = {**eq_presets, **to_append}
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets.set(new_eq_presets)
|
||||
embed3 = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Current equalizer saved to the {preset_name} preset.").format(
|
||||
preset_name=eq_preset
|
||||
),
|
||||
)
|
||||
if eq_exists_msg:
|
||||
await self._clear_react(eq_exists_msg)
|
||||
await eq_exists_msg.edit(embed=embed3)
|
||||
else:
|
||||
await self.send_embed_msg(ctx, embed=embed3)
|
||||
|
||||
@command_equalizer.command(name="set")
|
||||
async def command_equalizer_set(
|
||||
self, ctx: commands.Context, band_name_or_position, band_value: float
|
||||
):
|
||||
"""Set an eq band with a band number or name and value.
|
||||
|
||||
Band positions are 1-15 and values have a range of -0.25 to 1.0.
|
||||
Band names are 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k,
|
||||
6.3k, 10k, and 16k Hz.
|
||||
Setting a band value to -0.25 nullifies it while +0.25 is double.
|
||||
"""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Set Preset"),
|
||||
description=_("You need the DJ role to set equalizer presets."),
|
||||
)
|
||||
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
band_names = [
|
||||
"25",
|
||||
"40",
|
||||
"63",
|
||||
"100",
|
||||
"160",
|
||||
"250",
|
||||
"400",
|
||||
"630",
|
||||
"1k",
|
||||
"1.6k",
|
||||
"2.5k",
|
||||
"4k",
|
||||
"6.3k",
|
||||
"10k",
|
||||
"16k",
|
||||
]
|
||||
|
||||
eq = player.fetch("eq", Equalizer())
|
||||
bands_num = eq.band_count
|
||||
if band_value > 1:
|
||||
band_value = 1
|
||||
elif band_value <= -0.25:
|
||||
band_value = -0.25
|
||||
else:
|
||||
band_value = round(band_value, 1)
|
||||
|
||||
try:
|
||||
band_number = int(band_name_or_position) - 1
|
||||
except ValueError:
|
||||
band_number = 1000
|
||||
|
||||
if band_number not in range(0, bands_num) and band_name_or_position not in band_names:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Band"),
|
||||
description=_(
|
||||
"Valid band numbers are 1-15 or the band names listed in "
|
||||
"the help for this command."
|
||||
),
|
||||
)
|
||||
|
||||
if band_name_or_position in band_names:
|
||||
band_pos = band_names.index(band_name_or_position)
|
||||
band_int = False
|
||||
eq.set_gain(int(band_pos), band_value)
|
||||
await self._apply_gain(ctx.guild.id, int(band_pos), band_value)
|
||||
else:
|
||||
band_int = True
|
||||
eq.set_gain(band_number, band_value)
|
||||
await self._apply_gain(ctx.guild.id, band_number, band_value)
|
||||
|
||||
await self._eq_msg_clear(player.fetch("eq_message"))
|
||||
await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands)
|
||||
player.store("eq", eq)
|
||||
band_name = band_names[band_number] if band_int else band_name_or_position
|
||||
message = await ctx.send(
|
||||
content=box(eq.visualise(), lang="ini"),
|
||||
embed=discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Preset Modified"),
|
||||
description=_("The {band_name}Hz band has been set to {band_value}.").format(
|
||||
band_name=band_name, band_value=band_value
|
||||
),
|
||||
),
|
||||
)
|
||||
player.store("eq_message", message)
|
||||
168
redbot/cogs/audio/core/commands/llset.py
Normal file
168
redbot/cogs/audio/core/commands/llset.py
Normal file
@ -0,0 +1,168 @@
|
||||
import logging
|
||||
|
||||
import discord
|
||||
|
||||
from redbot.core import commands
|
||||
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.lavalink_setup")
|
||||
|
||||
|
||||
class LavalinkSetupCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.group(name="llsetup", aliases=["llset"])
|
||||
@commands.is_owner()
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_llsetup(self, ctx: commands.Context):
|
||||
"""Lavalink server configuration options."""
|
||||
|
||||
@command_llsetup.command(name="external")
|
||||
async def command_llsetup_external(self, ctx: commands.Context):
|
||||
"""Toggle using external Lavalink servers."""
|
||||
external = await self.config.use_external_lavalink()
|
||||
await self.config.use_external_lavalink.set(not external)
|
||||
|
||||
if external:
|
||||
embed = discord.Embed(
|
||||
title=_("Setting Changed"),
|
||||
description=_("External Lavalink server: {true_or_false}.").format(
|
||||
true_or_false=_("Enabled") if not external else _("Disabled")
|
||||
),
|
||||
)
|
||||
await self.send_embed_msg(ctx, embed=embed)
|
||||
else:
|
||||
try:
|
||||
if self.player_manager is not None:
|
||||
await self.player_manager.shutdown()
|
||||
except ProcessLookupError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Failed To Shutdown Lavalink"),
|
||||
description=_(
|
||||
"External Lavalink server: {true_or_false}\n"
|
||||
"For it to take effect please reload "
|
||||
"Audio (`{prefix}reload audio`)."
|
||||
).format(
|
||||
true_or_false=_("Enabled") if not external else _("Disabled"),
|
||||
prefix=ctx.prefix,
|
||||
),
|
||||
)
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("External Lavalink server: {true_or_false}.").format(
|
||||
true_or_false=_("Enabled") if not external else _("Disabled")
|
||||
),
|
||||
)
|
||||
try:
|
||||
self.lavalink_restart_connect()
|
||||
except ProcessLookupError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Failed To Shutdown Lavalink"),
|
||||
description=_("Please reload Audio (`{prefix}reload audio`).").format(
|
||||
prefix=ctx.prefix
|
||||
),
|
||||
)
|
||||
|
||||
@command_llsetup.command(name="host")
|
||||
async def command_llsetup_host(self, ctx: commands.Context, host: str):
|
||||
"""Set the Lavalink server host."""
|
||||
await self.config.host.set(host)
|
||||
footer = None
|
||||
if await self.update_external_status():
|
||||
footer = _("External Lavalink server set to True.")
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("Host set to {host}.").format(host=host),
|
||||
footer=footer,
|
||||
)
|
||||
try:
|
||||
self.lavalink_restart_connect()
|
||||
except ProcessLookupError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Failed To Shutdown Lavalink"),
|
||||
description=_("Please reload Audio (`{prefix}reload audio`).").format(
|
||||
prefix=ctx.prefix
|
||||
),
|
||||
)
|
||||
|
||||
@command_llsetup.command(name="password")
|
||||
async def command_llsetup_password(self, ctx: commands.Context, password: str):
|
||||
"""Set the Lavalink server password."""
|
||||
await self.config.password.set(str(password))
|
||||
footer = None
|
||||
if await self.update_external_status():
|
||||
footer = _("External Lavalink server set to True.")
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("Server password set to {password}.").format(password=password),
|
||||
footer=footer,
|
||||
)
|
||||
|
||||
try:
|
||||
self.lavalink_restart_connect()
|
||||
except ProcessLookupError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Failed To Shutdown Lavalink"),
|
||||
description=_("Please reload Audio (`{prefix}reload audio`).").format(
|
||||
prefix=ctx.prefix
|
||||
),
|
||||
)
|
||||
|
||||
@command_llsetup.command(name="restport")
|
||||
async def command_llsetup_restport(self, ctx: commands.Context, rest_port: int):
|
||||
"""Set the Lavalink REST server port."""
|
||||
await self.config.rest_port.set(rest_port)
|
||||
footer = None
|
||||
if await self.update_external_status():
|
||||
footer = _("External Lavalink server set to True.")
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("REST port set to {port}.").format(port=rest_port),
|
||||
footer=footer,
|
||||
)
|
||||
|
||||
try:
|
||||
self.lavalink_restart_connect()
|
||||
except ProcessLookupError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Failed To Shutdown Lavalink"),
|
||||
description=_("Please reload Audio (`{prefix}reload audio`).").format(
|
||||
prefix=ctx.prefix
|
||||
),
|
||||
)
|
||||
|
||||
@command_llsetup.command(name="wsport")
|
||||
async def command_llsetup_wsport(self, ctx: commands.Context, ws_port: int):
|
||||
"""Set the Lavalink websocket server port."""
|
||||
await self.config.ws_port.set(ws_port)
|
||||
footer = None
|
||||
if await self.update_external_status():
|
||||
footer = _("External Lavalink server set to True.")
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Setting Changed"),
|
||||
description=_("Websocket port set to {port}.").format(port=ws_port),
|
||||
footer=footer,
|
||||
)
|
||||
|
||||
try:
|
||||
self.lavalink_restart_connect()
|
||||
except ProcessLookupError:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Failed To Shutdown Lavalink"),
|
||||
description=_("Please reload Audio (`{prefix}reload audio`).").format(
|
||||
prefix=ctx.prefix
|
||||
),
|
||||
)
|
||||
118
redbot/cogs/audio/core/commands/localtracks.py
Normal file
118
redbot/cogs/audio/core/commands/localtracks.py
Normal file
@ -0,0 +1,118 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import MutableMapping
|
||||
|
||||
import discord
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.menus import DEFAULT_CONTROLS, close_menu, menu, next_page, prev_page
|
||||
|
||||
from ...audio_dataclasses import LocalPath, Query
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.local_track")
|
||||
|
||||
|
||||
class LocalTrackCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.group(name="local")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
|
||||
async def command_local(self, ctx: commands.Context):
|
||||
"""Local playback commands."""
|
||||
|
||||
@command_local.command(name="folder", aliases=["start"])
|
||||
async def command_local_folder(self, ctx: commands.Context, *, folder: str = None):
|
||||
"""Play all songs in a localtracks folder."""
|
||||
if not await self.localtracks_folder_exists(ctx):
|
||||
return
|
||||
|
||||
if not folder:
|
||||
await ctx.invoke(self.command_local_play)
|
||||
else:
|
||||
folder = folder.strip()
|
||||
_dir = LocalPath.joinpath(self.local_folder_current_path, folder)
|
||||
if not _dir.exists():
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Folder Not Found"),
|
||||
description=_("Localtracks folder named {name} does not exist.").format(
|
||||
name=folder
|
||||
),
|
||||
)
|
||||
query = Query.process_input(
|
||||
_dir, self.local_folder_current_path, search_subfolders=True
|
||||
)
|
||||
await self._local_play_all(ctx, query, from_search=False if not folder else True)
|
||||
|
||||
@command_local.command(name="play")
|
||||
async def command_local_play(self, ctx: commands.Context):
|
||||
"""Play a local track."""
|
||||
if not await self.localtracks_folder_exists(ctx):
|
||||
return
|
||||
localtracks_folders = await self.get_localtracks_folders(ctx, search_subfolders=True)
|
||||
if not localtracks_folders:
|
||||
return await self.send_embed_msg(ctx, title=_("No album folders found."))
|
||||
async with ctx.typing():
|
||||
len_folder_pages = math.ceil(len(localtracks_folders) / 5)
|
||||
folder_page_list = []
|
||||
for page_num in range(1, len_folder_pages + 1):
|
||||
embed = await self._build_search_page(ctx, localtracks_folders, page_num)
|
||||
folder_page_list.append(embed)
|
||||
|
||||
async def _local_folder_menu(
|
||||
ctx: commands.Context,
|
||||
pages: list,
|
||||
controls: MutableMapping,
|
||||
message: discord.Message,
|
||||
page: int,
|
||||
timeout: float,
|
||||
emoji: str,
|
||||
):
|
||||
if message:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.delete()
|
||||
await self._search_button_action(ctx, localtracks_folders, emoji, page)
|
||||
return None
|
||||
|
||||
local_folder_controls = {
|
||||
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
|
||||
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
|
||||
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
|
||||
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
|
||||
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu,
|
||||
"\N{LEFTWARDS BLACK ARROW}": prev_page,
|
||||
"\N{CROSS MARK}": close_menu,
|
||||
"\N{BLACK RIGHTWARDS ARROW}": next_page,
|
||||
}
|
||||
|
||||
dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
|
||||
if dj_enabled and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await menu(ctx, folder_page_list, DEFAULT_CONTROLS)
|
||||
else:
|
||||
await menu(ctx, folder_page_list, local_folder_controls)
|
||||
|
||||
@command_local.command(name="search")
|
||||
async def command_local_search(self, ctx: commands.Context, *, search_words):
|
||||
"""Search for songs across all localtracks folders."""
|
||||
if not await self.localtracks_folder_exists(ctx):
|
||||
return
|
||||
all_tracks = await self.get_localtrack_folder_list(
|
||||
ctx,
|
||||
(
|
||||
Query.process_input(
|
||||
Path(await self.config.localpath()).absolute(),
|
||||
self.local_folder_current_path,
|
||||
search_subfolders=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
if not all_tracks:
|
||||
return await self.send_embed_msg(ctx, title=_("No album folders found."))
|
||||
async with ctx.typing():
|
||||
search_list = await self._build_local_search_list(all_tracks, search_words)
|
||||
if not search_list:
|
||||
return await self.send_embed_msg(ctx, title=_("No matches."))
|
||||
return await ctx.invoke(self.command_search, query=search_list)
|
||||
138
redbot/cogs/audio/core/commands/miscellaneous.py
Normal file
138
redbot/cogs/audio/core/commands/miscellaneous.py
Normal file
@ -0,0 +1,138 @@
|
||||
import datetime
|
||||
import heapq
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.chat_formatting import humanize_number, pagify
|
||||
from redbot.core.utils.menus import DEFAULT_CONTROLS, menu
|
||||
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.miscellaneous")
|
||||
|
||||
|
||||
class MiscellaneousCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.command(name="sing")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_sing(self, ctx: commands.Context):
|
||||
"""Make Red sing one of her songs."""
|
||||
ids = (
|
||||
"zGTkAVsrfg8",
|
||||
"cGMWL8cOeAU",
|
||||
"vFrjMq4aL-g",
|
||||
"WROI5WYBU_A",
|
||||
"41tIUr_ex3g",
|
||||
"f9O2Rjn1azc",
|
||||
)
|
||||
url = f"https://www.youtube.com/watch?v={random.choice(ids)}"
|
||||
await ctx.invoke(self.command_play, query=url)
|
||||
|
||||
@commands.command(name="audiostats")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
|
||||
async def command_audiostats(self, ctx: commands.Context):
|
||||
"""Audio stats."""
|
||||
server_num = len(lavalink.active_players())
|
||||
total_num = len(lavalink.all_players())
|
||||
|
||||
msg = ""
|
||||
async for p in AsyncIter(lavalink.all_players()):
|
||||
connect_start = p.fetch("connect")
|
||||
connect_dur = self.get_time_string(
|
||||
int((datetime.datetime.utcnow() - connect_start).total_seconds())
|
||||
)
|
||||
try:
|
||||
if not p.current:
|
||||
raise AttributeError
|
||||
current_title = self.get_track_description(
|
||||
p.current, self.local_folder_current_path
|
||||
)
|
||||
msg += "{} [`{}`]: {}\n".format(p.channel.guild.name, connect_dur, current_title)
|
||||
except AttributeError:
|
||||
msg += "{} [`{}`]: **{}**\n".format(
|
||||
p.channel.guild.name, connect_dur, _("Nothing playing.")
|
||||
)
|
||||
|
||||
if total_num == 0:
|
||||
return await self.send_embed_msg(ctx, title=_("Not connected anywhere."))
|
||||
servers_embed = []
|
||||
pages = 1
|
||||
for page in pagify(msg, delims=["\n"], page_length=1500):
|
||||
em = discord.Embed(
|
||||
colour=await ctx.embed_colour(),
|
||||
title=_("Playing in {num}/{total} servers:").format(
|
||||
num=humanize_number(server_num), total=humanize_number(total_num)
|
||||
),
|
||||
description=page,
|
||||
)
|
||||
em.set_footer(
|
||||
text=_("Page {}/{}").format(
|
||||
humanize_number(pages), humanize_number((math.ceil(len(msg) / 1500)))
|
||||
)
|
||||
)
|
||||
pages += 1
|
||||
servers_embed.append(em)
|
||||
|
||||
await menu(ctx, servers_embed, DEFAULT_CONTROLS)
|
||||
|
||||
@commands.command(name="percent")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_percent(self, ctx: commands.Context):
|
||||
"""Queue percentage."""
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("Nothing playing."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
queue_tracks = player.queue
|
||||
requesters = {"total": 0, "users": {}}
|
||||
|
||||
async def _usercount(req_username):
|
||||
if req_username in requesters["users"]:
|
||||
requesters["users"][req_username]["songcount"] += 1
|
||||
requesters["total"] += 1
|
||||
else:
|
||||
requesters["users"][req_username] = {}
|
||||
requesters["users"][req_username]["songcount"] = 1
|
||||
requesters["total"] += 1
|
||||
|
||||
async for track in AsyncIter(queue_tracks):
|
||||
req_username = "{}#{}".format(track.requester.name, track.requester.discriminator)
|
||||
await _usercount(req_username)
|
||||
|
||||
try:
|
||||
req_username = "{}#{}".format(
|
||||
player.current.requester.name, player.current.requester.discriminator
|
||||
)
|
||||
await _usercount(req_username)
|
||||
except AttributeError:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
|
||||
async for req_username in AsyncIter(requesters["users"]):
|
||||
percentage = float(requesters["users"][req_username]["songcount"]) / float(
|
||||
requesters["total"]
|
||||
)
|
||||
requesters["users"][req_username]["percent"] = round(percentage * 100, 1)
|
||||
|
||||
top_queue_users = heapq.nlargest(
|
||||
20,
|
||||
[
|
||||
(x, requesters["users"][x][y])
|
||||
for x in requesters["users"]
|
||||
for y in requesters["users"][x]
|
||||
if y == "percent"
|
||||
],
|
||||
key=lambda x: x[1],
|
||||
)
|
||||
queue_user = ["{}: {:g}%".format(x[0], x[1]) for x in top_queue_users]
|
||||
queue_user_list = "\n".join(queue_user)
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Queued and playing tracks:"), description=queue_user_list
|
||||
)
|
||||
861
redbot/cogs/audio/core/commands/player.py
Normal file
861
redbot/cogs/audio/core/commands/player.py
Normal file
@ -0,0 +1,861 @@
|
||||
import contextlib
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from typing import MutableMapping, 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.menus import DEFAULT_CONTROLS, close_menu, menu, next_page, prev_page
|
||||
|
||||
from ...audio_dataclasses import _PARTIALLY_SUPPORTED_MUSIC_EXT, Query
|
||||
from ...audio_logging import IS_DEBUG
|
||||
from ...errors import DatabaseError, QueryUnauthorized, SpotifyFetchError, TrackEnqueueError
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.player")
|
||||
|
||||
|
||||
class PlayerCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.command(name="play")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_play(self, ctx: commands.Context, *, query: str):
|
||||
"""Play a URL or search for a track."""
|
||||
query = Query.process_input(query, self.local_folder_current_path)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
restrict = await self.config.restrict()
|
||||
if restrict and self.match_url(str(query)):
|
||||
valid_url = self.is_url_allowed(str(query))
|
||||
if not valid_url:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("That URL is not allowed."),
|
||||
)
|
||||
elif not await self.is_query_allowed(self.config, ctx.guild, f"{query}", query_obj=query):
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.")
|
||||
)
|
||||
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.")
|
||||
return await self.send_embed_msg(ctx, title=msg, description=desc)
|
||||
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)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
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=_("Unable To Play Tracks"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if guild_data["dj_enabled"] and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You need the DJ role to queue tracks."),
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
|
||||
player.store("channel", ctx.channel.id)
|
||||
player.store("guild", ctx.guild.id)
|
||||
await self._eq_check(ctx, player)
|
||||
await self.set_player_settings(ctx)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You must be in the voice channel to use the play command."),
|
||||
)
|
||||
if not query.valid:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("No tracks found for `{query}`.").format(
|
||||
query=query.to_string_user()
|
||||
),
|
||||
)
|
||||
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
|
||||
if query.is_spotify:
|
||||
return await self._get_spotify_tracks(ctx, query)
|
||||
try:
|
||||
await self._enqueue_tracks(ctx, query)
|
||||
except QueryUnauthorized as err:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Play Tracks"), description=err.message
|
||||
)
|
||||
|
||||
@commands.command(name="bumpplay")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_bumpplay(
|
||||
self, ctx: commands.Context, play_now: Optional[bool] = False, *, query: str
|
||||
):
|
||||
"""Force play a URL or search for a track."""
|
||||
query = Query.process_input(query, self.local_folder_current_path)
|
||||
if not query.single_track:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Bump Track"),
|
||||
description=_("Only single tracks work with bump play."),
|
||||
)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
restrict = await self.config.restrict()
|
||||
if restrict and self.match_url(str(query)):
|
||||
valid_url = self.is_url_allowed(str(query))
|
||||
if not valid_url:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("That URL is not allowed."),
|
||||
)
|
||||
elif not await self.is_query_allowed(self.config, ctx.guild, f"{query}", query_obj=query):
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.")
|
||||
)
|
||||
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.")
|
||||
return await self.send_embed_msg(ctx, title=msg, description=desc)
|
||||
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)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
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=_("Unable To Play Tracks"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if guild_data["dj_enabled"] and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You need the DJ role to queue tracks."),
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
|
||||
player.store("channel", ctx.channel.id)
|
||||
player.store("guild", ctx.guild.id)
|
||||
await self._eq_check(ctx, player)
|
||||
await self.set_player_settings(ctx)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You must be in the voice channel to use the play command."),
|
||||
)
|
||||
if not query.valid:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("No tracks found for `{query}`.").format(
|
||||
query=query.to_string_user()
|
||||
),
|
||||
)
|
||||
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 query.is_spotify:
|
||||
tracks = await self._get_spotify_tracks(ctx, query)
|
||||
else:
|
||||
tracks = await self._enqueue_tracks(ctx, query, enqueue=False)
|
||||
except QueryUnauthorized as err:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Play Tracks"), description=err.message
|
||||
)
|
||||
if isinstance(tracks, discord.Message):
|
||||
return
|
||||
elif not tracks:
|
||||
self.update_player_lock(ctx, False)
|
||||
title = _("Unable To Play Tracks")
|
||||
desc = _("No tracks found for `{query}`.").format(query=query.to_string_user())
|
||||
embed = discord.Embed(title=title, description=desc)
|
||||
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)
|
||||
queue_dur = await self.track_remaining_duration(ctx)
|
||||
index = query.track_index
|
||||
seek = 0
|
||||
if query.start_time:
|
||||
seek = query.start_time
|
||||
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=_("Unable To Play Tracks"),
|
||||
description=_("This track is not allowed in this server."),
|
||||
)
|
||||
elif guild_data["maxlength"] > 0:
|
||||
if self.is_track_too_long(single_track, guild_data["maxlength"]):
|
||||
single_track.requester = ctx.author
|
||||
player.queue.insert(0, 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=_("Unable To Play Tracks"),
|
||||
description=_("Track exceeds maximum length."),
|
||||
)
|
||||
|
||||
else:
|
||||
single_track.requester = ctx.author
|
||||
single_track.extras["bumped"] = True
|
||||
player.queue.insert(0, single_track)
|
||||
player.maybe_shuffle()
|
||||
self.bot.dispatch(
|
||||
"red_audio_track_enqueue", player.channel.guild, single_track, ctx.author
|
||||
)
|
||||
description = self.get_track_description(single_track, self.local_folder_current_path)
|
||||
footer = None
|
||||
if not play_now and not guild_data["shuffle"] and queue_dur > 0:
|
||||
footer = _("{time} until track playback: #1 in queue").format(
|
||||
time=self.format_time(queue_dur)
|
||||
)
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Track Enqueued"), description=description, footer=footer
|
||||
)
|
||||
|
||||
if not player.current:
|
||||
await player.play()
|
||||
elif play_now:
|
||||
await player.skip()
|
||||
|
||||
self.update_player_lock(ctx, False)
|
||||
|
||||
@commands.command(name="genre")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
async def command_genre(self, ctx: commands.Context):
|
||||
"""Pick a Spotify playlist from a list of categories to start playing."""
|
||||
|
||||
async def _category_search_menu(
|
||||
ctx: commands.Context,
|
||||
pages: list,
|
||||
controls: MutableMapping,
|
||||
message: discord.Message,
|
||||
page: int,
|
||||
timeout: float,
|
||||
emoji: str,
|
||||
):
|
||||
if message:
|
||||
output = await self._genre_search_button_action(ctx, category_list, emoji, page)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.delete()
|
||||
return output
|
||||
|
||||
async def _playlist_search_menu(
|
||||
ctx: commands.Context,
|
||||
pages: list,
|
||||
controls: MutableMapping,
|
||||
message: discord.Message,
|
||||
page: int,
|
||||
timeout: float,
|
||||
emoji: str,
|
||||
):
|
||||
if message:
|
||||
output = await self._genre_search_button_action(
|
||||
ctx, playlists_list, emoji, page, playlist=True
|
||||
)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.delete()
|
||||
return output
|
||||
|
||||
category_search_controls = {
|
||||
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
|
||||
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
|
||||
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
|
||||
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
|
||||
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu,
|
||||
"\N{LEFTWARDS BLACK ARROW}": prev_page,
|
||||
"\N{CROSS MARK}": close_menu,
|
||||
"\N{BLACK RIGHTWARDS ARROW}": next_page,
|
||||
}
|
||||
playlist_search_controls = {
|
||||
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
|
||||
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
|
||||
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
|
||||
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
|
||||
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu,
|
||||
"\N{LEFTWARDS BLACK ARROW}": prev_page,
|
||||
"\N{CROSS MARK}": close_menu,
|
||||
"\N{BLACK RIGHTWARDS ARROW}": next_page,
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
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.")
|
||||
return await self.send_embed_msg(ctx, title=msg, description=desc)
|
||||
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)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
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=_("Unable To Play Tracks"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
if guild_data["dj_enabled"] and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You need the DJ role to queue tracks."),
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
|
||||
player.store("channel", ctx.channel.id)
|
||||
player.store("guild", ctx.guild.id)
|
||||
await self._eq_check(ctx, player)
|
||||
await self.set_player_settings(ctx)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You must be in the voice channel to use the genre command."),
|
||||
)
|
||||
try:
|
||||
category_list = await self.api_interface.spotify_api.get_categories(ctx=ctx)
|
||||
except SpotifyFetchError as error:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("No categories found"),
|
||||
description=error.message.format(prefix=ctx.prefix),
|
||||
)
|
||||
if not category_list:
|
||||
return await self.send_embed_msg(ctx, title=_("No categories found, try again later."))
|
||||
len_folder_pages = math.ceil(len(category_list) / 5)
|
||||
category_search_page_list = []
|
||||
async for page_num in AsyncIter(range(1, len_folder_pages + 1)):
|
||||
embed = await self._build_genre_search_page(
|
||||
ctx, category_list, page_num, _("Categories")
|
||||
)
|
||||
category_search_page_list.append(embed)
|
||||
cat_menu_output = await menu(ctx, category_search_page_list, category_search_controls)
|
||||
if not cat_menu_output:
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("No categories selected, try again later.")
|
||||
)
|
||||
category_name, category_pick = cat_menu_output
|
||||
playlists_list = await self.api_interface.spotify_api.get_playlist_from_category(
|
||||
category_pick, ctx=ctx
|
||||
)
|
||||
if not playlists_list:
|
||||
return await self.send_embed_msg(ctx, title=_("No categories found, try again later."))
|
||||
len_folder_pages = math.ceil(len(playlists_list) / 5)
|
||||
playlists_search_page_list = []
|
||||
async for page_num in AsyncIter(range(1, len_folder_pages + 1)):
|
||||
embed = await self._build_genre_search_page(
|
||||
ctx,
|
||||
playlists_list,
|
||||
page_num,
|
||||
_("Playlists for {friendly_name}").format(friendly_name=category_name),
|
||||
playlist=True,
|
||||
)
|
||||
playlists_search_page_list.append(embed)
|
||||
playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls)
|
||||
query = Query.process_input(playlists_pick, self.local_folder_current_path)
|
||||
if not query.valid:
|
||||
return await self.send_embed_msg(ctx, title=_("No tracks to play."))
|
||||
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
|
||||
if query.is_spotify:
|
||||
return await self._get_spotify_tracks(ctx, query)
|
||||
return await self.send_embed_msg(
|
||||
ctx, title=_("Couldn't find tracks for the selected playlist.")
|
||||
)
|
||||
|
||||
@commands.command(name="autoplay")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True)
|
||||
@commands.mod_or_permissions(manage_guild=True)
|
||||
async def command_autoplay(self, ctx: commands.Context):
|
||||
"""Starts auto play."""
|
||||
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.")
|
||||
return await self.send_embed_msg(ctx, title=msg, description=desc)
|
||||
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)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
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=_("Unable To Play Tracks"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
if guild_data["dj_enabled"] and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You need the DJ role to queue tracks."),
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
|
||||
player.store("channel", ctx.channel.id)
|
||||
player.store("guild", ctx.guild.id)
|
||||
await self._eq_check(ctx, player)
|
||||
await self.set_player_settings(ctx)
|
||||
if (
|
||||
not ctx.author.voice or ctx.author.voice.channel != player.channel
|
||||
) and not await self._can_instaskip(ctx, ctx.author):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You must be in the voice channel to use the autoplay command."),
|
||||
)
|
||||
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:
|
||||
await self.api_interface.autoplay(player, self.playlist_api)
|
||||
except DatabaseError:
|
||||
notify_channel = player.fetch("channel")
|
||||
if notify_channel:
|
||||
notify_channel = self.bot.get_channel(notify_channel)
|
||||
await self.send_embed_msg(notify_channel, title=_("Couldn't get a valid track."))
|
||||
return
|
||||
|
||||
if not guild_data["auto_play"]:
|
||||
await ctx.invoke(self.command_audioset_autoplay_toggle)
|
||||
if not guild_data["notify"] and (
|
||||
(player.current and not player.current.extras.get("autoplay")) or not player.current
|
||||
):
|
||||
await self.send_embed_msg(ctx, title=_("Auto play started."))
|
||||
elif player.current:
|
||||
await self.send_embed_msg(ctx, title=_("Adding a track to queue."))
|
||||
|
||||
@commands.command(name="search")
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
|
||||
async def command_search(self, ctx: commands.Context, *, query: str):
|
||||
"""Pick a track with a search.
|
||||
|
||||
Use `[p]search list <search term>` to queue all tracks found on YouTube.
|
||||
Use `[p]search sc<search term>` will search SoundCloud instead of YouTube.
|
||||
"""
|
||||
|
||||
async def _search_menu(
|
||||
ctx: commands.Context,
|
||||
pages: list,
|
||||
controls: MutableMapping,
|
||||
message: discord.Message,
|
||||
page: int,
|
||||
timeout: float,
|
||||
emoji: str,
|
||||
):
|
||||
if message:
|
||||
await self._search_button_action(ctx, tracks, emoji, page)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.delete()
|
||||
return None
|
||||
|
||||
search_controls = {
|
||||
"\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
|
||||
"\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
|
||||
"\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
|
||||
"\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
|
||||
"\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu,
|
||||
"\N{LEFTWARDS BLACK ARROW}": prev_page,
|
||||
"\N{CROSS MARK}": close_menu,
|
||||
"\N{BLACK RIGHTWARDS ARROW}": next_page,
|
||||
}
|
||||
|
||||
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.")
|
||||
return await self.send_embed_msg(ctx, title=msg, description=desc)
|
||||
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)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Search For Tracks"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
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=_("Unable To Search For Tracks"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Search For Tracks"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
player.store("channel", ctx.channel.id)
|
||||
player.store("guild", ctx.guild.id)
|
||||
can_skip = await self._can_instaskip(ctx, ctx.author)
|
||||
if (not ctx.author.voice or ctx.author.voice.channel != player.channel) and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Search For Tracks"),
|
||||
description=_("You must be in the voice channel to enqueue tracks."),
|
||||
)
|
||||
await self._eq_check(ctx, player)
|
||||
await self.set_player_settings(ctx)
|
||||
|
||||
before_queue_length = len(player.queue)
|
||||
|
||||
if not isinstance(query, list):
|
||||
query = Query.process_input(query, self.local_folder_current_path)
|
||||
restrict = await self.config.restrict()
|
||||
if restrict and self.match_url(str(query)):
|
||||
valid_url = self.is_url_allowed(str(query))
|
||||
if not valid_url:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("That URL is not allowed."),
|
||||
)
|
||||
if not await self.is_query_allowed(
|
||||
self.config, ctx.guild, f"{query}", query_obj=query
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("That track is not allowed."),
|
||||
)
|
||||
if query.invoked_from == "search list" or query.invoked_from == "local folder":
|
||||
if query.invoked_from == "search list" and not query.is_local:
|
||||
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
|
||||
else:
|
||||
try:
|
||||
query.search_subfolders = True
|
||||
tracks = await self.get_localtrack_folder_tracks(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."
|
||||
),
|
||||
)
|
||||
if not tracks:
|
||||
embed = discord.Embed(title=_("Nothing found."))
|
||||
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:
|
||||
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)
|
||||
queue_dur = await self.queue_duration(ctx)
|
||||
queue_total_duration = self.format_time(queue_dur)
|
||||
if guild_data["dj_enabled"] and not can_skip:
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Play Tracks"),
|
||||
description=_("You need the DJ role to queue tracks."),
|
||||
)
|
||||
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
|
||||
)
|
||||
if not player.current:
|
||||
await player.play()
|
||||
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 = ""
|
||||
songembed = discord.Embed(
|
||||
title=_("Queued {num} track(s).{maxlength_msg}").format(
|
||||
num=track_len, maxlength_msg=maxlength_msg
|
||||
)
|
||||
)
|
||||
if not guild_data["shuffle"] and queue_dur > 0:
|
||||
if query.is_local and query.is_album:
|
||||
footer = _("folder")
|
||||
else:
|
||||
footer = _("search")
|
||||
|
||||
songembed.set_footer(
|
||||
text=_(
|
||||
"{time} until start of {type} playback: starts at #{position} in queue"
|
||||
).format(
|
||||
time=queue_total_duration,
|
||||
position=before_queue_length + 1,
|
||||
type=footer,
|
||||
)
|
||||
)
|
||||
return await self.send_embed_msg(ctx, embed=songembed)
|
||||
elif query.is_local and query.single_track:
|
||||
tracks = await self.get_localtrack_folder_list(ctx, query)
|
||||
elif query.is_local and query.is_album:
|
||||
if ctx.invoked_with == "folder":
|
||||
return await self._local_play_all(ctx, query, from_search=True)
|
||||
else:
|
||||
tracks = await self.get_localtrack_folder_list(ctx, query)
|
||||
else:
|
||||
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 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:
|
||||
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:
|
||||
tracks = query
|
||||
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
|
||||
len_search_pages = math.ceil(len(tracks) / 5)
|
||||
search_page_list = []
|
||||
async for page_num in AsyncIter(range(1, len_search_pages + 1)):
|
||||
embed = await self._build_search_page(ctx, tracks, page_num)
|
||||
search_page_list.append(embed)
|
||||
|
||||
if dj_enabled and not can_skip:
|
||||
return await menu(ctx, search_page_list, DEFAULT_CONTROLS)
|
||||
|
||||
await menu(ctx, search_page_list, search_controls)
|
||||
1951
redbot/cogs/audio/core/commands/playlists.py
Normal file
1951
redbot/cogs/audio/core/commands/playlists.py
Normal file
File diff suppressed because it is too large
Load Diff
359
redbot/cogs/audio/core/commands/queue.py
Normal file
359
redbot/cogs/audio/core/commands/queue.py
Normal file
@ -0,0 +1,359 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from typing import MutableMapping, Optional, Union, Tuple
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from redbot.core import commands
|
||||
from redbot.core.utils.menus import (
|
||||
DEFAULT_CONTROLS,
|
||||
close_menu,
|
||||
menu,
|
||||
next_page,
|
||||
prev_page,
|
||||
start_adding_reactions,
|
||||
)
|
||||
from redbot.core.utils.predicates import ReactionPredicate
|
||||
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Commands.queue")
|
||||
|
||||
|
||||
class QueueCommands(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.group(name="queue", invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
@commands.bot_has_permissions(embed_links=True, add_reactions=True)
|
||||
async def command_queue(self, ctx: commands.Context, *, page: int = 1):
|
||||
"""List the songs in the queue."""
|
||||
|
||||
async def _queue_menu(
|
||||
ctx: commands.Context,
|
||||
pages: list,
|
||||
controls: MutableMapping,
|
||||
message: discord.Message,
|
||||
page: int,
|
||||
timeout: float,
|
||||
emoji: str,
|
||||
):
|
||||
if message:
|
||||
await ctx.send_help(self.command_queue)
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.delete()
|
||||
return None
|
||||
|
||||
queue_controls = {
|
||||
"\N{LEFTWARDS BLACK ARROW}": prev_page,
|
||||
"\N{CROSS MARK}": close_menu,
|
||||
"\N{BLACK RIGHTWARDS ARROW}": next_page,
|
||||
"\N{INFORMATION SOURCE}": _queue_menu,
|
||||
}
|
||||
|
||||
if not self._player_check(ctx):
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
|
||||
if player.current and not player.queue:
|
||||
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)
|
||||
song = self.get_track_description(player.current, self.local_folder_current_path) or ""
|
||||
song += _("\n Requested by: **{track.requester}**")
|
||||
song += "\n\n{arrow}`{pos}`/`{dur}`"
|
||||
song = song.format(track=player.current, arrow=arrow, pos=pos, dur=dur)
|
||||
embed = discord.Embed(title=_("Now Playing"), description=song)
|
||||
guild_data = await self.config.guild(ctx.guild).all()
|
||||
if guild_data["thumbnail"] and player.current and player.current.thumbnail:
|
||||
embed.set_thumbnail(url=player.current.thumbnail)
|
||||
|
||||
shuffle = guild_data["shuffle"]
|
||||
repeat = guild_data["repeat"]
|
||||
autoplay = guild_data["auto_play"]
|
||||
text = ""
|
||||
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)
|
||||
message = await self.send_embed_msg(ctx, embed=embed)
|
||||
dj_enabled = self._dj_status_cache.setdefault(ctx.guild.id, guild_data["dj_enabled"])
|
||||
vote_enabled = guild_data["vote_enabled"]
|
||||
if (
|
||||
(dj_enabled or vote_enabled)
|
||||
and not await self._can_instaskip(ctx, ctx.author)
|
||||
and not await self.is_requester_alone(ctx)
|
||||
):
|
||||
return
|
||||
|
||||
expected: Union[Tuple[str, ...]] = ("⏮", "⏹", "⏯", "⏭", "\N{CROSS MARK}")
|
||||
emoji = {
|
||||
"prev": "⏮",
|
||||
"stop": "⏹",
|
||||
"pause": "⏯",
|
||||
"next": "⏭",
|
||||
"close": "\N{CROSS MARK}",
|
||||
}
|
||||
if not player.queue and not autoplay:
|
||||
expected = ("⏹", "⏯", "\N{CROSS MARK}")
|
||||
if player.current:
|
||||
task: Optional[asyncio.Task] = start_adding_reactions(message, expected[:5])
|
||||
else:
|
||||
task: Optional[asyncio.Task] = None
|
||||
|
||||
try:
|
||||
(r, u) = await self.bot.wait_for(
|
||||
"reaction_add",
|
||||
check=ReactionPredicate.with_emojis(expected, message, ctx.author),
|
||||
timeout=30.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return await self._clear_react(message, emoji)
|
||||
else:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
reacts = {v: k for k, v in emoji.items()}
|
||||
react = reacts[r.emoji]
|
||||
if react == "prev":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_prev)
|
||||
elif react == "stop":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_stop)
|
||||
elif react == "pause":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_pause)
|
||||
elif react == "next":
|
||||
await self._clear_react(message, emoji)
|
||||
await ctx.invoke(self.command_skip)
|
||||
elif react == "close":
|
||||
await message.delete()
|
||||
return
|
||||
elif not player.current and not player.queue:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
|
||||
async with ctx.typing():
|
||||
limited_queue = player.queue[:500] # TODO: Improve when Toby menu's are merged
|
||||
len_queue_pages = math.ceil(len(limited_queue) / 10)
|
||||
queue_page_list = []
|
||||
async for page_num in AsyncIter(range(1, len_queue_pages + 1)):
|
||||
embed = await self._build_queue_page(ctx, limited_queue, player, page_num)
|
||||
queue_page_list.append(embed)
|
||||
if page > len_queue_pages:
|
||||
page = len_queue_pages
|
||||
return await menu(ctx, queue_page_list, queue_controls, page=(page - 1))
|
||||
|
||||
@command_queue.command(name="clear")
|
||||
async def command_queue_clear(self, ctx: commands.Context):
|
||||
"""Clears the queue."""
|
||||
try:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
except KeyError:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if not self._player_check(ctx) or not player.queue:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
if (
|
||||
dj_enabled
|
||||
and not await self._can_instaskip(ctx, ctx.author)
|
||||
and not await self.is_requester_alone(ctx)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Clear Queue"),
|
||||
description=_("You need the DJ role to clear the queue."),
|
||||
)
|
||||
player.queue.clear()
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Queue Modified"), description=_("The queue has been cleared.")
|
||||
)
|
||||
|
||||
@command_queue.command(name="clean")
|
||||
async def command_queue_clean(self, ctx: commands.Context):
|
||||
"""Removes songs from the queue if the requester is not in the voice channel."""
|
||||
try:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
except KeyError:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if not self._player_check(ctx) or not player.queue:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
if (
|
||||
dj_enabled
|
||||
and not await self._can_instaskip(ctx, ctx.author)
|
||||
and not await self.is_requester_alone(ctx)
|
||||
):
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Clean Queue"),
|
||||
description=_("You need the DJ role to clean the queue."),
|
||||
)
|
||||
clean_tracks = []
|
||||
removed_tracks = 0
|
||||
listeners = player.channel.members
|
||||
async for track in AsyncIter(player.queue):
|
||||
if track.requester in listeners:
|
||||
clean_tracks.append(track)
|
||||
else:
|
||||
removed_tracks += 1
|
||||
player.queue = clean_tracks
|
||||
if removed_tracks == 0:
|
||||
await self.send_embed_msg(ctx, title=_("Removed 0 tracks."))
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Removed Tracks From The Queue"),
|
||||
description=_(
|
||||
"Removed {removed_tracks} tracks queued by members "
|
||||
"outside of the voice channel."
|
||||
).format(removed_tracks=removed_tracks),
|
||||
)
|
||||
|
||||
@command_queue.command(name="cleanself")
|
||||
async def command_queue_cleanself(self, ctx: commands.Context):
|
||||
"""Removes all tracks you requested from the queue."""
|
||||
|
||||
try:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
except KeyError:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
if not self._player_check(ctx) or not player.queue:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
|
||||
clean_tracks = []
|
||||
removed_tracks = 0
|
||||
async for track in AsyncIter(player.queue):
|
||||
if track.requester != ctx.author:
|
||||
clean_tracks.append(track)
|
||||
else:
|
||||
removed_tracks += 1
|
||||
player.queue = clean_tracks
|
||||
if removed_tracks == 0:
|
||||
await self.send_embed_msg(ctx, title=_("Removed 0 tracks."))
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Removed Tracks From The Queue"),
|
||||
description=_(
|
||||
"Removed {removed_tracks} tracks queued by {member.display_name}."
|
||||
).format(removed_tracks=removed_tracks, member=ctx.author),
|
||||
)
|
||||
|
||||
@command_queue.command(name="search")
|
||||
async def command_queue_search(self, ctx: commands.Context, *, search_words: str):
|
||||
"""Search the queue."""
|
||||
try:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
except KeyError:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
if not self._player_check(ctx) or not player.queue:
|
||||
return await self.send_embed_msg(ctx, title=_("There's nothing in the queue."))
|
||||
|
||||
search_list = await self._build_queue_search_list(player.queue, search_words)
|
||||
if not search_list:
|
||||
return await self.send_embed_msg(ctx, title=_("No matches."))
|
||||
|
||||
len_search_pages = math.ceil(len(search_list) / 10)
|
||||
search_page_list = []
|
||||
async for page_num in AsyncIter(range(1, len_search_pages + 1)):
|
||||
embed = await self._build_queue_search_page(ctx, page_num, search_list)
|
||||
search_page_list.append(embed)
|
||||
await menu(ctx, search_page_list, DEFAULT_CONTROLS)
|
||||
|
||||
@command_queue.command(name="shuffle")
|
||||
@commands.cooldown(1, 30, commands.BucketType.guild)
|
||||
async def command_queue_shuffle(self, ctx: commands.Context):
|
||||
"""Shuffles the queue."""
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
if (
|
||||
dj_enabled
|
||||
and not await self._can_instaskip(ctx, ctx.author)
|
||||
and not await self.is_requester_alone(ctx)
|
||||
):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("You need the DJ role to shuffle the queue."),
|
||||
)
|
||||
if not self._player_check(ctx):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("There's nothing in the queue."),
|
||||
)
|
||||
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)
|
||||
):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("I don't have permission to connect to your channel."),
|
||||
)
|
||||
await lavalink.connect(ctx.author.voice.channel)
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
player.store("connect", datetime.datetime.utcnow())
|
||||
except AttributeError:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("Connect to a voice channel first."),
|
||||
)
|
||||
except IndexError:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("Connection to Lavalink has not yet been established."),
|
||||
)
|
||||
except KeyError:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("There's nothing in the queue."),
|
||||
)
|
||||
|
||||
if not self._player_check(ctx) or not player.queue:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Unable To Shuffle Queue"),
|
||||
description=_("There's nothing in the queue."),
|
||||
)
|
||||
|
||||
player.force_shuffle(0)
|
||||
return await self.send_embed_msg(ctx, title=_("Queue has been shuffled."))
|
||||
13
redbot/cogs/audio/core/events/__init__.py
Normal file
13
redbot/cogs/audio/core/events/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
import logging
|
||||
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
from .cog import AudioEvents
|
||||
from .dpy import DpyEvents
|
||||
from .lavalink import LavalinkEvents
|
||||
from .red import RedEvents
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Events")
|
||||
|
||||
|
||||
class Events(AudioEvents, DpyEvents, LavalinkEvents, RedEvents, metaclass=CompositeMetaClass):
|
||||
"""Class joining all event subclasses"""
|
||||
147
redbot/cogs/audio/core/events/cog.py
Normal file
147
redbot/cogs/audio/core/events/cog.py
Normal file
@ -0,0 +1,147 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
|
||||
from redbot.core import commands
|
||||
|
||||
from ...apis.playlist_interface import Playlist, delete_playlist, get_playlist
|
||||
from ...audio_logging import debug_exc_log
|
||||
from ...utils import PlaylistScope
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Events.audio")
|
||||
|
||||
|
||||
class AudioEvents(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.Cog.listener()
|
||||
async def on_red_audio_track_start(
|
||||
self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member
|
||||
):
|
||||
if not (track and guild):
|
||||
return
|
||||
track_identifier = track.track_identifier
|
||||
if self.playlist_api is not None:
|
||||
daily_cache = self._daily_playlist_cache.setdefault(
|
||||
guild.id, await self.config.guild(guild).daily_playlists()
|
||||
)
|
||||
global_daily_playlists = self._daily_global_playlist_cache.setdefault(
|
||||
self.bot.user.id, await self.config.daily_playlists()
|
||||
)
|
||||
today = datetime.date.today()
|
||||
midnight = datetime.datetime.combine(today, datetime.datetime.min.time())
|
||||
today_id = int(time.mktime(today.timetuple()))
|
||||
track = self.track_to_json(track)
|
||||
if daily_cache:
|
||||
name = f"Daily playlist - {today}"
|
||||
playlist: Optional[Playlist]
|
||||
try:
|
||||
playlist = await get_playlist(
|
||||
playlist_api=self.playlist_api,
|
||||
playlist_number=today_id,
|
||||
scope=PlaylistScope.GUILD.value,
|
||||
bot=self.bot,
|
||||
guild=guild,
|
||||
author=self.bot.user,
|
||||
)
|
||||
except RuntimeError:
|
||||
playlist = None
|
||||
|
||||
if playlist:
|
||||
tracks = playlist.tracks
|
||||
tracks.append(track)
|
||||
await playlist.edit({"tracks": tracks})
|
||||
else:
|
||||
playlist = Playlist(
|
||||
bot=self.bot,
|
||||
scope=PlaylistScope.GUILD.value,
|
||||
author=self.bot.user.id,
|
||||
playlist_id=today_id,
|
||||
name=name,
|
||||
playlist_url=None,
|
||||
tracks=[track],
|
||||
guild=guild,
|
||||
playlist_api=self.playlist_api,
|
||||
)
|
||||
await playlist.save()
|
||||
if global_daily_playlists:
|
||||
global_name = f"Global Daily playlist - {today}"
|
||||
try:
|
||||
playlist = await get_playlist(
|
||||
playlist_number=today_id,
|
||||
scope=PlaylistScope.GLOBAL.value,
|
||||
bot=self.bot,
|
||||
guild=guild,
|
||||
author=self.bot.user,
|
||||
playlist_api=self.playlist_api,
|
||||
)
|
||||
except RuntimeError:
|
||||
playlist = None
|
||||
if playlist:
|
||||
tracks = playlist.tracks
|
||||
tracks.append(track)
|
||||
await playlist.edit({"tracks": tracks})
|
||||
else:
|
||||
playlist = Playlist(
|
||||
bot=self.bot,
|
||||
scope=PlaylistScope.GLOBAL.value,
|
||||
author=self.bot.user.id,
|
||||
playlist_id=today_id,
|
||||
name=global_name,
|
||||
playlist_url=None,
|
||||
tracks=[track],
|
||||
guild=guild,
|
||||
playlist_api=self.playlist_api,
|
||||
)
|
||||
await playlist.save()
|
||||
too_old = midnight - datetime.timedelta(days=8)
|
||||
too_old_id = int(time.mktime(too_old.timetuple()))
|
||||
try:
|
||||
await delete_playlist(
|
||||
scope=PlaylistScope.GUILD.value,
|
||||
playlist_id=too_old_id,
|
||||
guild=guild,
|
||||
author=self.bot.user,
|
||||
playlist_api=self.playlist_api,
|
||||
bot=self.bot,
|
||||
)
|
||||
except Exception as err:
|
||||
debug_exc_log(log, err, f"Failed to delete daily playlist ID: {too_old_id}")
|
||||
try:
|
||||
await delete_playlist(
|
||||
scope=PlaylistScope.GLOBAL.value,
|
||||
playlist_id=too_old_id,
|
||||
guild=guild,
|
||||
author=self.bot.user,
|
||||
playlist_api=self.playlist_api,
|
||||
bot=self.bot,
|
||||
)
|
||||
except Exception as err:
|
||||
debug_exc_log(log, err, f"Failed to delete global daily playlist ID: {too_old_id}")
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_red_audio_queue_end(
|
||||
self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member
|
||||
):
|
||||
if not (track and guild):
|
||||
return
|
||||
if self.api_interface is not None and self.playlist_api is not None:
|
||||
await self.api_interface.local_cache_api.youtube.clean_up_old_entries()
|
||||
await asyncio.sleep(5)
|
||||
await self.playlist_api.delete_scheduled()
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_red_audio_track_end(
|
||||
self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member
|
||||
):
|
||||
if not (track and guild):
|
||||
return
|
||||
if self.api_interface is not None and self.playlist_api is not None:
|
||||
await self.api_interface.local_cache_api.youtube.clean_up_old_entries()
|
||||
await asyncio.sleep(5)
|
||||
await self.playlist_api.delete_scheduled()
|
||||
184
redbot/cogs/audio/core/events/dpy.py
Normal file
184
redbot/cogs/audio/core/events/dpy.py
Normal file
@ -0,0 +1,184 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Final, Pattern
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
from aiohttp import ClientConnectorError
|
||||
|
||||
from redbot.core import commands
|
||||
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
from ...audio_logging import debug_exc_log
|
||||
from ...errors import TrackEnqueueError
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Events.dpy")
|
||||
|
||||
RE_CONVERSION: Final[Pattern] = re.compile('Converting to "(.*)" failed for parameter "(.*)".')
|
||||
|
||||
|
||||
class DpyEvents(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def cog_before_invoke(self, ctx: commands.Context) -> None:
|
||||
await self.cog_ready_event.wait()
|
||||
# check for unsupported arch
|
||||
# Check on this needs refactoring at a later date
|
||||
# so that we have a better way to handle the tasks
|
||||
if self.command_llsetup in [ctx.command, ctx.command.root_parent]:
|
||||
pass
|
||||
|
||||
elif self.lavalink_connect_task and self.lavalink_connect_task.cancelled():
|
||||
await ctx.send(
|
||||
_(
|
||||
"You have attempted to run Audio's Lavalink server on an unsupported"
|
||||
" architecture. Only settings related commands will be available."
|
||||
)
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Not running audio command due to invalid machine architecture for Lavalink."
|
||||
)
|
||||
# with contextlib.suppress(Exception):
|
||||
# player = lavalink.get_player(ctx.guild.id)
|
||||
# notify_channel = player.fetch("channel")
|
||||
# if not notify_channel:
|
||||
# player.store("channel", ctx.channel.id)
|
||||
dj_enabled = self._dj_status_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
|
||||
)
|
||||
self._daily_playlist_cache.setdefault(
|
||||
ctx.guild.id, await self.config.guild(ctx.guild).daily_playlists()
|
||||
)
|
||||
self._daily_global_playlist_cache.setdefault(
|
||||
self.bot.user.id, await self.config.daily_playlists()
|
||||
)
|
||||
if self.local_folder_current_path is None:
|
||||
self.local_folder_current_path = Path(await self.config.localpath())
|
||||
if dj_enabled:
|
||||
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)
|
||||
if not dj_role_obj:
|
||||
await self.config.guild(ctx.guild).dj_enabled.set(None)
|
||||
self._dj_status_cache[ctx.guild.id] = None
|
||||
await self.config.guild(ctx.guild).dj_role.set(None)
|
||||
self._dj_role_cache[ctx.guild.id] = None
|
||||
await self.send_embed_msg(ctx, title=_("No DJ role found. Disabling DJ mode."))
|
||||
|
||||
async def cog_after_invoke(self, ctx: commands.Context) -> None:
|
||||
await self.maybe_run_pending_db_tasks(ctx)
|
||||
|
||||
async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
|
||||
error = getattr(error, "original", error)
|
||||
handled = False
|
||||
if isinstance(error, commands.ArgParserFailure):
|
||||
handled = True
|
||||
msg = _("`{user_input}` is not a valid value for `{command}`").format(
|
||||
user_input=error.user_input, command=error.cmd,
|
||||
)
|
||||
if error.custom_help_msg:
|
||||
msg += f"\n{error.custom_help_msg}"
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Unable To Parse Argument"), description=msg, error=True,
|
||||
)
|
||||
if error.send_cmd_help:
|
||||
await ctx.send_help()
|
||||
elif isinstance(error, commands.ConversionFailure):
|
||||
handled = True
|
||||
if error.args:
|
||||
if match := RE_CONVERSION.search(error.args[0]):
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Argument"),
|
||||
description=_(
|
||||
"The argument you gave for `{}` is not valid: I was expecting a `{}`."
|
||||
).format(match.group(2), match.group(1)),
|
||||
error=True,
|
||||
)
|
||||
else:
|
||||
await self.send_embed_msg(
|
||||
ctx, title=_("Invalid Argument"), description=error.args[0], error=True,
|
||||
)
|
||||
else:
|
||||
await ctx.send_help()
|
||||
elif isinstance(error, (IndexError, ClientConnectorError)) and any(
|
||||
e in str(error).lower() for e in ["no nodes found.", "cannot connect to host"]
|
||||
):
|
||||
handled = True
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("Invalid Environment"),
|
||||
description=_("Connection to Lavalink has been lost."),
|
||||
error=True,
|
||||
)
|
||||
debug_exc_log(log, error, "This is a handled error")
|
||||
elif isinstance(error, KeyError) and "such player for that guild" in str(error):
|
||||
handled = True
|
||||
await self.send_embed_msg(
|
||||
ctx,
|
||||
title=_("No Player Available"),
|
||||
description=_("The bot is not connected to a voice channel."),
|
||||
error=True,
|
||||
)
|
||||
debug_exc_log(log, error, "This is a handled error")
|
||||
elif isinstance(error, (TrackEnqueueError, asyncio.exceptions.TimeoutError)):
|
||||
handled = True
|
||||
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,
|
||||
)
|
||||
debug_exc_log(log, error, "This is a handled error")
|
||||
if not isinstance(
|
||||
error,
|
||||
(
|
||||
commands.CheckFailure,
|
||||
commands.UserInputError,
|
||||
commands.DisabledCommand,
|
||||
commands.CommandOnCooldown,
|
||||
commands.MaxConcurrencyReached,
|
||||
),
|
||||
):
|
||||
self.update_player_lock(ctx, False)
|
||||
if self.api_interface is not None:
|
||||
await self.api_interface.run_tasks(ctx)
|
||||
if not handled:
|
||||
await self.bot.on_command_error(ctx, error, unhandled_by_cog=True)
|
||||
|
||||
def cog_unload(self) -> None:
|
||||
if not self.cog_cleaned_up:
|
||||
self.bot.dispatch("red_audio_unload", self)
|
||||
self.session.detach()
|
||||
self.bot.loop.create_task(self._close_database())
|
||||
if self.player_automated_timer_task:
|
||||
self.player_automated_timer_task.cancel()
|
||||
|
||||
if self.lavalink_connect_task:
|
||||
self.lavalink_connect_task.cancel()
|
||||
|
||||
if self.cog_init_task:
|
||||
self.cog_init_task.cancel()
|
||||
|
||||
lavalink.unregister_event_listener(self.lavalink_event_handler)
|
||||
self.bot.loop.create_task(lavalink.close())
|
||||
if self.player_manager is not None:
|
||||
self.bot.loop.create_task(self.player_manager.shutdown())
|
||||
|
||||
self.cog_cleaned_up = True
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_voice_state_update(
|
||||
self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState
|
||||
) -> None:
|
||||
await self.cog_ready_event.wait()
|
||||
if after.channel != before.channel:
|
||||
try:
|
||||
self.skip_votes[before.channel.guild].remove(member.id)
|
||||
except (ValueError, KeyError, AttributeError):
|
||||
pass
|
||||
192
redbot/cogs/audio/core/events/lavalink.py
Normal file
192
redbot/cogs/audio/core/events/lavalink.py
Normal file
@ -0,0 +1,192 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
|
||||
from ...errors import DatabaseError
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass, _
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Events.lavalink")
|
||||
|
||||
|
||||
class LavalinkEvents(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def lavalink_event_handler(
|
||||
self, player: lavalink.Player, event_type: lavalink.LavalinkEvents, extra
|
||||
) -> None:
|
||||
current_track = player.current
|
||||
current_channel = player.channel
|
||||
guild = self.rgetattr(current_channel, "guild", None)
|
||||
guild_id = self.rgetattr(guild, "id", None)
|
||||
current_requester = self.rgetattr(current_track, "requester", None)
|
||||
current_stream = self.rgetattr(current_track, "is_stream", None)
|
||||
current_length = self.rgetattr(current_track, "length", None)
|
||||
current_thumbnail = self.rgetattr(current_track, "thumbnail", None)
|
||||
current_extras = self.rgetattr(current_track, "extras", {})
|
||||
guild_data = await self.config.guild(guild).all()
|
||||
repeat = guild_data["repeat"]
|
||||
notify = guild_data["notify"]
|
||||
disconnect = guild_data["disconnect"]
|
||||
autoplay = guild_data["auto_play"]
|
||||
description = self.get_track_description(current_track, self.local_folder_current_path)
|
||||
status = await self.config.status()
|
||||
log.debug(f"Received a new lavalink event for {guild_id}: {event_type}: {extra}")
|
||||
prev_song: lavalink.Track = player.fetch("prev_song")
|
||||
await self.maybe_reset_error_counter(player)
|
||||
|
||||
if event_type == lavalink.LavalinkEvents.TRACK_START:
|
||||
self.skip_votes[guild] = []
|
||||
playing_song = player.fetch("playing_song")
|
||||
requester = player.fetch("requester")
|
||||
player.store("prev_song", playing_song)
|
||||
player.store("prev_requester", requester)
|
||||
player.store("playing_song", current_track)
|
||||
player.store("requester", current_requester)
|
||||
self.bot.dispatch("red_audio_track_start", guild, current_track, current_requester)
|
||||
if event_type == lavalink.LavalinkEvents.TRACK_END:
|
||||
prev_requester = player.fetch("prev_requester")
|
||||
self.bot.dispatch("red_audio_track_end", guild, prev_song, prev_requester)
|
||||
if event_type == lavalink.LavalinkEvents.QUEUE_END:
|
||||
prev_requester = player.fetch("prev_requester")
|
||||
self.bot.dispatch("red_audio_queue_end", guild, prev_song, prev_requester)
|
||||
if (
|
||||
autoplay
|
||||
and not player.queue
|
||||
and player.fetch("playing_song") is not None
|
||||
and self.playlist_api is not None
|
||||
and self.api_interface is not None
|
||||
):
|
||||
try:
|
||||
await self.api_interface.autoplay(player, self.playlist_api)
|
||||
except DatabaseError:
|
||||
notify_channel = player.fetch("channel")
|
||||
if notify_channel:
|
||||
notify_channel = self.bot.get_channel(notify_channel)
|
||||
await self.send_embed_msg(
|
||||
notify_channel, title=_("Couldn't get a valid track.")
|
||||
)
|
||||
return
|
||||
if event_type == lavalink.LavalinkEvents.TRACK_START and notify:
|
||||
notify_channel = player.fetch("channel")
|
||||
if notify_channel:
|
||||
notify_channel = self.bot.get_channel(notify_channel)
|
||||
if player.fetch("notify_message") is not None:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await player.fetch("notify_message").delete()
|
||||
|
||||
if (
|
||||
autoplay
|
||||
and current_extras.get("autoplay")
|
||||
and (
|
||||
prev_song is None
|
||||
or (hasattr(prev_song, "extras") and not prev_song.extras.get("autoplay"))
|
||||
)
|
||||
):
|
||||
await self.send_embed_msg(notify_channel, title=_("Auto Play started."))
|
||||
|
||||
if not description:
|
||||
return
|
||||
if current_stream:
|
||||
dur = "LIVE"
|
||||
else:
|
||||
dur = self.format_time(current_length)
|
||||
|
||||
thumb = None
|
||||
if await self.config.guild(guild).thumbnail() and current_thumbnail:
|
||||
thumb = current_thumbnail
|
||||
|
||||
notify_message = await self.send_embed_msg(
|
||||
notify_channel,
|
||||
title=_("Now Playing"),
|
||||
description=description,
|
||||
footer=_("Track length: {length} | Requested by: {user}").format(
|
||||
length=dur, user=current_requester
|
||||
),
|
||||
thumbnail=thumb,
|
||||
)
|
||||
player.store("notify_message", notify_message)
|
||||
if event_type == lavalink.LavalinkEvents.TRACK_START and status:
|
||||
player_check = self.get_active_player_count()
|
||||
await self.update_bot_presence(*player_check)
|
||||
|
||||
if event_type == lavalink.LavalinkEvents.TRACK_END and status:
|
||||
await asyncio.sleep(1)
|
||||
if not player.is_playing:
|
||||
player_check = self.get_active_player_count()
|
||||
await self.update_bot_presence(*player_check)
|
||||
|
||||
if event_type == lavalink.LavalinkEvents.QUEUE_END:
|
||||
if not autoplay:
|
||||
notify_channel = player.fetch("channel")
|
||||
if notify_channel and notify:
|
||||
notify_channel = self.bot.get_channel(notify_channel)
|
||||
await self.send_embed_msg(notify_channel, title=_("Queue ended."))
|
||||
if disconnect:
|
||||
self.bot.dispatch("red_audio_audio_disconnect", guild)
|
||||
await player.disconnect()
|
||||
if status:
|
||||
player_check = self.get_active_player_count()
|
||||
await self.update_bot_presence(*player_check)
|
||||
|
||||
if event_type in [
|
||||
lavalink.LavalinkEvents.TRACK_EXCEPTION,
|
||||
lavalink.LavalinkEvents.TRACK_STUCK,
|
||||
]:
|
||||
message_channel = player.fetch("channel")
|
||||
while True:
|
||||
if current_track in player.queue:
|
||||
player.queue.remove(current_track)
|
||||
else:
|
||||
break
|
||||
if repeat:
|
||||
player.current = None
|
||||
if not guild_id:
|
||||
return
|
||||
self._error_counter.setdefault(guild_id, 0)
|
||||
if guild_id not in self._error_counter:
|
||||
self._error_counter[guild_id] = 0
|
||||
early_exit = await self.increase_error_counter(player)
|
||||
if early_exit:
|
||||
self._disconnected_players[guild_id] = True
|
||||
self.play_lock[guild_id] = False
|
||||
eq = player.fetch("eq")
|
||||
player.queue = []
|
||||
player.store("playing_song", None)
|
||||
if eq:
|
||||
await self.config.custom("EQUALIZER", guild_id).eq_bands.set(eq.bands)
|
||||
await player.stop()
|
||||
await player.disconnect()
|
||||
self.bot.dispatch("red_audio_audio_disconnect", guild)
|
||||
if message_channel:
|
||||
message_channel = self.bot.get_channel(message_channel)
|
||||
if early_exit:
|
||||
embed = discord.Embed(
|
||||
colour=await self.bot.get_embed_color(message_channel),
|
||||
title=_("Multiple Errors Detected"),
|
||||
description=_(
|
||||
"Closing the audio player "
|
||||
"due to multiple errors being detected. "
|
||||
"If this persists, please inform the bot owner "
|
||||
"as the Audio cog may be temporally unavailable."
|
||||
),
|
||||
)
|
||||
await message_channel.send(embed=embed)
|
||||
return
|
||||
else:
|
||||
description = description or ""
|
||||
if event_type == lavalink.LavalinkEvents.TRACK_STUCK:
|
||||
embed = discord.Embed(
|
||||
colour=await self.bot.get_embed_color(message_channel),
|
||||
title=_("Track Stuck"),
|
||||
description="{}".format(description),
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title=_("Track Error"),
|
||||
colour=await self.bot.get_embed_color(message_channel),
|
||||
description="{}\n{}".format(extra.replace("\n", ""), description),
|
||||
)
|
||||
await message_channel.send(embed=embed)
|
||||
await player.skip()
|
||||
21
redbot/cogs/audio/core/events/red.py
Normal file
21
redbot/cogs/audio/core/events/red.py
Normal file
@ -0,0 +1,21 @@
|
||||
import logging
|
||||
from typing import Mapping
|
||||
|
||||
from redbot.core import commands
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Events.red")
|
||||
|
||||
|
||||
class RedEvents(MixinMeta, metaclass=CompositeMetaClass):
|
||||
@commands.Cog.listener()
|
||||
async def on_red_api_tokens_update(
|
||||
self, service_name: str, api_tokens: Mapping[str, str]
|
||||
) -> None:
|
||||
if service_name == "youtube":
|
||||
self.api_interface.youtube_api.update_token(api_tokens)
|
||||
elif service_name == "spotify":
|
||||
self.api_interface.spotify_api.update_token(api_tokens)
|
||||
elif service_name == "audiodb":
|
||||
self.api_interface.global_cache_api.update_token(api_tokens)
|
||||
12
redbot/cogs/audio/core/tasks/__init__.py
Normal file
12
redbot/cogs/audio/core/tasks/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
import logging
|
||||
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
from .lavalink import LavalinkTasks
|
||||
from .player import PlayerTasks
|
||||
from .startup import StartUpTasks
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Tasks")
|
||||
|
||||
|
||||
class Tasks(LavalinkTasks, PlayerTasks, StartUpTasks, metaclass=CompositeMetaClass):
|
||||
"""Class joining all task subclasses"""
|
||||
113
redbot/cogs/audio/core/tasks/lavalink.py
Normal file
113
redbot/cogs/audio/core/tasks/lavalink.py
Normal file
@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import lavalink
|
||||
|
||||
from ...errors import LavalinkDownloadFailed
|
||||
from ...manager import ServerManager
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Tasks.lavalink")
|
||||
|
||||
|
||||
class LavalinkTasks(MixinMeta, metaclass=CompositeMetaClass):
|
||||
def lavalink_restart_connect(self) -> None:
|
||||
if self.lavalink_connect_task:
|
||||
self.lavalink_connect_task.cancel()
|
||||
|
||||
self.lavalink_connect_task = self.bot.loop.create_task(self.lavalink_attempt_connect())
|
||||
|
||||
async def lavalink_attempt_connect(self, timeout: int = 50) -> None:
|
||||
self.lavalink_connection_aborted = False
|
||||
max_retries = 5
|
||||
retry_count = 0
|
||||
while retry_count < max_retries:
|
||||
external = await self.config.use_external_lavalink()
|
||||
if external is False:
|
||||
settings = self._default_lavalink_settings
|
||||
host = settings["host"]
|
||||
password = settings["password"]
|
||||
rest_port = settings["rest_port"]
|
||||
ws_port = settings["ws_port"]
|
||||
if self.player_manager is not None:
|
||||
await self.player_manager.shutdown()
|
||||
self.player_manager = ServerManager()
|
||||
try:
|
||||
await self.player_manager.start()
|
||||
except LavalinkDownloadFailed as exc:
|
||||
await asyncio.sleep(1)
|
||||
if exc.should_retry:
|
||||
log.exception(
|
||||
"Exception whilst starting internal Lavalink server, retrying...",
|
||||
exc_info=exc,
|
||||
)
|
||||
retry_count += 1
|
||||
continue
|
||||
else:
|
||||
log.exception(
|
||||
"Fatal exception whilst starting internal Lavalink server, "
|
||||
"aborting...",
|
||||
exc_info=exc,
|
||||
)
|
||||
self.lavalink_connection_aborted = True
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
log.exception("Invalid machine architecture, cannot run Lavalink.")
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"Unhandled exception whilst starting internal Lavalink server, "
|
||||
"aborting...",
|
||||
exc_info=exc,
|
||||
)
|
||||
self.lavalink_connection_aborted = True
|
||||
raise
|
||||
else:
|
||||
break
|
||||
else:
|
||||
config_data = await self.config.all()
|
||||
host = config_data["host"]
|
||||
password = config_data["password"]
|
||||
rest_port = config_data["rest_port"]
|
||||
ws_port = config_data["ws_port"]
|
||||
break
|
||||
else:
|
||||
log.critical(
|
||||
"Setting up the Lavalink server failed after multiple attempts. "
|
||||
"See above tracebacks for details."
|
||||
)
|
||||
self.lavalink_connection_aborted = True
|
||||
return
|
||||
|
||||
retry_count = 0
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
await lavalink.initialize(
|
||||
bot=self.bot,
|
||||
host=host,
|
||||
password=password,
|
||||
rest_port=rest_port,
|
||||
ws_port=ws_port,
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Connecting to Lavalink server timed out, retrying...")
|
||||
if external is False and self.player_manager is not None:
|
||||
await self.player_manager.shutdown()
|
||||
retry_count += 1
|
||||
await asyncio.sleep(1) # prevent busylooping
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"Unhandled exception whilst connecting to Lavalink, aborting...", exc_info=exc
|
||||
)
|
||||
self.lavalink_connection_aborted = True
|
||||
raise
|
||||
else:
|
||||
break
|
||||
else:
|
||||
self.lavalink_connection_aborted = True
|
||||
log.critical(
|
||||
"Connecting to the Lavalink server failed after multiple attempts. "
|
||||
"See above tracebacks for details."
|
||||
)
|
||||
70
redbot/cogs/audio/core/tasks/player.py
Normal file
70
redbot/cogs/audio/core/tasks/player.py
Normal file
@ -0,0 +1,70 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import lavalink
|
||||
from redbot.core.utils import AsyncIter
|
||||
|
||||
from ...audio_logging import debug_exc_log
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Tasks.player")
|
||||
|
||||
|
||||
class PlayerTasks(MixinMeta, metaclass=CompositeMetaClass):
|
||||
async def player_automated_timer(self) -> None:
|
||||
stop_times: Dict = {}
|
||||
pause_times: Dict = {}
|
||||
while True:
|
||||
async for p in AsyncIter(lavalink.all_players()):
|
||||
server = p.channel.guild
|
||||
|
||||
if [self.bot.user] == p.channel.members:
|
||||
stop_times.setdefault(server.id, time.time())
|
||||
pause_times.setdefault(server.id, time.time())
|
||||
else:
|
||||
stop_times.pop(server.id, None)
|
||||
if p.paused and server.id in pause_times:
|
||||
try:
|
||||
await p.pause(False)
|
||||
except Exception as err:
|
||||
debug_exc_log(
|
||||
log,
|
||||
err,
|
||||
f"Exception raised in Audio's unpausing player for {server.id}.",
|
||||
)
|
||||
pause_times.pop(server.id, None)
|
||||
servers = stop_times.copy()
|
||||
servers.update(pause_times)
|
||||
async for sid in AsyncIter(servers, steps=5):
|
||||
server_obj = self.bot.get_guild(sid)
|
||||
if sid in stop_times and await self.config.guild(server_obj).emptydc_enabled():
|
||||
emptydc_timer = await self.config.guild(server_obj).emptydc_timer()
|
||||
if (time.time() - stop_times[sid]) >= emptydc_timer:
|
||||
stop_times.pop(sid)
|
||||
try:
|
||||
player = lavalink.get_player(sid)
|
||||
await player.stop()
|
||||
await player.disconnect()
|
||||
except Exception as err:
|
||||
if "No such player for that guild" in str(err):
|
||||
stop_times.pop(sid, None)
|
||||
debug_exc_log(
|
||||
log, err, f"Exception raised in Audio's emptydc_timer for {sid}."
|
||||
)
|
||||
elif (
|
||||
sid in pause_times and await self.config.guild(server_obj).emptypause_enabled()
|
||||
):
|
||||
emptypause_timer = await self.config.guild(server_obj).emptypause_timer()
|
||||
if (time.time() - pause_times.get(sid, 0)) >= emptypause_timer:
|
||||
try:
|
||||
await lavalink.get_player(sid).pause()
|
||||
except Exception as err:
|
||||
if "No such player for that guild" in str(err):
|
||||
pause_times.pop(sid, None)
|
||||
debug_exc_log(
|
||||
log, err, f"Exception raised in Audio's pausing for {sid}."
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
49
redbot/cogs/audio/core/tasks/startup.py
Normal file
49
redbot/cogs/audio/core/tasks/startup.py
Normal file
@ -0,0 +1,49 @@
|
||||
import logging
|
||||
|
||||
import lavalink
|
||||
|
||||
from redbot.core.data_manager import cog_data_path
|
||||
from redbot.core.utils.dbtools import APSWConnectionWrapper
|
||||
|
||||
from ...apis.interface import AudioAPIInterface
|
||||
from ...apis.playlist_wrapper import PlaylistWrapper
|
||||
from ..abc import MixinMeta
|
||||
from ..cog_utils import _SCHEMA_VERSION, CompositeMetaClass
|
||||
|
||||
log = logging.getLogger("red.cogs.Audio.cog.Tasks.startup")
|
||||
|
||||
|
||||
class StartUpTasks(MixinMeta, metaclass=CompositeMetaClass):
|
||||
def start_up_task(self):
|
||||
# There has to be a task since this requires the bot to be ready
|
||||
# If it waits for ready in startup, we cause a deadlock during initial load
|
||||
# as initial load happens before the bot can ever be ready.
|
||||
self.cog_init_task = self.bot.loop.create_task(self.initialize())
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self.bot.wait_until_red_ready()
|
||||
# Unlike most cases, we want the cache to exit before migration.
|
||||
try:
|
||||
self.db_conn = APSWConnectionWrapper(
|
||||
str(cog_data_path(self.bot.get_cog("Audio")) / "Audio.db")
|
||||
)
|
||||
self.api_interface = AudioAPIInterface(
|
||||
self.bot, self.config, self.session, self.db_conn, self.bot.get_cog("Audio")
|
||||
)
|
||||
self.playlist_api = PlaylistWrapper(self.bot, self.config, self.db_conn)
|
||||
await self.playlist_api.init()
|
||||
await self.api_interface.initialize()
|
||||
await self.data_schema_migration(
|
||||
from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION
|
||||
)
|
||||
await self.playlist_api.delete_scheduled()
|
||||
self.lavalink_restart_connect()
|
||||
self.player_automated_timer_task = self.bot.loop.create_task(
|
||||
self.player_automated_timer()
|
||||
)
|
||||
lavalink.register_event_listener(self.lavalink_event_handler)
|
||||
except Exception as err:
|
||||
log.exception("Audio failed to start up, please report this issue.", exc_info=err)
|
||||
raise err
|
||||
|
||||
self.cog_ready_event.set()
|
||||
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
|
||||
@ -1,372 +0,0 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Union, MutableMapping, Mapping
|
||||
|
||||
import apsw
|
||||
|
||||
from redbot.core import Config
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.data_manager import cog_data_path
|
||||
|
||||
from .errors import InvalidTableError
|
||||
from .sql_statements import *
|
||||
from .utils import PlaylistScope
|
||||
|
||||
log = logging.getLogger("red.audio.database")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
database_connection: apsw.Connection
|
||||
_bot: Red
|
||||
_config: Config
|
||||
else:
|
||||
_config = None
|
||||
_bot = None
|
||||
database_connection = None
|
||||
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
SQLError = apsw.ExecutionCompleteError
|
||||
|
||||
|
||||
_PARSER: Mapping = {
|
||||
"youtube": {
|
||||
"insert": YOUTUBE_UPSERT,
|
||||
"youtube_url": {"query": YOUTUBE_QUERY},
|
||||
"update": YOUTUBE_UPDATE,
|
||||
},
|
||||
"spotify": {
|
||||
"insert": SPOTIFY_UPSERT,
|
||||
"track_info": {"query": SPOTIFY_QUERY},
|
||||
"update": SPOTIFY_UPDATE,
|
||||
},
|
||||
"lavalink": {
|
||||
"insert": LAVALINK_UPSERT,
|
||||
"data": {"query": LAVALINK_QUERY, "played": LAVALINK_QUERY_LAST_FETCHED_RANDOM},
|
||||
"update": LAVALINK_UPDATE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _pass_config_to_databases(config: Config, bot: Red):
|
||||
global _config, _bot, database_connection
|
||||
if _config is None:
|
||||
_config = config
|
||||
if _bot is None:
|
||||
_bot = bot
|
||||
if database_connection is None:
|
||||
database_connection = apsw.Connection(
|
||||
str(cog_data_path(_bot.get_cog("Audio")) / "Audio.db")
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistFetchResult:
|
||||
playlist_id: int
|
||||
playlist_name: str
|
||||
scope_id: int
|
||||
author_id: int
|
||||
playlist_url: Optional[str] = None
|
||||
tracks: List[MutableMapping] = field(default_factory=lambda: [])
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.tracks, str):
|
||||
self.tracks = json.loads(self.tracks)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheFetchResult:
|
||||
query: Optional[Union[str, MutableMapping]]
|
||||
last_updated: int
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.last_updated, int):
|
||||
self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated)
|
||||
if isinstance(self.query, str) and all(
|
||||
k in self.query for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]
|
||||
):
|
||||
self.query = json.loads(self.query)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheLastFetchResult:
|
||||
tracks: List[MutableMapping] = field(default_factory=lambda: [])
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.tracks, str):
|
||||
self.tracks = json.loads(self.tracks)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheGetAllLavalink:
|
||||
query: str
|
||||
data: List[MutableMapping] = field(default_factory=lambda: [])
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.data, str):
|
||||
self.data = json.loads(self.data)
|
||||
|
||||
|
||||
class CacheInterface:
|
||||
def __init__(self):
|
||||
self.database = database_connection.cursor()
|
||||
|
||||
@staticmethod
|
||||
def close():
|
||||
with contextlib.suppress(Exception):
|
||||
database_connection.close()
|
||||
|
||||
async def init(self):
|
||||
self.database.execute(PRAGMA_SET_temp_store)
|
||||
self.database.execute(PRAGMA_SET_journal_mode)
|
||||
self.database.execute(PRAGMA_SET_read_uncommitted)
|
||||
self.maybe_migrate()
|
||||
|
||||
self.database.execute(LAVALINK_CREATE_TABLE)
|
||||
self.database.execute(LAVALINK_CREATE_INDEX)
|
||||
self.database.execute(YOUTUBE_CREATE_TABLE)
|
||||
self.database.execute(YOUTUBE_CREATE_INDEX)
|
||||
self.database.execute(SPOTIFY_CREATE_TABLE)
|
||||
self.database.execute(SPOTIFY_CREATE_INDEX)
|
||||
|
||||
await self.clean_up_old_entries()
|
||||
|
||||
async def clean_up_old_entries(self):
|
||||
max_age = await _config.cache_age()
|
||||
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
|
||||
maxage_int = int(time.mktime(maxage.timetuple()))
|
||||
values = {"maxage": maxage_int}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.execute, LAVALINK_DELETE_OLD_ENTRIES, values)
|
||||
executor.submit(self.database.execute, YOUTUBE_DELETE_OLD_ENTRIES, values)
|
||||
executor.submit(self.database.execute, SPOTIFY_DELETE_OLD_ENTRIES, values)
|
||||
|
||||
def maybe_migrate(self):
|
||||
current_version = self.database.execute(PRAGMA_FETCH_user_version).fetchone()
|
||||
if isinstance(current_version, tuple):
|
||||
current_version = current_version[0]
|
||||
if current_version == SCHEMA_VERSION:
|
||||
return
|
||||
self.database.execute(PRAGMA_SET_user_version, {"version": SCHEMA_VERSION})
|
||||
|
||||
async def insert(self, table: str, values: List[MutableMapping]):
|
||||
try:
|
||||
query = _PARSER.get(table, {}).get("insert")
|
||||
if query is None:
|
||||
raise InvalidTableError(f"{table} is not a valid table in the database.")
|
||||
self.database.execute("BEGIN;")
|
||||
self.database.executemany(query, values)
|
||||
self.database.execute("COMMIT;")
|
||||
except Exception as err:
|
||||
log.debug("Error during audio db insert", exc_info=err)
|
||||
|
||||
async def update(self, table: str, values: Dict[str, Union[str, int]]):
|
||||
try:
|
||||
table = _PARSER.get(table, {})
|
||||
sql_query = table.get("update")
|
||||
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
values["last_fetched"] = time_now
|
||||
if not table:
|
||||
raise InvalidTableError(f"{table} is not a valid table in the database.")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.database.execute, sql_query, values)
|
||||
except Exception as err:
|
||||
log.debug("Error during audio db update", exc_info=err)
|
||||
|
||||
async def fetch_one(
|
||||
self, table: str, query: str, values: Dict[str, Union[str, int]]
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
table = _PARSER.get(table, {})
|
||||
sql_query = table.get(query, {}).get("query")
|
||||
if not table:
|
||||
raise InvalidTableError(f"{table} is not a valid table in the database.")
|
||||
max_age = await _config.cache_age()
|
||||
maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age)
|
||||
maxage_int = int(time.mktime(maxage.timetuple()))
|
||||
values.update({"maxage": maxage_int})
|
||||
output = self.database.execute(sql_query, values).fetchone() or (None, 0)
|
||||
result = CacheFetchResult(*output)
|
||||
return result.query, False
|
||||
|
||||
async def fetch_all(
|
||||
self, table: str, query: str, values: Dict[str, Union[str, int]]
|
||||
) -> List[CacheLastFetchResult]:
|
||||
table = _PARSER.get(table, {})
|
||||
sql_query = table.get(query, {}).get("played")
|
||||
if not table:
|
||||
raise InvalidTableError(f"{table} is not a valid table in the database.")
|
||||
|
||||
output = []
|
||||
for index, row in enumerate(self.database.execute(sql_query, values), start=1):
|
||||
if index % 50 == 0:
|
||||
await asyncio.sleep(0.01)
|
||||
output.append(CacheLastFetchResult(*row))
|
||||
return output
|
||||
|
||||
async def fetch_random(
|
||||
self, table: str, query: str, values: Dict[str, Union[str, int]]
|
||||
) -> CacheLastFetchResult:
|
||||
table = _PARSER.get(table, {})
|
||||
sql_query = table.get(query, {}).get("played")
|
||||
if not table:
|
||||
raise InvalidTableError(f"{table} is not a valid table in the database.")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
for future in concurrent.futures.as_completed(
|
||||
[executor.submit(self.database.execute, sql_query, values)]
|
||||
):
|
||||
try:
|
||||
row = future.result()
|
||||
row = row.fetchone()
|
||||
except Exception as exc:
|
||||
log.debug(f"Failed to completed random fetch from database", exc_info=exc)
|
||||
return CacheLastFetchResult(*row)
|
||||
|
||||
|
||||
class PlaylistInterface:
|
||||
def __init__(self):
|
||||
self.cursor = database_connection.cursor()
|
||||
self.cursor.execute(PRAGMA_SET_temp_store)
|
||||
self.cursor.execute(PRAGMA_SET_journal_mode)
|
||||
self.cursor.execute(PRAGMA_SET_read_uncommitted)
|
||||
self.cursor.execute(PLAYLIST_CREATE_TABLE)
|
||||
self.cursor.execute(PLAYLIST_CREATE_INDEX)
|
||||
|
||||
@staticmethod
|
||||
def close():
|
||||
with contextlib.suppress(Exception):
|
||||
database_connection.close()
|
||||
|
||||
@staticmethod
|
||||
def get_scope_type(scope: str) -> int:
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
table = 1
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
table = 3
|
||||
else:
|
||||
table = 2
|
||||
return table
|
||||
|
||||
def fetch(self, scope: str, playlist_id: int, scope_id: int) -> PlaylistFetchResult:
|
||||
scope_type = self.get_scope_type(scope)
|
||||
row = (
|
||||
self.cursor.execute(
|
||||
PLAYLIST_FETCH,
|
||||
({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}),
|
||||
).fetchone()
|
||||
or []
|
||||
)
|
||||
|
||||
return PlaylistFetchResult(*row) if row else None
|
||||
|
||||
async def fetch_all(
|
||||
self, scope: str, scope_id: int, author_id=None
|
||||
) -> List[PlaylistFetchResult]:
|
||||
scope_type = self.get_scope_type(scope)
|
||||
if author_id is not None:
|
||||
output = []
|
||||
for index, row in enumerate(
|
||||
self.cursor.execute(
|
||||
PLAYLIST_FETCH_ALL_WITH_FILTER,
|
||||
({"scope_type": scope_type, "scope_id": scope_id, "author_id": author_id}),
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
if index % 50 == 0:
|
||||
await asyncio.sleep(0.01)
|
||||
output.append(row)
|
||||
else:
|
||||
output = []
|
||||
for index, row in enumerate(
|
||||
self.cursor.execute(
|
||||
PLAYLIST_FETCH_ALL, ({"scope_type": scope_type, "scope_id": scope_id})
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
if index % 50 == 0:
|
||||
await asyncio.sleep(0.01)
|
||||
output.append(row)
|
||||
return [PlaylistFetchResult(*row) for row in output] if output else []
|
||||
|
||||
async def fetch_all_converter(
|
||||
self, scope: str, playlist_name, playlist_id
|
||||
) -> List[PlaylistFetchResult]:
|
||||
scope_type = self.get_scope_type(scope)
|
||||
try:
|
||||
playlist_id = int(playlist_id)
|
||||
except Exception:
|
||||
playlist_id = -1
|
||||
|
||||
output = []
|
||||
for index, row in enumerate(
|
||||
self.cursor.execute(
|
||||
PLAYLIST_FETCH_ALL_CONVERTER,
|
||||
(
|
||||
{
|
||||
"scope_type": scope_type,
|
||||
"playlist_name": playlist_name,
|
||||
"playlist_id": playlist_id,
|
||||
}
|
||||
),
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
if index % 50 == 0:
|
||||
await asyncio.sleep(0.01)
|
||||
output.append(row)
|
||||
return [PlaylistFetchResult(*row) for row in output] if output else []
|
||||
|
||||
def delete(self, scope: str, playlist_id: int, scope_id: int):
|
||||
scope_type = self.get_scope_type(scope)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
self.cursor.execute,
|
||||
PLAYLIST_DELETE,
|
||||
({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}),
|
||||
)
|
||||
|
||||
def delete_scheduled(self):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(self.cursor.execute, PLAYLIST_DELETE_SCHEDULED)
|
||||
|
||||
def drop(self, scope: str):
|
||||
scope_type = self.get_scope_type(scope)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
self.cursor.execute, PLAYLIST_DELETE_SCOPE, ({"scope_type": scope_type})
|
||||
)
|
||||
|
||||
def create_table(self, scope: str):
|
||||
scope_type = self.get_scope_type(scope)
|
||||
return self.cursor.execute(PLAYLIST_CREATE_TABLE, ({"scope_type": scope_type}))
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
scope: str,
|
||||
playlist_id: int,
|
||||
playlist_name: str,
|
||||
scope_id: int,
|
||||
author_id: int,
|
||||
playlist_url: Optional[str],
|
||||
tracks: List[MutableMapping],
|
||||
):
|
||||
scope_type = self.get_scope_type(scope)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
self.cursor.execute,
|
||||
PLAYLIST_UPSERT,
|
||||
{
|
||||
"scope_type": str(scope_type),
|
||||
"playlist_id": int(playlist_id),
|
||||
"playlist_name": str(playlist_name),
|
||||
"scope_id": int(scope_id),
|
||||
"author_id": int(author_id),
|
||||
"playlist_url": playlist_url,
|
||||
"tracks": json.dumps(tracks),
|
||||
},
|
||||
)
|
||||
@ -1,14 +1,15 @@
|
||||
# The equalizer class and some audio eq functions are derived from
|
||||
# 180093157554388993's work, with his permission
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Equalizer:
|
||||
def __init__(self):
|
||||
self._band_count = 15
|
||||
self.bands = [0.0 for _loop_counter in range(self._band_count)]
|
||||
self.band_count: Final[int] = 15
|
||||
self.bands = [0.0 for _loop_counter in range(self.band_count)]
|
||||
|
||||
def set_gain(self, band: int, gain: float):
|
||||
if band < 0 or band >= self._band_count:
|
||||
if band < 0 or band >= self.band_count:
|
||||
raise IndexError(f"Band {band} does not exist!")
|
||||
|
||||
gain = min(max(gain, -0.25), 1.0)
|
||||
@ -16,13 +17,13 @@ class Equalizer:
|
||||
self.bands[band] = gain
|
||||
|
||||
def get_gain(self, band: int):
|
||||
if band < 0 or band >= self._band_count:
|
||||
if band < 0 or band >= self.band_count:
|
||||
raise IndexError(f"Band {band} does not exist!")
|
||||
return self.bands[band]
|
||||
|
||||
def visualise(self):
|
||||
block = ""
|
||||
bands = [str(band + 1).zfill(2) for band in range(self._band_count)]
|
||||
bands = [str(band + 1).zfill(2) for band in range(self.band_count)]
|
||||
bottom = (" " * 8) + " ".join(bands)
|
||||
gains = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0, -0.1, -0.2, -0.25]
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import ClassVar, List, Optional, Tuple
|
||||
from typing import ClassVar, Final, List, Optional, Tuple, Pattern
|
||||
|
||||
import aiohttp
|
||||
from tqdm import tqdm
|
||||
@ -19,24 +19,25 @@ from redbot.core import data_manager
|
||||
from .errors import LavalinkDownloadFailed
|
||||
|
||||
log = logging.getLogger("red.audio.manager")
|
||||
JAR_VERSION = "3.3.1"
|
||||
JAR_BUILD = 987
|
||||
LAVALINK_DOWNLOAD_URL = (
|
||||
f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/"
|
||||
JAR_VERSION: Final[str] = "3.3.1"
|
||||
JAR_BUILD: Final[int] = 987
|
||||
LAVALINK_DOWNLOAD_URL: Final[str] = (
|
||||
"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/"
|
||||
f"{JAR_VERSION}_{JAR_BUILD}/"
|
||||
"Lavalink.jar"
|
||||
)
|
||||
LAVALINK_DOWNLOAD_DIR = data_manager.cog_data_path(raw_name="Audio")
|
||||
LAVALINK_JAR_FILE = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar"
|
||||
BUNDLED_APP_YML = pathlib.Path(__file__).parent / "data" / "application.yml"
|
||||
LAVALINK_APP_YML = LAVALINK_DOWNLOAD_DIR / "application.yml"
|
||||
LAVALINK_DOWNLOAD_DIR: Final[pathlib.Path] = data_manager.cog_data_path(raw_name="Audio")
|
||||
LAVALINK_JAR_FILE: Final[pathlib.Path] = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar"
|
||||
BUNDLED_APP_YML: Final[pathlib.Path] = pathlib.Path(__file__).parent / "data" / "application.yml"
|
||||
LAVALINK_APP_YML: Final[pathlib.Path] = LAVALINK_DOWNLOAD_DIR / "application.yml"
|
||||
|
||||
_RE_READY_LINE = re.compile(rb"Started Launcher in \S+ seconds")
|
||||
_FAILED_TO_START = re.compile(rb"Web server failed to start. (.*)")
|
||||
_RE_BUILD_LINE = re.compile(rb"Build:\s+(?P<build>\d+)")
|
||||
_RE_JAVA_VERSION_LINE = re.compile(
|
||||
_RE_READY_LINE: Final[Pattern] = re.compile(rb"Started Launcher in \S+ seconds")
|
||||
_FAILED_TO_START: Final[Pattern] = re.compile(rb"Web server failed to start. (.*)")
|
||||
_RE_BUILD_LINE: Final[Pattern] = re.compile(rb"Build:\s+(?P<build>\d+)")
|
||||
_RE_JAVA_VERSION_LINE: Final[Pattern] = re.compile(
|
||||
r'version "(?P<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"'
|
||||
)
|
||||
_RE_JAVA_SHORT_VERSION = re.compile(r'version "(?P<major>\d+)"')
|
||||
_RE_JAVA_SHORT_VERSION: Final[Pattern] = re.compile(r'version "(?P<major>\d+)"')
|
||||
|
||||
|
||||
class ServerManager:
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from typing import Final
|
||||
|
||||
# TODO: https://github.com/Cog-Creators/Red-DiscordBot/pull/3195#issuecomment-567821701
|
||||
# Thanks a lot Sinbad!
|
||||
|
||||
@ -26,15 +28,19 @@ __all__ = [
|
||||
"YOUTUBE_UPSERT",
|
||||
"YOUTUBE_UPDATE",
|
||||
"YOUTUBE_QUERY",
|
||||
"YOUTUBE_QUERY_ALL",
|
||||
"YOUTUBE_DELETE_OLD_ENTRIES",
|
||||
"YOUTUBE_QUERY_LAST_FETCHED_RANDOM",
|
||||
# Spotify table statements
|
||||
"SPOTIFY_DROP_TABLE",
|
||||
"SPOTIFY_CREATE_INDEX",
|
||||
"SPOTIFY_CREATE_TABLE",
|
||||
"SPOTIFY_UPSERT",
|
||||
"SPOTIFY_QUERY",
|
||||
"SPOTIFY_QUERY_ALL",
|
||||
"SPOTIFY_UPDATE",
|
||||
"SPOTIFY_DELETE_OLD_ENTRIES",
|
||||
"SPOTIFY_QUERY_LAST_FETCHED_RANDOM",
|
||||
# Lavalink table statements
|
||||
"LAVALINK_DROP_TABLE",
|
||||
"LAVALINK_CREATE_TABLE",
|
||||
@ -42,30 +48,44 @@ __all__ = [
|
||||
"LAVALINK_UPSERT",
|
||||
"LAVALINK_UPDATE",
|
||||
"LAVALINK_QUERY",
|
||||
"LAVALINK_QUERY_ALL",
|
||||
"LAVALINK_QUERY_LAST_FETCHED_RANDOM",
|
||||
"LAVALINK_DELETE_OLD_ENTRIES",
|
||||
"LAVALINK_FETCH_ALL_ENTRIES_GLOBAL",
|
||||
]
|
||||
|
||||
# PRAGMA Statements
|
||||
PRAGMA_SET_temp_store = """
|
||||
|
||||
PRAGMA_SET_temp_store: Final[
|
||||
str
|
||||
] = """
|
||||
PRAGMA temp_store = 2;
|
||||
"""
|
||||
PRAGMA_SET_journal_mode = """
|
||||
PRAGMA_SET_journal_mode: Final[
|
||||
str
|
||||
] = """
|
||||
PRAGMA journal_mode = wal;
|
||||
"""
|
||||
PRAGMA_SET_read_uncommitted = """
|
||||
PRAGMA_SET_read_uncommitted: Final[
|
||||
str
|
||||
] = """
|
||||
PRAGMA read_uncommitted = 1;
|
||||
"""
|
||||
PRAGMA_FETCH_user_version = """
|
||||
PRAGMA_FETCH_user_version: Final[
|
||||
str
|
||||
] = """
|
||||
pragma user_version;
|
||||
"""
|
||||
PRAGMA_SET_user_version = """
|
||||
PRAGMA_SET_user_version: Final[
|
||||
str
|
||||
] = """
|
||||
pragma user_version=3;
|
||||
"""
|
||||
|
||||
# Playlist table statements
|
||||
PLAYLIST_CREATE_TABLE = """
|
||||
PLAYLIST_CREATE_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
scope_type INTEGER NOT NULL,
|
||||
playlist_id INTEGER NOT NULL,
|
||||
@ -78,7 +98,9 @@ CREATE TABLE IF NOT EXISTS playlists (
|
||||
PRIMARY KEY (playlist_id, scope_id, scope_type)
|
||||
);
|
||||
"""
|
||||
PLAYLIST_DELETE = """
|
||||
PLAYLIST_DELETE: Final[
|
||||
str
|
||||
] = """
|
||||
UPDATE playlists
|
||||
SET
|
||||
deleted = true
|
||||
@ -90,21 +112,27 @@ WHERE
|
||||
)
|
||||
;
|
||||
"""
|
||||
PLAYLIST_DELETE_SCOPE = """
|
||||
PLAYLIST_DELETE_SCOPE: Final[
|
||||
str
|
||||
] = """
|
||||
DELETE
|
||||
FROM
|
||||
playlists
|
||||
WHERE
|
||||
scope_type = :scope_type ;
|
||||
"""
|
||||
PLAYLIST_DELETE_SCHEDULED = """
|
||||
PLAYLIST_DELETE_SCHEDULED: Final[
|
||||
str
|
||||
] = """
|
||||
DELETE
|
||||
FROM
|
||||
playlists
|
||||
WHERE
|
||||
deleted = true;
|
||||
"""
|
||||
PLAYLIST_FETCH_ALL = """
|
||||
PLAYLIST_FETCH_ALL: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
@ -120,7 +148,9 @@ WHERE
|
||||
AND deleted = false
|
||||
;
|
||||
"""
|
||||
PLAYLIST_FETCH_ALL_WITH_FILTER = """
|
||||
PLAYLIST_FETCH_ALL_WITH_FILTER: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
@ -139,7 +169,9 @@ WHERE
|
||||
)
|
||||
;
|
||||
"""
|
||||
PLAYLIST_FETCH_ALL_CONVERTER = """
|
||||
PLAYLIST_FETCH_ALL_CONVERTER: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
@ -162,7 +194,9 @@ WHERE
|
||||
)
|
||||
;
|
||||
"""
|
||||
PLAYLIST_FETCH = """
|
||||
PLAYLIST_FETCH: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
@ -181,7 +215,9 @@ WHERE
|
||||
)
|
||||
LIMIT 1;
|
||||
"""
|
||||
PLAYLIST_UPSERT = """
|
||||
PLAYLIST_UPSERT: Final[
|
||||
str
|
||||
] = """
|
||||
INSERT INTO
|
||||
playlists ( scope_type, playlist_id, playlist_name, scope_id, author_id, playlist_url, tracks )
|
||||
VALUES
|
||||
@ -195,15 +231,23 @@ VALUES
|
||||
playlist_url = excluded.playlist_url,
|
||||
tracks = excluded.tracks;
|
||||
"""
|
||||
PLAYLIST_CREATE_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS name_index ON playlists (scope_type, playlist_id, playlist_name, scope_id);
|
||||
PLAYLIST_CREATE_INDEX: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE INDEX IF NOT EXISTS name_index ON playlists (
|
||||
scope_type, playlist_id, playlist_name, scope_id
|
||||
);
|
||||
"""
|
||||
|
||||
# YouTube table statements
|
||||
YOUTUBE_DROP_TABLE = """
|
||||
YOUTUBE_DROP_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
DROP TABLE IF EXISTS youtube;
|
||||
"""
|
||||
YOUTUBE_CREATE_TABLE = """
|
||||
YOUTUBE_CREATE_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE TABLE IF NOT EXISTS youtube(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
track_info TEXT,
|
||||
@ -212,11 +256,15 @@ CREATE TABLE IF NOT EXISTS youtube(
|
||||
last_fetched INTEGER
|
||||
);
|
||||
"""
|
||||
YOUTUBE_CREATE_INDEX = """
|
||||
YOUTUBE_CREATE_INDEX: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_youtube_url
|
||||
ON youtube (track_info, youtube_url);
|
||||
"""
|
||||
YOUTUBE_UPSERT = """INSERT INTO
|
||||
YOUTUBE_UPSERT: Final[
|
||||
str
|
||||
] = """INSERT INTO
|
||||
youtube
|
||||
(
|
||||
track_info,
|
||||
@ -241,12 +289,16 @@ DO UPDATE
|
||||
track_info = excluded.track_info,
|
||||
last_updated = excluded.last_updated
|
||||
"""
|
||||
YOUTUBE_UPDATE = """
|
||||
YOUTUBE_UPDATE: Final[
|
||||
str
|
||||
] = """
|
||||
UPDATE youtube
|
||||
SET last_fetched=:last_fetched
|
||||
WHERE track_info=:track;
|
||||
"""
|
||||
YOUTUBE_QUERY = """
|
||||
YOUTUBE_QUERY: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT youtube_url, last_updated
|
||||
FROM youtube
|
||||
WHERE
|
||||
@ -254,17 +306,41 @@ WHERE
|
||||
AND last_updated > :maxage
|
||||
LIMIT 1;
|
||||
"""
|
||||
YOUTUBE_DELETE_OLD_ENTRIES = """
|
||||
YOUTUBE_QUERY_ALL: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT youtube_url, last_updated
|
||||
FROM youtube
|
||||
"""
|
||||
YOUTUBE_DELETE_OLD_ENTRIES: Final[
|
||||
str
|
||||
] = """
|
||||
DELETE FROM youtube
|
||||
WHERE
|
||||
last_updated < :maxage;
|
||||
last_updated < :maxage
|
||||
;
|
||||
"""
|
||||
YOUTUBE_QUERY_LAST_FETCHED_RANDOM: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT youtube_url, last_updated
|
||||
FROM youtube
|
||||
WHERE
|
||||
last_fetched > :day
|
||||
AND last_updated > :maxage
|
||||
LIMIT 100
|
||||
;
|
||||
"""
|
||||
|
||||
# Spotify table statements
|
||||
SPOTIFY_DROP_TABLE = """
|
||||
SPOTIFY_DROP_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
DROP TABLE IF EXISTS spotify;
|
||||
"""
|
||||
SPOTIFY_CREATE_TABLE = """
|
||||
SPOTIFY_CREATE_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE TABLE IF NOT EXISTS spotify(
|
||||
id TEXT,
|
||||
type TEXT,
|
||||
@ -277,11 +353,15 @@ CREATE TABLE IF NOT EXISTS spotify(
|
||||
last_fetched INTEGER
|
||||
);
|
||||
"""
|
||||
SPOTIFY_CREATE_INDEX = """
|
||||
SPOTIFY_CREATE_INDEX: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_spotify_uri
|
||||
ON spotify (id, type, uri);
|
||||
"""
|
||||
SPOTIFY_UPSERT = """INSERT INTO
|
||||
SPOTIFY_UPSERT: Final[
|
||||
str
|
||||
] = """INSERT INTO
|
||||
spotify
|
||||
(
|
||||
id, type, uri, track_name, artist_name,
|
||||
@ -306,12 +386,16 @@ DO UPDATE
|
||||
track_info = excluded.track_info,
|
||||
last_updated = excluded.last_updated;
|
||||
"""
|
||||
SPOTIFY_UPDATE = """
|
||||
SPOTIFY_UPDATE: Final[
|
||||
str
|
||||
] = """
|
||||
UPDATE spotify
|
||||
SET last_fetched=:last_fetched
|
||||
WHERE uri=:uri;
|
||||
"""
|
||||
SPOTIFY_QUERY = """
|
||||
SPOTIFY_QUERY: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT track_info, last_updated
|
||||
FROM spotify
|
||||
WHERE
|
||||
@ -319,17 +403,41 @@ WHERE
|
||||
AND last_updated > :maxage
|
||||
LIMIT 1;
|
||||
"""
|
||||
SPOTIFY_DELETE_OLD_ENTRIES = """
|
||||
SPOTIFY_QUERY_ALL: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT track_info, last_updated
|
||||
FROM spotify
|
||||
"""
|
||||
SPOTIFY_DELETE_OLD_ENTRIES: Final[
|
||||
str
|
||||
] = """
|
||||
DELETE FROM spotify
|
||||
WHERE
|
||||
last_updated < :maxage;
|
||||
last_updated < :maxage
|
||||
;
|
||||
"""
|
||||
SPOTIFY_QUERY_LAST_FETCHED_RANDOM: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT track_info, last_updated
|
||||
FROM spotify
|
||||
WHERE
|
||||
last_fetched > :day
|
||||
AND last_updated > :maxage
|
||||
LIMIT 100
|
||||
;
|
||||
"""
|
||||
|
||||
# Lavalink table statements
|
||||
LAVALINK_DROP_TABLE = """
|
||||
LAVALINK_DROP_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
DROP TABLE IF EXISTS lavalink ;
|
||||
"""
|
||||
LAVALINK_CREATE_TABLE = """
|
||||
LAVALINK_CREATE_TABLE: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE TABLE IF NOT EXISTS lavalink(
|
||||
query TEXT,
|
||||
data JSON,
|
||||
@ -338,11 +446,15 @@ CREATE TABLE IF NOT EXISTS lavalink(
|
||||
|
||||
);
|
||||
"""
|
||||
LAVALINK_CREATE_INDEX = """
|
||||
LAVALINK_CREATE_INDEX: Final[
|
||||
str
|
||||
] = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lavalink_query
|
||||
ON lavalink (query);
|
||||
"""
|
||||
LAVALINK_UPSERT = """INSERT INTO
|
||||
LAVALINK_UPSERT: Final[
|
||||
str
|
||||
] = """INSERT INTO
|
||||
lavalink
|
||||
(
|
||||
query,
|
||||
@ -366,12 +478,16 @@ DO UPDATE
|
||||
data = excluded.data,
|
||||
last_updated = excluded.last_updated;
|
||||
"""
|
||||
LAVALINK_UPDATE = """
|
||||
LAVALINK_UPDATE: Final[
|
||||
str
|
||||
] = """
|
||||
UPDATE lavalink
|
||||
SET last_fetched=:last_fetched
|
||||
WHERE query=:query;
|
||||
"""
|
||||
LAVALINK_QUERY = """
|
||||
LAVALINK_QUERY: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT data, last_updated
|
||||
FROM lavalink
|
||||
WHERE
|
||||
@ -379,22 +495,34 @@ WHERE
|
||||
AND last_updated > :maxage
|
||||
LIMIT 1;
|
||||
"""
|
||||
LAVALINK_QUERY_LAST_FETCHED_RANDOM = """
|
||||
SELECT data
|
||||
LAVALINK_QUERY_ALL: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT data, last_updated
|
||||
FROM lavalink
|
||||
"""
|
||||
LAVALINK_QUERY_LAST_FETCHED_RANDOM: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT data, last_updated
|
||||
FROM lavalink
|
||||
WHERE
|
||||
last_fetched > :day
|
||||
AND last_updated > :maxage
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 10
|
||||
LIMIT 100
|
||||
;
|
||||
"""
|
||||
LAVALINK_DELETE_OLD_ENTRIES = """
|
||||
LAVALINK_DELETE_OLD_ENTRIES: Final[
|
||||
str
|
||||
] = """
|
||||
DELETE FROM lavalink
|
||||
WHERE
|
||||
last_updated < :maxage;
|
||||
last_updated < :maxage
|
||||
;
|
||||
"""
|
||||
LAVALINK_FETCH_ALL_ENTRIES_GLOBAL = """
|
||||
LAVALINK_FETCH_ALL_ENTRIES_GLOBAL: Final[
|
||||
str
|
||||
] = """
|
||||
SELECT query, data
|
||||
FROM lavalink
|
||||
"""
|
||||
|
||||
@ -1,336 +1,10 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import re
|
||||
import tarfile
|
||||
import time
|
||||
import zipfile
|
||||
from enum import Enum, unique
|
||||
from io import BytesIO
|
||||
from typing import MutableMapping, Optional, TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
from typing import MutableMapping
|
||||
|
||||
import discord
|
||||
import lavalink
|
||||
|
||||
from redbot.core import Config, commands
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.i18n import Translator
|
||||
from redbot.core.utils.chat_formatting import bold, box
|
||||
from discord.utils import escape_markdown as escape
|
||||
|
||||
from .audio_dataclasses import Query
|
||||
|
||||
__all__ = [
|
||||
"_pass_config_to_utils",
|
||||
"track_limit",
|
||||
"queue_duration",
|
||||
"draw_time",
|
||||
"dynamic_time",
|
||||
"match_url",
|
||||
"clear_react",
|
||||
"match_yt_playlist",
|
||||
"remove_react",
|
||||
"get_track_description",
|
||||
"track_creator",
|
||||
"time_convert",
|
||||
"url_check",
|
||||
"userlimit",
|
||||
"is_allowed",
|
||||
"track_to_json",
|
||||
"rgetattr",
|
||||
"humanize_scope",
|
||||
"CacheLevel",
|
||||
"format_playlist_picker_data",
|
||||
"get_track_description_unformatted",
|
||||
"track_remaining_duration",
|
||||
"Notifier",
|
||||
"PlaylistScope",
|
||||
]
|
||||
_RE_TIME_CONVERTER = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])")
|
||||
_RE_YT_LIST_PLAYLIST = re.compile(
|
||||
r"^(https?://)?(www\.)?(youtube\.com|youtu\.?be)(/playlist\?).*(list=)(.*)(&|$)"
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
_config: Config
|
||||
_bot: Red
|
||||
else:
|
||||
_config = None
|
||||
_bot = None
|
||||
|
||||
_ = Translator("Audio", __file__)
|
||||
|
||||
|
||||
def _pass_config_to_utils(config: Config, bot: Red) -> None:
|
||||
global _config, _bot
|
||||
if _config is None:
|
||||
_config = config
|
||||
if _bot is None:
|
||||
_bot = bot
|
||||
|
||||
|
||||
def track_limit(track, maxlength) -> bool:
|
||||
try:
|
||||
length = round(track.length / 1000)
|
||||
except AttributeError:
|
||||
length = round(track / 1000)
|
||||
|
||||
if maxlength < length <= 900000000000000: # livestreams return 9223372036854775807ms
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def is_allowed(guild: discord.Guild, query: str, query_obj: Query = None) -> bool:
|
||||
|
||||
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 = set(await _config.guild(guild).url_keyword_whitelist())
|
||||
whitelist = [i.lower() for i in whitelist]
|
||||
if whitelist:
|
||||
return any(i in query for i in whitelist)
|
||||
blacklist = set(await _config.guild(guild).url_keyword_blacklist())
|
||||
blacklist = [i.lower() for i in blacklist]
|
||||
return not any(i in query for i in blacklist)
|
||||
return True
|
||||
|
||||
|
||||
async def queue_duration(ctx) -> int:
|
||||
player = lavalink.get_player(ctx.guild.id)
|
||||
duration = []
|
||||
for i in 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(ctx) -> 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
|
||||
|
||||
|
||||
async def draw_time(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
|
||||
|
||||
|
||||
def dynamic_time(seconds) -> 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_playlist_picker_data(pid, pname, ptracks, pauthor, scope) -> str:
|
||||
author = _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=humanize_scope(scope), pid=pid, ptracks=ptracks, author=author)
|
||||
return box(line, lang="md")
|
||||
|
||||
|
||||
def match_url(url) -> 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(url) -> bool:
|
||||
if _RE_YT_LIST_PLAYLIST.match(url):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def remove_react(message, react_emoji, react_user) -> None:
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
await message.remove_reaction(react_emoji, react_user)
|
||||
|
||||
|
||||
async def clear_react(bot: Red, message: discord.Message, emoji: MutableMapping = None) -> None:
|
||||
try:
|
||||
await message.clear_reactions()
|
||||
except discord.Forbidden:
|
||||
if not emoji:
|
||||
return
|
||||
with contextlib.suppress(discord.HTTPException):
|
||||
for key in emoji.values():
|
||||
await asyncio.sleep(0.2)
|
||||
await message.remove_reaction(key, bot.user)
|
||||
except discord.HTTPException:
|
||||
return
|
||||
|
||||
|
||||
def get_track_description(track) -> Optional[str]:
|
||||
if track and getattr(track, "uri", None):
|
||||
query = Query.process_input(track.uri)
|
||||
if query.is_local or "localtracks/" in track.uri:
|
||||
if track.title != "Unknown title":
|
||||
return f'**{escape(f"{track.author} - {track.title}")}**' + escape(
|
||||
f"\n{query.to_string_user()} "
|
||||
)
|
||||
else:
|
||||
return escape(query.to_string_user())
|
||||
else:
|
||||
return f'**{escape(f"[{track.title}]({track.uri}) ")}**'
|
||||
elif hasattr(track, "to_string_user") and track.is_local:
|
||||
return escape(track.to_string_user() + " ")
|
||||
|
||||
|
||||
def get_track_description_unformatted(track) -> Optional[str]:
|
||||
if track and hasattr(track, "uri"):
|
||||
query = Query.process_input(track.uri)
|
||||
if query.is_local or "localtracks/" in track.uri:
|
||||
if track.title != "Unknown title":
|
||||
return escape(f"{track.author} - {track.title}")
|
||||
else:
|
||||
return escape(query.to_string_user())
|
||||
else:
|
||||
return escape(f"{track.title}")
|
||||
elif hasattr(track, "to_string_user") and track.is_local:
|
||||
return escape(track.to_string_user() + " ")
|
||||
|
||||
|
||||
def track_creator(player, position=None, other_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 track_to_json(queued_track)
|
||||
|
||||
|
||||
def track_to_json(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"]
|
||||
values = [track_id, track_info]
|
||||
track_obj = {}
|
||||
for key, value in zip(keys, values):
|
||||
track_obj[key] = value
|
||||
return track_obj
|
||||
|
||||
|
||||
def time_convert(length) -> int:
|
||||
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
|
||||
|
||||
|
||||
def url_check(url) -> 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 userlimit(channel) -> bool:
|
||||
if channel.user_limit == 0 or channel.user_limit > len(channel.members) + 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def rsetattr(obj, attr, val):
|
||||
pre, _, post = attr.rpartition(".")
|
||||
return setattr(rgetattr(obj, pre) if pre else obj, post, val)
|
||||
|
||||
|
||||
def rgetattr(obj, attr, *args):
|
||||
def _getattr(obj2, attr2):
|
||||
return getattr(obj2, attr2, *args)
|
||||
|
||||
return functools.reduce(_getattr, [obj] + attr.split("."))
|
||||
from redbot.core import commands
|
||||
|
||||
|
||||
class CacheLevel:
|
||||
@ -494,13 +168,13 @@ class Notifier:
|
||||
self.color = await self.context.embed_colour()
|
||||
embed2 = discord.Embed(
|
||||
colour=self.color,
|
||||
title=self.updates.get(key).format(num=current, total=total, seconds=seconds),
|
||||
title=self.updates.get(key, "").format(num=current, total=total, seconds=seconds),
|
||||
)
|
||||
if seconds and seconds_key:
|
||||
embed2.set_footer(text=self.updates.get(seconds_key).format(seconds=seconds))
|
||||
embed2.set_footer(text=self.updates.get(seconds_key, "").format(seconds=seconds))
|
||||
try:
|
||||
await self.message.edit(embed=embed2)
|
||||
self.last_msg_time = time.time()
|
||||
self.last_msg_time = int(time.time())
|
||||
except discord.errors.NotFound:
|
||||
pass
|
||||
|
||||
@ -514,7 +188,7 @@ class Notifier:
|
||||
async def update_embed(self, embed: discord.Embed):
|
||||
try:
|
||||
await self.message.edit(embed=embed)
|
||||
self.last_msg_time = time.time()
|
||||
self.last_msg_time = int(time.time())
|
||||
except discord.errors.NotFound:
|
||||
pass
|
||||
|
||||
@ -531,13 +205,3 @@ class PlaylistScope(Enum):
|
||||
@staticmethod
|
||||
def list():
|
||||
return list(map(lambda c: c.value, PlaylistScope))
|
||||
|
||||
|
||||
def humanize_scope(scope, ctx=None, the=None):
|
||||
|
||||
if scope == PlaylistScope.GLOBAL.value:
|
||||
return (_("the ") if the else "") + _("Global")
|
||||
elif scope == PlaylistScope.GUILD.value:
|
||||
return ctx.name if ctx else (_("the ") if the else "") + _("Server")
|
||||
elif scope == PlaylistScope.USER.value:
|
||||
return str(ctx) if ctx else (_("the ") if the else "") + _("User")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user