Compare commits

..

40 Commits

Author SHA1 Message Date
Flame442
d8d3e9fceb [Trivia] Remove an unnecessary .format (#4175) 2020-08-10 01:17:44 +02:00
jack1142
b3281385e9 3.3.11 changelog (#4173)
* 3.3.11 changelog

* Update docs/changelog_3_3_0.rst

Co-authored-by: Flame442 <34169552+Flame442@users.noreply.github.com>

* Update docs/changelog_3_3_0.rst

Co-authored-by: Flame442 <34169552+Flame442@users.noreply.github.com>

* Update docs/changelog_3_3_0.rst

Co-authored-by: Flame442 <34169552+Flame442@users.noreply.github.com>

* Update docs/changelog_3_3_0.rst

Co-authored-by: Flame442 <34169552+Flame442@users.noreply.github.com>

Co-authored-by: Flame442 <34169552+Flame442@users.noreply.github.com>
2020-08-10 01:17:37 +02:00
jack1142
e6947bdbf6 Bump to 3.3.11 2020-08-10 01:10:35 +02:00
Douglas
a6d924221d [Trivia] Fix unresolved reference to bank.BalanceTooHigh (#4170)
Co-authored-by: douglas-cpp <douglasc.dev@gmail.com>
2020-08-10 00:50:22 +02:00
Vexed
cf7db1e891 Docs improvements after watching two self-proclaimed incompetent people install it (#4119)
* the thing

* right

* hmm

* review
2020-08-10 00:50:03 +02:00
jack1142
bc544d476b Fix the errors related to installed module having invalid commit data (#4086) 2020-08-10 00:49:17 +02:00
MeatyChunks
10976f218b [Economy] Prevent forbidden error when blocked by user (#4120)
Stop `[p]payouts` throwing a console error if the user has blocked the bot. Probably too spammy to put the payout message in chat.
2020-08-10 00:48:34 +02:00
jack1142
a589838a41 Only accept positive integers in [p]cleanup commands (#4115) 2020-08-10 00:48:26 +02:00
Ryan
e36d1f143d [CustomCom] Add missing await (#4108) 2020-08-10 00:47:36 +02:00
jack1142
9a6f78e62e Update chocolatey install commands per chocolatey.org (#4098) 2020-08-10 00:47:28 +02:00
Draper
649db87a8a [Audio] Ensure TrackEnqueueError is always handled (#3879)
Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com>
2020-08-10 00:43:20 +02:00
jack1142
c6f9a78d57 Update pyenv instructions to install Python 3.8.5 (#4094) 2020-08-10 00:35:30 +02:00
jack1142
bd89e4386d Fix no message when unregistered reason is used in [p]warn (#3840) 2020-08-10 00:34:26 +02:00
jack1142
a962f94a58 Ignore that the rule for model doesn't exist when trying to remove it (#4036) 2020-08-10 00:34:10 +02:00
jack1142
3471011f85 Fix the error for empty author list in [p]findcog (#4042) 2020-08-10 00:34:01 +02:00
Draper
186dbe6118 Lavalink.jar bump for internal manager (#4168) 2020-08-10 00:27:45 +02:00
Kowlin
a59ff57c27 Dev bump for 3.3.11 (#4056) 2020-07-09 08:55:40 +02:00
Kowlin
0cd3bede0d 3.3.10 release bump. (#4054) 2020-07-09 08:45:17 +02:00
Neuro Assassin
637fa37fad Red 3.3.10 - Changelog (#3967)
* Add 3.3.10 or 3.4.0 section

* PR 3981

* PR 3951

* PR 3975

* PR 3974

* PR 3964

* PR 3884

* Various things

* PR 3972

* PR 3970

* Add contributor

* PR 3980

* Add Draper as contributor

* PR 3973

* PR 3965

* PR 3987

* PR 3901

* PR 3906

* PR 3958

* PR 3895

* PR 3920

* PR 3915

* oops

* I don't like info

* PR 3969

* PR 3990

* PR 3911

* PR 3921

* PR 3938

* PR 3608

* Update entries about PR 3608

* PR 4012

* PR 4030

* PR 4026

* PR 4039

* PR 3994

* PR 3991

* PR 4023

* Actually fix conflicts

* A lot of PRs.

* And stamp a date on top of it.

* Fun fact, we format it with YYYY-MM-DD...

* Forgot Sinbad, 👀 Sorry

* Update changelog_3_3_0.rst

Co-authored-by: Neuro Assassin <wrbrown70@yahoo.com>
Co-authored-by: Kowlin <Kowlin@users.noreply.github.com>
2020-07-09 08:41:56 +02:00
aikaterna
07dcf38291 Update Lavalink.jar version (#4055) 2020-07-09 08:28:00 +02:00
Lui
ff1f7362ee Bump d.py dependency (#4053) 2020-07-09 07:38:49 +02:00
Draper
d30e83b5fc Reduce config calls when changing white/blacklist and use sets for their cache (#3910)
* optimise use of config ctx manager to reduce calls to config

* fine you potato

* since jack said yes i'll abuse it

* Apply suggestions from code review

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>

* difference_update and update

* Apply suggestions from code review

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/core/settings_caches.py

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>

* one last tweak

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-07-08 19:25:14 +02:00
Dav
14349d0649 Make strings in help command *truly* translatable (#4044)
* make help.py translatable

* jack's not-review 1

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>

* pylint is going crazy her, not sure what I'm missing

* Jack's now-this-is-actually-a-review 1

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>

* Jack's review 2

* Let's not bother Dav with one missing backtick

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-07-08 15:11:04 +02:00
Dav
49b19450fd [Mod] Make tempbans permanent when using [p]hackban (#4025)
* Remove users from tempban unban list when hackbanning them

* black and missing bracket

* make sure this actually gets processed

* let the user know when a tempban was upgraded

* say more things

* reduce config calls

* jack loves performance

* adress review

* review the 2nd
2020-07-08 01:15:42 +02:00
Dav
5b612b8ac7 [Docs] Update framework_i18n.rst (#4018)
* Update framework_i18n.rst

* Update framework_i18n.rst

* -h flag note

* Add hyphen

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-07-08 00:53:03 +02:00
Draper
e0b922c949 Make localwhitelist check if caller will still be able to use bot after changes (#3903)
* Check invokers theoretical perms in localwhitelist add before completing command

* remove unnecessary code

* add check to remove

* ignore bot owner and server owner

* Update core_commands.py

* lets not crash shit
2020-07-07 20:08:06 +02:00
Michael H
60df447550 Add settings view commands (#4041)
Any group which sent help + settings views has had the settings view
  split into a seperate command. This ensures that custom help behavior
  does not interfere with settings views.
2020-07-07 00:53:41 +02:00
Vexed
2cf7a1f80d [Core] Docstring full stops and a few other grammar fixes (#4023)
* core full stops/other grammar fixes

* need to read better (i'm keeping it on two lines)

* apply review

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-07-06 20:21:45 +02:00
jack1142
4dd0fb97fe Stop putting text about invite when invite isn't sent in tempban message (#3991) 2020-07-06 18:57:16 +01:00
Vexed
12bce6a560 [Image] Update instructions for setting the GIPHY API (#3994)
* update wording for giphy api

* of cource master draper
#3938 - make the command untranslateable

* i18n <your_api_key_here>

* commas to parens as discused in discord
2020-07-06 19:11:04 +02:00
Michael H
d869410d36 Add .gitattributes to ensure project consistent line endings (#4037)
- Renormalized as well
2020-07-06 18:53:10 +02:00
Draper
31bb43ca38 Vendor discord.ext.menus (#4039)
Vendor `discord.ext.menus` from commit `cc108bed812d0e481a628ca573c2eeeca9226b42` at https://github.com/Rapptz/discord-ext-menus

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-07-06 17:37:52 +02:00
jack1142
07e480ff7a Stop using git ls-files *.py for style check (#4040) 2020-07-06 14:45:46 +01:00
MiniJennJenn
c251804162 Trivia Update (#4026)
Co-authored-by: hatred2k <hatred2k@gmail.com>
2020-07-06 09:29:56 +01:00
github-actions[bot]
ff72e415aa Automated Crowdin downstream (#4033)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2020-07-02 19:04:35 +02:00
aikaterna
7d30e3de14 [Utils] Fix regex for role mentions in MessagePredicate (#4030) 2020-07-01 03:29:16 +02:00
bobloy
8b529f488b Add 'discord.com/invite' to docs of filter_invites() function (#4027) 2020-06-30 14:49:45 +02:00
github-actions[bot]
b5930155df Automated Crowdin downstream (#4016)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2020-06-25 17:20:12 +02:00
bobloy
632840384b Add discord.com to supported domains in INVITE_URL_RE (#4012)
* Support for discord.com

* Modified to support discord.com requiring `/invite`

* Non-capturing
2020-06-24 20:06:11 +02:00
jack1142
a96e814af4 Add project_urls and improve our use of classifiers (#4006) 2020-06-22 21:18:53 +02:00
658 changed files with 152290 additions and 27451 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
* text eol=lf
# binary file excludsions
*.png binary

1
.github/CODEOWNERS vendored
View File

@@ -1,5 +1,4 @@
# Core
redbot/core/apis/audio/** @aikaterna @Drapersniper
redbot/core/bank.py @palmtree5
redbot/core/checks.py @tekulvw
redbot/core/cli.py @tekulvw

View File

@@ -700,3 +700,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
This project vendors discord.ext.menus package (https://github.com/Rapptz/discord-ext-menus) made by Danny Y. (Rapptz) which is distributed under MIT License.
Copy of this license can be found in discord-ext-menus.LICENSE file in redbot/vendored folder of this repository.

View File

@@ -1,12 +1,14 @@
PYTHON ?= python3.8
ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
# Python Code Style
reformat:
$(PYTHON) -m black `git ls-files "*.py"`
$(PYTHON) -m black $(ROOT_DIR)
stylecheck:
$(PYTHON) -m black --check `git ls-files "*.py"`
$(PYTHON) -m black --check $(ROOT_DIR)
stylediff:
$(PYTHON) -m black --check --diff `git ls-files "*.py"`
$(PYTHON) -m black --check --diff $(ROOT_DIR)
# Translations
gettext:

View File

@@ -126,3 +126,6 @@ Red is named after the main character of "Transistor", a video game by
Artwork created by [Sinlaire](https://sinlaire.deviantart.com/) on Deviant Art for the Red Discord
Bot Project.
This project vendors [discord.ext.menus](https://github.com/Rapptz/discord-ext-menus) package made by Danny Y. (Rapptz) which is distributed under MIT License.
Copy of this license can be found in [discord-ext-menus.LICENSE](redbot/vendored/discord-ext-menus.LICENSE) file in [redbot/vendored](redbot/vendored) folder of this repository.

View File

@@ -1,5 +1,150 @@
.. 3.3.x Changelogs
Redbot 3.3.11 (2020-08-10)
==========================
| Thanks to all these amazing people that contributed to this release:
| :ghuser:`douglas-cpp`, :ghuser:`Drapersniper`, :ghuser:`jack1142`, :ghuser:`MeatyChunks`, :ghuser:`Vexed01`, :ghuser:`yamikaitou`
End-user changelog
------------------
Audio
*****
- Audio should now work again on all voice regions (:issue:`4162`, :issue:`4168`)
- Removed an edge case where an unfriendly error message was sent in Audio cog (:issue:`3879`)
Cleanup
*******
- Fixed a bug causing ``[p]cleanup`` commands to clear all messages within last 2 weeks when ``0`` is passed as the amount of messages to delete (:issue:`4114`, :issue:`4115`)
CustomCommands
**************
- ``[p]cc show`` now sends an error message when command with the provided name couldn't be found (:issue:`4108`)
Downloader
**********
- ``[p]findcog`` no longer fails for 3rd-party cogs without any author (:issue:`4032`, :issue:`4042`)
- Update commands no longer crash when a different repo is added under a repo name that was once used (:issue:`4086`)
Permissions
***********
- ``[p]permissions removeserverrule`` and ``[p]permissions removeglobalrule`` no longer error when trying to remove a rule that doesn't exist (:issue:`4028`, :issue:`4036`)
Warnings
********
- ``[p]warn`` now sends an error message (instead of no feedback) when an unregistered reason is used by someone who doesn't have Administrator permission (:issue:`3839`, :issue:`3840`)
Redbot 3.3.10 (2020-07-09)
===================================
| Thanks to all these amazing people that contributed to this release:
| :ghuser:`aikaterna`, :ghuser:`bobloy`, :ghuser:`Dav-Git`, :ghuser:`Drapersniper`, :ghuser:`Flame442`, :ghuser:`flaree`, :ghuser:`jack1142`, :ghuser:`MiniJennJenn`, :ghuser:`NeuroAssassin`, :ghuser:`thisisjvgrace`, :ghuser:`Vexed01`, :ghuser:`Injabie3`, :ghuser:`mikeshardmind`
End-user changelog
------------------
Audio
*****
- Added information about internally managed jar to ``[p]audioset info`` (:issue:`3915`)
- Updated to Lavaplayer 1.3.50
- Twitch playback and YouTube searching should be functioning again.
Core Bot
********
- Fixed delayed help when ``[p]set deletedelay`` is enabled (:issue:`3884`, :issue:`3883`)
- Bumped the Discord.py requirement from 1.3.3 to 1.3.4 (:issue:`4053`)
- Added settings view commands for nearly all cogs. (:issue:`4041`)
- Added more strings to be fully translatable by i18n. (:issue:`4044`)
Downloader
**********
- Added ``[p]cog listpinned`` subcommand to see currently pinned cogs (:issue:`3974`)
- Fixed unnecessary typing when running downloader commands (:issue:`3964`, :issue:`3948`)
- Added embed version of ``[p]findcog`` (:issue:`3965`, :issue:`3944`)
- Fixed ``[p]findcog`` not differentiating between core cogs and local cogs(:issue:`3969`, :issue:`3966`)
Filter
******
- Added ``[p]filter list`` to show filtered words, and removed DMs when no subcommand was passed (:issue:`3973`)
Image
*****
- Updated instructions for obtaining and setting the GIPHY API key (:issue:`3994`)
Mod
***
- Added option to delete messages within the passed amount of days with ``[p]tempban`` (:issue:`3958`)
- Added the ability to permanently ban a temporary banned user with ``[p]hackban`` (:issue:`4025`)
- Fixed the passed reason not being used when using ``[p]tempban`` (:issue:`3958`)
- Fixed invite being sent with ``[p]tempban`` even when no invite was set (:issue:`3991`)
- Prevented an issue whereby the author may lock him self out of using the bot via whitelists (:issue:`3903`)
- Reduced the number of API calls made to the storage APIs (:issue:`3910`)
Permissions
***********
- Uploaded YAML files now accept integer commands without quotes (:issue:`3987`, :issue:`3185`)
- Uploaded YAML files now accept command rules with empty dictionaries (:issue:`3987`, :issue:`3961`)
Streams
*******
- Fixed streams cog sending multiple owner notifications about twitch secret not set (:issue:`3901`, :issue:`3587`)
- Fixed old bearer tokens not being invalidated when the API key is updated (:issue:`3990`, :issue:`3917`)
Trivia Lists
************
- Fixed URLs in ``whosthatpokemon`` (:issue:`3975`, :issue:`3023`)
- Fixed trivia files ``leagueults`` and ``sports`` (:issue:`4026`)
- Updated ``greekmyth`` to include more answer variations (:issue:`3970`)
- Added new ``lotr`` trivia list (:issue:`3980`)
- Added new ``r6seige`` trivia list (:issue:`4026`)
Developer changelog
-------------------
- Added the utility functions ``map``, ``find``, and ``next`` to ``AsyncIter`` (:issue:`3921`, :issue:`3887`)
- Updated deprecation times for ``APIToken``, and loops being passed to various functions to the first minor release (represented by ``X`` in ``3.X.0``) after 2020-08-05 (:issue:`3608`)
- Updated deprecation warnings for shared libs to reflect that they have been moved for an undefined time (:issue:`3608`)
- Added new ``discord.com`` domain to ``INVITE_URL_RE`` common filter (:issue:`4012`)
- Fixed incorrect role mention regex in ``MessagePredicate`` (:issue:`4030`)
- Vendor the ``discord.ext.menus`` module (:issue:`4039`)
Documentation changes
---------------------
Miscellaneous
-------------
- Improved error responses for when Modlog and Autoban on mention spam were already disabled (:issue:`3951`, :issue:`3949`)
- Clarified that ``[p]embedset user`` only affects commands executed in DMs (:issue:`3972`, :issue:`3953`)
- Added link to Getting Started guide if the bot was not in any guilds (:issue:`3906`)
- Fixed exceptions being ignored or not sent to log files in special cases (:issue:`3895`)
- Added the option of using dots in the instance name when creating your instances (:issue:`3920`)
- Added a confirmation when using hyphens in instance names to discourage the use of them (:issue:`3920`)
- Fixed migration owner notifications being sent even when migration was not necessary (:issue:`3911`. :issue:`3909`)
- Fixed commands being translated where they should not be (:issue:`3938`, :issue:`3919`)
- Fixed grammar errors and added full stopts in ``core_commands.py`` (:issue:`4023`)
Redbot 3.3.9 (2020-06-12)
=========================

View File

@@ -33,21 +33,17 @@ Tutorial
After making your cog, generate a :code:`messages.pot` file
The process of generating this will depend on the operating system
you are using
We recommend using redgettext - a modified version of pygettext for Red.
You can install redgettext by running :code:`pip install redgettext` in a command prompt.
In a command prompt in your cog's package (where yourcog.py is),
create a directory called "locales".
Then do one of the following:
Windows: :code:`python <your python install path>\Tools\i18n\pygettext.py -D -n -p locales`
Mac: ?
Linux: :code:`pygettext3 -D -n -p locales`
This will generate a messages.pot file with strings to be translated, including
To generate the :code:`messages.pot` file, you will now need to run
:code:`python -m redgettext -c [path_to_cog]`
This file will contain all strings to be translated, including
docstrings.
(For advanced usage check :code:`python -m redgettext -h`)
You can now use a tool like `poedit
<https://poedit.net/>`_ to translate the strings in your messages.pot file.
-------------
API Reference

View File

@@ -397,7 +397,7 @@ Then run the following command:
.. code-block:: none
CONFIGURE_OPTS=--enable-optimizations pyenv install 3.8.3 -v
CONFIGURE_OPTS=--enable-optimizations pyenv install 3.8.5 -v
This may take a long time to complete, depending on your hardware. For some machines (such as
Raspberry Pis and micro-tier VPSes), it may take over an hour; in this case, you may wish to remove
@@ -409,7 +409,7 @@ After that is finished, run:
.. code-block:: none
pyenv global 3.8.3
pyenv global 3.8.5
Pyenv is now installed and your system should be configured to run Python 3.8.

View File

@@ -29,13 +29,14 @@ Using PowerShell and Chocolatey (recommended)
*********************************************
To install via PowerShell, search "powershell" in the Windows start menu,
right-click on it and then click "Run as administrator"
right-click on it and then click "Run as administrator".
Then run each of the following commands:
.. code-block:: none
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
choco upgrade git --params "/GitOnlyOnPath /WindowsTerminal" -y
choco upgrade visualstudio2019-workload-vctools -y
@@ -63,7 +64,7 @@ Manually installing dependencies
* `MSVC Build tools <https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2019>`_
* `Python 3.8.1 <https://www.python.org/downloads/>`_ - Red needs Python 3.8.1 or greater
* `Python 3.8.1 or greater <https://www.python.org/downloads/>`_
.. attention:: Please make sure that the box to add Python to PATH is CHECKED, otherwise
you may run into issues when trying to run Red.
@@ -86,7 +87,7 @@ Creating a Virtual Environment
.. tip::
If you want to learn more about virtual environments, see page: `about-venvs`
If you want to learn more about virtual environments, see page: `about-venvs`.
We require installing Red into a virtual environment. Don't be scared, it's very
straightforward.
@@ -95,7 +96,12 @@ First, choose a directory where you would like to create your virtual environmen
to keep it in a location which is easy to type out the path to. From now, we'll call it
``redenv`` and it will be located in your home directory.
Start with opening a command prompt (open Start, search for "command prompt", then click it)
Start with opening a command prompt (open Start, search for "command prompt", then click it).
.. note::
You shouldn't run command prompt as administrator when creating your virtual environment, or
running Red.
.. warning::
@@ -144,11 +150,6 @@ Run **one** of the following set of commands, depending on what extras you want
python -m pip install -U pip setuptools wheel
python -m pip install -U Red-DiscordBot[postgres]
.. note::
These commands are also used for updating Red
--------------------------
Setting Up and Running Red
--------------------------

View File

@@ -17,7 +17,7 @@ Guarantees
Anything in the ``redbot.core`` module or any of its submodules
which is not private (even if not documented) should not break without notice.
Anything in the ``redbot.cogs`` module or any of it's submodules is specifically
Anything in the ``redbot.cogs`` and ``redbot.vendored`` modules or any of their submodules is specifically
excluded from being guaranteed.
Any RPC method exposed by Red may break without notice.

View File

@@ -5,24 +5,18 @@ if [%1] == [] goto help
REM This allows us to expand variables at execution
setlocal ENABLEDELAYEDEXPANSION
REM This will set PYFILES as a list of tracked .py files
set PYFILES=
for /F "tokens=* USEBACKQ" %%A in (`git ls-files "*.py"`) do (
set PYFILES=!PYFILES! %%A
)
goto %1
:reformat
black !PYFILES!
black "%~dp0."
exit /B %ERRORLEVEL%
:stylecheck
black --check !PYFILES!
black --check "%~dp0."
exit /B %ERRORLEVEL%
:stylediff
black --check --diff !PYFILES!
black --check --diff "%~dp0."
exit /B %ERRORLEVEL%
:newenv

View File

@@ -14,5 +14,6 @@
| buck-out
| build
| dist
| redbot\/vendored
)/
'''

View File

@@ -191,7 +191,7 @@ def _update_event_loop_policy():
_asyncio.set_event_loop_policy(_uvloop.EventLoopPolicy())
__version__ = "3.3.10.dev1"
__version__ = "3.3.11"
version_info = VersionInfo.from_str(__version__)
# Filter fuzzywuzzy slow sequence matcher warning

View File

@@ -21,7 +21,7 @@ msgstr "Я попыталась сделать что-то, в чем Discord о
#: redbot/cogs/admin/admin.py:22
msgid "I can not give {role.name} to {member.display_name} because that role is higher than or equal to my highest role in the Discord hierarchy."
msgstr "Я не могу дать {role.name} для {member.display_name}, потому что эта роль выше или равна моей самой высокой роли в иерархии Discord."
msgstr "Невозможно выдать роль {role.name} для {member.display_name}, потому что эта роль выше или равна моей самой высокой роли в иерархии Discord."
#: redbot/cogs/admin/admin.py:28
msgid "I can not remove {role.name} from {member.display_name} because that role is higher than or equal to my highest role in the Discord hierarchy."
@@ -172,7 +172,7 @@ msgstr "\\n Список всех доступных Собственны
#: redbot/cogs/admin/admin.py:407
msgid "Available Selfroles:\\n{selfroles}"
msgstr "Доступные Собственные роли:\\n{selfroles}"
msgstr "Доступные собственные роли:\\n{selfroles}"
#: redbot/cogs/admin/admin.py:413
#, docstring
@@ -186,7 +186,7 @@ msgstr "\\n Добавить роль в список доступных
#: redbot/cogs/admin/admin.py:425
msgid "I cannot let you add {role.name} as a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
msgstr "Я не могу позволить вам добавить {role.name} в качестве собственной роли, потому что эта роль выше или равна вашей самой высокой роли в иерархии Discord."
msgstr "Нельзя добавить {role.name} в качестве собственной роли, потому что эта роль выше или равна вашей самой высшей роли в иерархии Discord."
#: redbot/cogs/admin/admin.py:433
msgid "Added."
@@ -194,7 +194,7 @@ msgstr "Добавлено."
#: redbot/cogs/admin/admin.py:436
msgid "That role is already a selfrole."
msgstr "Эта роль уже является собственной ролью."
msgstr "У вас уже имеется эта роль."
#: redbot/cogs/admin/admin.py:440
#, docstring
@@ -203,7 +203,7 @@ msgstr "\\n Удалить роль из списка доступных
#: redbot/cogs/admin/admin.py:447
msgid "I cannot let you remove {role.name} from being a selfrole because that role is higher than or equal to your highest role in the Discord hierarchy."
msgstr "Я не могу позволить вам сделать {role.name} не собственной ролью, потому что эта роль выше или равна вашей самой высокой роли в иерархии Discord."
msgstr "Вы не можете забрать у себя роль {role.name}, потому что эта роль выше или равна вашей самой высокой роли в иерархии Discord."
#: redbot/cogs/admin/admin.py:455
msgid "Removed."

View File

@@ -21,19 +21,19 @@ msgstr "Bir şey denemeye çalıştım ancak Discord izinlerim yeterli olmadı.
#: redbot/cogs/admin/admin.py:22
msgid "I can not give {role.name} to {member.display_name} because that role is higher than or equal to my highest role in the Discord hierarchy."
msgstr "{member.display_name} kullanıcısına {role.name} veremiyorum çünkü bu rol kendi rolümden daha yüksek bir pozisyonda."
msgstr "{member.display_name} kullanıcısına {role.name} veremiyorum çünkü bu rol Discord hiyerarşisinde rolüme eşit veya daha yüksek."
#: redbot/cogs/admin/admin.py:28
msgid "I can not remove {role.name} from {member.display_name} because that role is higher than or equal to my highest role in the Discord hierarchy."
msgstr "{member.display_name} kullanıcısından {role.name} rolünü kaldıramıyorum çünkü bu rolümden daha yüksek pozisyonda."
msgstr "{member.display_name} kullanıcısından {role.name} rolünü kaldıramıyorum çünkü bu rol Discord hiyerarşisinde rolüme eşit veya daha yüksek."
#: redbot/cogs/admin/admin.py:34
msgid "I can not edit {role.name} because that role is higher than my or equal to highest role in the Discord hierarchy."
msgstr "{role.name} kullanıcısının rolünü düzenleyemiyorum, çünkü bu rolümden daha yüksek pozisyonda."
msgstr "{role.name} kullanıcısının rolünü düzenleyemiyorum, çünkü bu rol Discord hiyerarşisinde rolüme eşit veya daha yüksek."
#: redbot/cogs/admin/admin.py:40
msgid "I can not let you give {role.name} to {member.display_name} because that role is higher than or equal to your highest role in the Discord hierarchy."
msgstr "{member.display_name} kullanıcısına {role.name} rolünü vermene müsaade edemem çünkü bu rol senin mevcut rolünden daha yüksek durumda."
msgstr "{member.display_name} kullanıcısına {role.name} rolünü verilemedi çünkü bu rol discord hiyerarşisinde senin mevcut rolünden daha yüksek durumda."
#: redbot/cogs/admin/admin.py:46
msgid "I can not let you remove {role.name} from {member.display_name} because that role is higher than or equal to your highest role in the Discord hierarchy."

View File

@@ -18,7 +18,7 @@ msgstr ""
#: redbot/cogs/alias/alias.py:31
#, docstring
msgid "Create aliases for commands.\\n\\n Aliases are alternative names shortcuts for commands. They\\n can act as both a lambda (storing arguments for repeated use)\\n or as simply a shortcut to saying \\\"x y z\\\".\\n\\n When run, aliases will accept any additional arguments\\n and append them to the stored alias.\\n "
msgstr "Создать псевдонимы для команд.\\n\\n Псевдонимы - это альтернативные сокращения имен для команд.\\n Они могут действовать как лямбда (хранение аргументов для\\n многократного использования) или просто как сокращение \\\"а б в\\\".\\n\\n При запуске псевдонимы принимают любые дополнительные\\n аргументы и добавляют их к сохраненному псевдониму.\\n "
msgstr "Создать синонимы для команд.\\n\\n Синонимы - это альтернативные сокращения имен для команд.\\n Они могут действовать как лямбда (хранение аргументов для\\n многократного использования) или просто как сокращение \\\"а б в\\\".\\n\\n При запуске синонимы принимают любые дополнительные\\n аргументы и добавляют их к сохраненному синониму.\\n "
#: redbot/cogs/alias/alias.py:86
msgid "No prefix found."
@@ -26,7 +26,7 @@ msgstr "Префикс не найден."
#: redbot/cogs/alias/alias.py:116
msgid "Aliases:\\n"
msgstr ""
msgstr "Синонимы:\\n"
#: redbot/cogs/alias/alias.py:118
msgid "\\n\\nPage {page}/{total}"
@@ -53,7 +53,7 @@ msgstr "Вы попытались создать новый псевдоним {
#: redbot/cogs/alias/alias.py:156
msgid "You attempted to create a new alias with the name {name} but that alias already exists."
msgstr ""
msgstr "Синоним {name} уже существует."
#: redbot/cogs/alias/alias.py:167
msgid "You attempted to create a new alias with the name {name} but that name is an invalid alias name. Alias names may not contain spaces."
@@ -61,7 +61,7 @@ msgstr "Вы попытались создать новый псевдоним {
#: redbot/cogs/alias/alias.py:179 redbot/cogs/alias/alias.py:238
msgid "You attempted to create a new alias for a command that doesn't exist."
msgstr "Вы попытались создать новый псевдоним для не существующей команды."
msgstr "Вы попытались создать новый синоним для не существующей команды."
#: redbot/cogs/alias/alias.py:193
msgid "A new alias with the trigger `{name}` has been created."
@@ -78,7 +78,7 @@ msgstr "Вы попытались создать новый глобальный
#: redbot/cogs/alias/alias.py:215
msgid "You attempted to create a new global alias with the name {name} but that alias already exists."
msgstr ""
msgstr "Общий синоним {name} уже существует."
#: redbot/cogs/alias/alias.py:226
msgid "You attempted to create a new global alias with the name {name} but that name is an invalid alias name. Alias names may not contain spaces."
@@ -134,7 +134,7 @@ msgstr "Удалить существующий глобальный псевд
#: redbot/cogs/alias/alias.py:298
msgid "There are no global aliases on this bot."
msgstr "У этого бота нет глобальных псевдонимов."
msgstr "У этого бота нет всеобщих синонимов."
#: redbot/cogs/alias/alias.py:312
#, docstring
@@ -148,7 +148,7 @@ msgstr "Список доступных глобальных псевдоним
#: redbot/cogs/alias/alias.py:324
msgid "There are no global aliases."
msgstr "Нет глобальных псевдонимов."
msgstr "Нет всеобщих синонимов."
#: redbot/cogs/alias/alias_entry.py:174
msgid "Arguments must be specified with a number."

View File

@@ -0,0 +1,9 @@
from redbot.core.bot import Red
from .core import Audio
def setup(bot: Red):
cog = Audio(bot)
bot.add_cog(cog)
cog.start_up_task()

View File

@@ -0,0 +1,10 @@
from . import (
api_utils,
global_db,
interface,
local_db,
playlist_interface,
playlist_wrapper,
spotify,
youtube,
)

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

View 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

View File

@@ -0,0 +1,894 @@
import asyncio
import datetime
import json
import logging
import random
import time
from collections import namedtuple
from typing import Callable, List, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union, cast
import aiohttp
import discord
import lavalink
from lavalink.rest_api import LoadResult
from redbot.core.utils import AsyncIter
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.commands import Cog, Context
from redbot.core.i18n import Translator
from redbot.core.utils.dbtools import APSWConnectionWrapper
from ..audio_dataclasses import Query
from ..audio_logging import IS_DEBUG, debug_exc_log
from ..errors import DatabaseError, SpotifyFetchError, TrackEnqueueError
from ..utils import CacheLevel, Notifier
from .global_db import GlobalCacheWrapper
from .local_db import LocalCacheWrapper
from .playlist_interface import get_playlist
from .playlist_wrapper import PlaylistWrapper
from .spotify import SpotifyWrapper
from .youtube import YouTubeWrapper
if TYPE_CHECKING:
from .. import Audio
_ = Translator("Audio", __file__)
log = logging.getLogger("red.cogs.Audio.api.AudioAPIInterface")
_TOP_100_US = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn5rWitrRWFKdm-ulaFiIyoK"
class AudioAPIInterface:
"""Handles music queries.
Always tries the Local cache first, then Global cache before making API calls.
"""
def __init__(
self,
bot: Red,
config: Config,
session: aiohttp.ClientSession,
conn: APSWConnectionWrapper,
cog: Union["Audio", Cog],
):
self.bot = bot
self.config = config
self.conn = conn
self.cog = cog
self.spotify_api: SpotifyWrapper = SpotifyWrapper(self.bot, self.config, session, self.cog)
self.youtube_api: YouTubeWrapper = YouTubeWrapper(self.bot, self.config, session, self.cog)
self.local_cache_api = LocalCacheWrapper(self.bot, self.config, self.conn, self.cog)
self.global_cache_api = GlobalCacheWrapper(self.bot, self.config, session, self.cog)
self._session: aiohttp.ClientSession = session
self._tasks: MutableMapping = {}
self._lock: asyncio.Lock = asyncio.Lock()
async def initialize(self) -> None:
"""Initialises the Local Cache connection"""
await self.local_cache_api.lavalink.init()
def close(self) -> None:
"""Closes the Local Cache connection"""
self.local_cache_api.lavalink.close()
async def get_random_track_from_db(self) -> Optional[MutableMapping]:
"""Get a random track from the local database and return it"""
track: Optional[MutableMapping] = {}
try:
query_data = {}
date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7)
date_timestamp = int(date.timestamp())
query_data["day"] = date_timestamp
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()))
query_data["maxage"] = maxage_int
track = await self.local_cache_api.lavalink.fetch_random(query_data)
if track is not None:
if track.get("loadType") == "V2_COMPACT":
track["loadType"] = "V2_COMPAT"
results = LoadResult(track)
track = random.choice(list(results.tracks))
except Exception as exc:
debug_exc_log(log, exc, "Failed to fetch a random track from database")
track = {}
if not track:
return None
return track
async def route_tasks(
self, action_type: str = None, data: Union[List[MutableMapping], MutableMapping] = None,
) -> None:
"""Separate the tasks and run them in the appropriate functions"""
if not data:
return
if action_type == "insert" and isinstance(data, list):
for table, d in data:
if table == "lavalink":
await self.local_cache_api.lavalink.insert(d)
elif table == "youtube":
await self.local_cache_api.youtube.insert(d)
elif table == "spotify":
await self.local_cache_api.spotify.insert(d)
elif action_type == "update" and isinstance(data, dict):
for table, d in data:
if table == "lavalink":
await self.local_cache_api.lavalink.update(data)
elif table == "youtube":
await self.local_cache_api.youtube.update(data)
elif table == "spotify":
await self.local_cache_api.spotify.update(data)
async def run_tasks(self, ctx: Optional[commands.Context] = None, message_id=None) -> None:
"""Run tasks for a specific context"""
if message_id is not None:
lock_id = message_id
elif ctx is not None:
lock_id = ctx.message.id
else:
return
lock_author = ctx.author if ctx else None
async with self._lock:
if lock_id in self._tasks:
if IS_DEBUG:
log.debug(f"Running database writes for {lock_id} ({lock_author})")
try:
tasks = self._tasks[lock_id]
tasks = [self.route_tasks(a, tasks[a]) for a in tasks]
await asyncio.gather(*tasks, return_exceptions=True)
del self._tasks[lock_id]
except Exception as exc:
debug_exc_log(
log, exc, f"Failed database writes for {lock_id} ({lock_author})"
)
else:
if IS_DEBUG:
log.debug(f"Completed database writes for {lock_id} ({lock_author})")
async def run_all_pending_tasks(self) -> None:
"""Run all pending tasks left in the cache, called on cog_unload"""
async with self._lock:
if IS_DEBUG:
log.debug("Running pending writes to database")
try:
tasks: MutableMapping = {"update": [], "insert": [], "global": []}
async for k, task in AsyncIter(self._tasks.items()):
async for t, args in AsyncIter(task.items()):
tasks[t].append(args)
self._tasks = {}
coro_tasks = [self.route_tasks(a, tasks[a]) for a in tasks]
await asyncio.gather(*coro_tasks, return_exceptions=True)
except Exception as exc:
debug_exc_log(log, exc, "Failed database writes")
else:
if IS_DEBUG:
log.debug("Completed pending writes to database have finished")
def append_task(self, ctx: commands.Context, event: str, task: Tuple, _id: int = None) -> None:
"""Add a task to the cache to be run later"""
lock_id = _id or ctx.message.id
if lock_id not in self._tasks:
self._tasks[lock_id] = {"update": [], "insert": [], "global": []}
self._tasks[lock_id][event].append(task)
async def fetch_spotify_query(
self,
ctx: commands.Context,
query_type: str,
uri: str,
notifier: Optional[Notifier],
skip_youtube: bool = False,
current_cache_level: CacheLevel = CacheLevel.none(),
) -> List[str]:
"""Return youtube URLS for the spotify URL provided"""
youtube_urls = []
tracks = await self.fetch_from_spotify_api(
query_type, uri, params=None, notifier=notifier, ctx=ctx
)
total_tracks = len(tracks)
database_entries = []
track_count = 0
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level)
async for track in AsyncIter(tracks):
if isinstance(track, str):
break
elif isinstance(track, dict) and track.get("error", {}).get("message") == "invalid id":
continue
(
song_url,
track_info,
uri,
artist_name,
track_name,
_id,
_type,
) = await self.spotify_api.get_spotify_track_info(track, ctx)
database_entries.append(
{
"id": _id,
"type": _type,
"uri": uri,
"track_name": track_name,
"artist_name": artist_name,
"song_url": song_url,
"track_info": track_info,
"last_updated": time_now,
"last_fetched": time_now,
}
)
if skip_youtube is False:
val = None
if youtube_cache:
try:
(val, last_update) = await self.local_cache_api.youtube.fetch_one(
{"track": track_info}
)
except Exception as exc:
debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table")
if val is None:
val = await self.fetch_youtube_query(
ctx, track_info, current_cache_level=current_cache_level
)
if youtube_cache and val:
task = ("update", ("youtube", {"track": track_info}))
self.append_task(ctx, *task)
if val:
youtube_urls.append(val)
else:
youtube_urls.append(track_info)
track_count += 1
if notifier is not None and ((track_count % 2 == 0) or (track_count == total_tracks)):
await notifier.notify_user(current=track_count, total=total_tracks, key="youtube")
if CacheLevel.set_spotify().is_subset(current_cache_level):
task = ("insert", ("spotify", database_entries))
self.append_task(ctx, *task)
return youtube_urls
async def fetch_from_spotify_api(
self,
query_type: str,
uri: str,
recursive: Union[str, bool] = False,
params: MutableMapping = None,
notifier: Optional[Notifier] = None,
ctx: Context = None,
) -> Union[List[MutableMapping], List[str]]:
"""Gets track info from spotify API"""
if recursive is False:
(call, params) = self.spotify_api.spotify_format_call(query_type, uri)
results = await self.spotify_api.make_get_call(call, params)
else:
if isinstance(recursive, str):
results = await self.spotify_api.make_get_call(recursive, params)
else:
results = {}
try:
if results["error"]["status"] == 401 and not recursive:
raise SpotifyFetchError(
_(
"The Spotify API key or client secret has not been set properly. "
"\nUse `{prefix}audioset spotifyapi` for instructions."
)
)
elif recursive:
return {"next": None}
except KeyError:
pass
if recursive:
return results
tracks = []
track_count = 0
total_tracks = results.get("tracks", results).get("total", 1)
while True:
new_tracks: List = []
if query_type == "track":
new_tracks = results
tracks.append(new_tracks)
elif query_type == "album":
tracks_raw = results.get("tracks", results).get("items", [])
if tracks_raw:
new_tracks = tracks_raw
tracks.extend(new_tracks)
else:
tracks_raw = results.get("tracks", results).get("items", [])
if tracks_raw:
new_tracks = [k["track"] for k in tracks_raw if k.get("track")]
tracks.extend(new_tracks)
track_count += len(new_tracks)
if notifier:
await notifier.notify_user(current=track_count, total=total_tracks, key="spotify")
try:
if results.get("next") is not None:
results = await self.fetch_from_spotify_api(
query_type, uri, results["next"], params, notifier=notifier
)
continue
else:
break
except KeyError:
raise SpotifyFetchError(
_("This doesn't seem to be a valid Spotify playlist/album URL or code.")
)
return tracks
async def spotify_query(
self,
ctx: commands.Context,
query_type: str,
uri: str,
skip_youtube: bool = False,
notifier: Optional[Notifier] = None,
) -> List[str]:
"""Queries the Database then falls back to Spotify and YouTube APIs.
Parameters
----------
ctx: commands.Context
The context this method is being called under.
query_type : str
Type of query to perform (Pl
uri: str
Spotify URL ID.
skip_youtube:bool
Whether or not to skip YouTube API Calls.
notifier: Notifier
A Notifier object to handle the user UI notifications while tracks are loaded.
Returns
-------
List[str]
List of Youtube URLs.
"""
current_cache_level = CacheLevel(await self.config.cache_level())
cache_enabled = CacheLevel.set_spotify().is_subset(current_cache_level)
if query_type == "track" and cache_enabled:
try:
(val, last_update) = await self.local_cache_api.spotify.fetch_one(
{"uri": f"spotify:track:{uri}"}
)
except Exception as exc:
debug_exc_log(
log, exc, f"Failed to fetch 'spotify:track:{uri}' from Spotify table"
)
val = None
else:
val = None
youtube_urls = []
if val is None:
urls = await self.fetch_spotify_query(
ctx,
query_type,
uri,
notifier,
skip_youtube,
current_cache_level=current_cache_level,
)
youtube_urls.extend(urls)
else:
if query_type == "track" and cache_enabled:
task = ("update", ("spotify", {"uri": f"spotify:track:{uri}"}))
self.append_task(ctx, *task)
youtube_urls.append(val)
return youtube_urls
async def spotify_enqueue(
self,
ctx: commands.Context,
query_type: str,
uri: str,
enqueue: bool,
player: lavalink.Player,
lock: Callable,
notifier: Optional[Notifier] = None,
forced: bool = False,
query_global: bool = False,
) -> List[lavalink.Track]:
"""Queries the Database then falls back to Spotify and YouTube APIs then Enqueued matched tracks.
Parameters
----------
ctx: commands.Context
The context this method is being called under.
query_type : str
Type of query to perform (Pl
uri: str
Spotify URL ID.
enqueue:bool
Whether or not to enqueue the tracks
player: lavalink.Player
The current Player.
notifier: Notifier
A Notifier object to handle the user UI notifications while tracks are loaded.
lock: Callable
A callable handling the Track enqueue lock while spotify tracks are being added.
query_global: bool
Whether or not to query the global API.
forced: bool
Ignore Cache and make a fetch from API.
Returns
-------
List[str]
List of Youtube URLs.
"""
# globaldb_toggle = await self.config.global_db_enabled()
track_list: List = []
has_not_allowed = False
try:
current_cache_level = CacheLevel(await self.config.cache_level())
guild_data = await self.config.guild(ctx.guild).all()
enqueued_tracks = 0
consecutive_fails = 0
queue_dur = await self.cog.queue_duration(ctx)
queue_total_duration = self.cog.format_time(queue_dur)
before_queue_length = len(player.queue)
tracks_from_spotify = await self.fetch_from_spotify_api(
query_type, uri, params=None, notifier=notifier
)
total_tracks = len(tracks_from_spotify)
if total_tracks < 1 and notifier is not None:
lock(ctx, False)
embed3 = discord.Embed(
colour=await ctx.embed_colour(),
title=_("This doesn't seem to be a supported Spotify URL or code."),
)
await notifier.update_embed(embed3)
return track_list
database_entries = []
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level)
spotify_cache = CacheLevel.set_spotify().is_subset(current_cache_level)
async for track_count, track in AsyncIter(tracks_from_spotify).enumerate(start=1):
(
song_url,
track_info,
uri,
artist_name,
track_name,
_id,
_type,
) = await self.spotify_api.get_spotify_track_info(track, ctx)
database_entries.append(
{
"id": _id,
"type": _type,
"uri": uri,
"track_name": track_name,
"artist_name": artist_name,
"song_url": song_url,
"track_info": track_info,
"last_updated": time_now,
"last_fetched": time_now,
}
)
val = None
llresponse = None
if youtube_cache:
try:
(val, last_updated) = await self.local_cache_api.youtube.fetch_one(
{"track": track_info}
)
except Exception as exc:
debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table")
if val is None:
val = await self.fetch_youtube_query(
ctx, track_info, current_cache_level=current_cache_level
)
if youtube_cache and val and llresponse is None:
task = ("update", ("youtube", {"track": track_info}))
self.append_task(ctx, *task)
if llresponse is not None:
track_object = llresponse.tracks
elif val:
try:
(result, called_api) = await self.fetch_track(
ctx,
player,
Query.process_input(val, self.cog.local_folder_current_path),
forced=forced,
)
except (RuntimeError, aiohttp.ServerDisconnectedError):
lock(ctx, False)
error_embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("The connection was reset while loading the playlist."),
)
if notifier is not None:
await notifier.update_embed(error_embed)
break
except asyncio.TimeoutError:
lock(ctx, False)
error_embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Player timeout, skipping remaining tracks."),
)
if notifier is not None:
await notifier.update_embed(error_embed)
break
track_object = result.tracks
else:
track_object = []
if (track_count % 2 == 0) or (track_count == total_tracks):
key = "lavalink"
seconds = "???"
second_key = None
if notifier is not None:
await notifier.notify_user(
current=track_count,
total=total_tracks,
key=key,
seconds_key=second_key,
seconds=seconds,
)
if consecutive_fails >= 10:
error_embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Failing to get tracks, skipping remaining."),
)
if notifier is not None:
await notifier.update_embed(error_embed)
break
if not track_object:
consecutive_fails += 1
continue
consecutive_fails = 0
single_track = track_object[0]
if not await self.cog.is_query_allowed(
self.config,
ctx.guild,
(
f"{single_track.title} {single_track.author} {single_track.uri} "
f"{Query.process_input(single_track, self.cog.local_folder_current_path)}"
),
):
has_not_allowed = True
if IS_DEBUG:
log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})")
continue
track_list.append(single_track)
if enqueue:
if len(player.queue) >= 10000:
continue
if guild_data["maxlength"] > 0:
if self.cog.is_track_length_allowed(single_track, guild_data["maxlength"]):
enqueued_tracks += 1
player.add(ctx.author, single_track)
self.bot.dispatch(
"red_audio_track_enqueue",
player.channel.guild,
single_track,
ctx.author,
)
else:
enqueued_tracks += 1
player.add(ctx.author, single_track)
self.bot.dispatch(
"red_audio_track_enqueue",
player.channel.guild,
single_track,
ctx.author,
)
if not player.current:
await player.play()
if not track_list and not has_not_allowed:
raise SpotifyFetchError(
message=_(
"Nothing found.\nThe YouTube API key may be invalid "
"or you may be rate limited on YouTube's search service.\n"
"Check the YouTube API key again and follow the instructions "
"at `{prefix}audioset youtubeapi`."
)
)
player.maybe_shuffle()
if enqueue and tracks_from_spotify:
if total_tracks > enqueued_tracks:
maxlength_msg = _(" {bad_tracks} tracks cannot be queued.").format(
bad_tracks=(total_tracks - enqueued_tracks)
)
else:
maxlength_msg = ""
embed = discord.Embed(
colour=await ctx.embed_colour(),
title=_("Playlist Enqueued"),
description=_("Added {num} tracks to the queue.{maxlength_msg}").format(
num=enqueued_tracks, 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 notifier is not None:
await notifier.update_embed(embed)
lock(ctx, False)
if spotify_cache:
task = ("insert", ("spotify", database_entries))
self.append_task(ctx, *task)
except Exception as exc:
lock(ctx, False)
raise exc
finally:
lock(ctx, False)
return track_list
async def fetch_youtube_query(
self,
ctx: commands.Context,
track_info: str,
current_cache_level: CacheLevel = CacheLevel.none(),
) -> Optional[str]:
"""
Call the Youtube API and returns the youtube URL that the query matched
"""
track_url = await self.youtube_api.get_call(track_info)
if CacheLevel.set_youtube().is_subset(current_cache_level) and track_url:
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
task = (
"insert",
(
"youtube",
[
{
"track_info": track_info,
"track_url": track_url,
"last_updated": time_now,
"last_fetched": time_now,
}
],
),
)
self.append_task(ctx, *task)
return track_url
async def fetch_from_youtube_api(
self, ctx: commands.Context, track_info: str
) -> Optional[str]:
"""
Gets an YouTube URL from for the query
"""
current_cache_level = CacheLevel(await self.config.cache_level())
cache_enabled = CacheLevel.set_youtube().is_subset(current_cache_level)
val = None
if cache_enabled:
try:
(val, update) = await self.local_cache_api.youtube.fetch_one({"track": track_info})
except Exception as exc:
debug_exc_log(log, exc, f"Failed to fetch {track_info} from YouTube table")
if val is None:
youtube_url = await self.fetch_youtube_query(
ctx, track_info, current_cache_level=current_cache_level
)
else:
if cache_enabled:
task = ("update", ("youtube", {"track": track_info}))
self.append_task(ctx, *task)
youtube_url = val
return youtube_url
async def fetch_track(
self,
ctx: commands.Context,
player: lavalink.Player,
query: Query,
forced: bool = False,
lazy: bool = False,
should_query_global: bool = True,
) -> Tuple[LoadResult, bool]:
"""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.
Parameters
----------
ctx: commands.Context
The context this method is being called under.
player : lavalink.Player
The player who's requesting the query.
query: audio_dataclasses.Query
The Query object for the query in question.
forced:bool
Whether or not to skip cache and call API first.
lazy:bool
If set to True, it will not call the api if a track is not found.
should_query_global:bool
If the method should query the global database.
Returns
-------
Tuple[lavalink.LoadResult, bool]
Tuple with the Load result and whether or not the API was called.
"""
current_cache_level = CacheLevel(await self.config.cache_level())
cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level)
val = None
query = Query.process_input(query, self.cog.local_folder_current_path)
query_string = str(query)
valid_global_entry = False
results = None
called_api = False
prefer_lyrics = await self.cog.get_lyrics_status(ctx)
if prefer_lyrics and query.is_youtube and query.is_search:
query_string = f"{query} - lyrics"
if cache_enabled and not forced and not query.is_local:
try:
(val, last_updated) = await self.local_cache_api.lavalink.fetch_one(
{"query": query_string}
)
except Exception as exc:
debug_exc_log(log, exc, f"Failed to fetch '{query_string}' from Lavalink table")
if val and isinstance(val, dict):
if IS_DEBUG:
log.debug(f"Updating Local Database with {query_string}")
task = ("update", ("lavalink", {"query": query_string}))
self.append_task(ctx, *task)
else:
val = None
if val and not forced and isinstance(val, dict):
valid_global_entry = False
called_api = False
else:
val = None
if valid_global_entry:
pass
elif lazy is True:
called_api = False
elif val and not forced and isinstance(val, dict):
data = val
data["query"] = query_string
if data.get("loadType") == "V2_COMPACT":
data["loadType"] = "V2_COMPAT"
results = LoadResult(data)
called_api = False
if results.has_error:
# If cached value has an invalid entry make a new call so that it gets updated
results, called_api = await self.fetch_track(ctx, player, query, forced=True)
else:
if IS_DEBUG:
log.debug(f"Querying Lavalink api for {query_string}")
called_api = True
try:
results = await player.load_tracks(query_string)
except KeyError:
results = None
except RuntimeError:
raise TrackEnqueueError
if results is None:
results = LoadResult({"loadType": "LOAD_FAILED", "playlistInfo": {}, "tracks": []})
if (
cache_enabled
and results.load_type
and not results.has_error
and not query.is_local
and results.tracks
):
try:
time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
data = json.dumps(results._raw)
if all(k in data for k in ["loadType", "playlistInfo", "isSeekable", "isStream"]):
task = (
"insert",
(
"lavalink",
[
{
"query": query_string,
"data": data,
"last_updated": time_now,
"last_fetched": time_now,
}
],
),
)
self.append_task(ctx, *task)
except Exception as exc:
debug_exc_log(
log,
exc,
f"Failed to enqueue write task for '{query_string}' to Lavalink table",
)
return results, called_api
async def autoplay(self, player: lavalink.Player, playlist_api: PlaylistWrapper):
"""
Enqueue a random track
"""
autoplaylist = await self.config.guild(player.channel.guild).autoplaylist()
current_cache_level = CacheLevel(await self.config.cache_level())
cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level)
playlist = None
tracks = None
if autoplaylist["enabled"]:
try:
playlist = await get_playlist(
autoplaylist["id"],
autoplaylist["scope"],
self.bot,
playlist_api,
player.channel.guild,
player.channel.guild.me,
)
tracks = playlist.tracks_obj
except Exception as exc:
debug_exc_log(log, exc, "Failed to fetch playlist for autoplay")
if not tracks or not getattr(playlist, "tracks", None):
if cache_enabled:
track = await self.get_random_track_from_db()
tracks = [] if not track else [track]
if not tracks:
ctx = namedtuple("Context", "message guild cog")
(results, called_api) = await self.fetch_track(
cast(
commands.Context, ctx(player.channel.guild, player.channel.guild, self.cog)
),
player,
Query.process_input(_TOP_100_US, self.cog.local_folder_current_path),
)
tracks = list(results.tracks)
if tracks:
multiple = len(tracks) > 1
valid = not multiple
tries = len(tracks)
track = tracks[0]
while valid is False and multiple:
tries -= 1
if tries <= 0:
raise DatabaseError("No valid entry found")
track = random.choice(tracks)
query = Query.process_input(track, self.cog.local_folder_current_path)
await asyncio.sleep(0.001)
if not query.valid or (
query.is_local
and query.local_track_path is not None
and not query.local_track_path.exists()
):
continue
if not await self.cog.is_query_allowed(
self.config,
player.channel.guild,
(
f"{track.title} {track.author} {track.uri} "
f"{str(Query.process_input(track, self.cog.local_folder_current_path))}"
),
):
if IS_DEBUG:
log.debug(
"Query is not allowed in "
f"{player.channel.guild} ({player.channel.guild.id})"
)
continue
valid = True
track.extras["autoplay"] = True
player.add(player.channel.guild.me, track)
self.bot.dispatch(
"red_audio_track_auto_play", player.channel.guild, track, player.channel.guild.me
)
if not player.current:
await player.play()

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

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Afrikaans\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: af\n"
"X-Crowdin-File-ID: 698\n"
"Language: af_ZA\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: ar\n"
"X-Crowdin-File-ID: 698\n"
"Language: ar_SA\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: bg\n"
"X-Crowdin-File-ID: 698\n"
"Language: bg_BG\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Catalan\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: ca\n"
"X-Crowdin-File-ID: 698\n"
"Language: ca_ES\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: cs\n"
"X-Crowdin-File-ID: 698\n"
"Language: cs_CZ\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API klíč nebo klientský tajný klíč nebyl správně nastaven. \\nPro pokyny použijte `{prefix}audioset spotifyapi`."
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "Nezdá se, že by to byla platná adresa Spotify playlistu/alba nebo kód."
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "Pravděpodobně se nejedná o podporovaný Spotify odkaz nebo kód."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "Připojení bylo obnoveno při načítání seznamu skladeb."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "Vypršel časový limit přehrávače, přeskakuji zbývající skladby."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "Nepodařilo se získat skladby, zbývá přeskakování."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "Nic nenalezeno.\\nYouTube API klíč může být neplatný nebo může být omezen na YouTube's vyhledávací službu.\\nPodívejte se znovu na YouTube API klíč a postupujte podle instrukcí na `{prefix}audioset youtubeapi`."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr " {bad_tracks} skladby nemůžou být zařazeny do fronty."
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Playlist zařazen do fronty"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "Přidáno {num} skladeb do fronty.{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time} do začátku přehrávání playlistu: je na #{position} pozici ve frontě"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API klíč nebo klientský tajný klíč nebyl správně nastaven. \\nPro pokyny použijte `{prefix}audioset spotifyapi`."

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: da\n"
"X-Crowdin-File-ID: 698\n"
"Language: da_DK\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: German\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File-ID: 698\n"
"Language: de_DE\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Der Spotify API Key oder das Client secret wurden nicht richtig eingestellt.\\n Benutze `{prefix}audioset spotifyapi` für eine Anleitung."
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "Dies scheint keine gültige Spotify-Playlist/Album-URL oder Spotify-Code zu sein."
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "Dies scheint keine unterstützte Spotify-URL oder Spotify-Code zu sein."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "Die Verbindung wurde zurückgesetzt beim Laden der Playlist."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "Audioplayer-Timeout. Verbleibende Titel werden übersprungen."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "Fehler beim laden der Tracks. Verbleibende Tracks werden übersprungen."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "Nichts Gefunden.\\n Der Youtube API Key könnte falsch sein oder du überschreitest das Rate Limit der Youtube Suche.\\n Kontrollieren den Youtube API Key nocheinmal und dann folge der Anleitung bei `{prefix}audioset youtubeapi`."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr " {bad_tracks} Tracks können nicht zur Warteschlange hinzugefügt werden."
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Wiedergabeliste eingereiht"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "Es wurden {num} Tracks zu der Playlist hinzugefügt.{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time} bis zum Start der Wiedergabeliste: beginnt bei #{position} in der Warteschlange"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Der Spotify API Key oder dar Client secret wurden nicht richtig eingestellt.\\n Benutze `{prefix}audioset spotifyapi` für eine Anleitung."

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Greek\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: el\n"
"X-Crowdin-File-ID: 698\n"
"Language: el_GR\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File-ID: 698\n"
"Language: es_ES\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Lista de reproducción en cola"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Finnish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: fi\n"
"X-Crowdin-File-ID: 698\n"
"Language: fi_FI\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: French\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File-ID: 698\n"
"Language: fr_FR\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "La clé API de Spotify ou le secret client n'ont pas étés correctement définis. \\nUtilisez `{prefix}audioset spotifyapi` pour connaître la marche à suivre."
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "Cela ne semble pas être une URL ou un album/playlist Spotify valide."
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "Cela ne semble pas être une URL ou un code Spotify pris en charge."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "La connexion a été réinitialisée lors du chargement de la playlist."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "Arrêt du lecteur, pistes restantes ignoré."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "Impossible d'obtenir les pistes, pistes ignoré."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "Rien n'a été trouvé.\\nLa clé de l'API YouTube peut être invalide ou vous pouvez être limité sur le service de recherche de YouTube.\\nVérifiez à nouveau la clé de l'API YouTube et suivez les instructions à `{prefix}audioset youtubeapi`."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr " {bad_tracks} pistes ne peuvent pas être mises en attente."
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Playlist en file dattente"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "Ajout de {num} pistes à la file d'attente.{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time} avant le début de la lecture de la playlist : commence à #{position} dans la liste"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "La clé API de Spotify ou le secret client n'ont pas étés correctement définis. \\nUtilisez `{prefix}audioset spotifyapi` pour connaître la marche à suivre."

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Hebrew\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: he\n"
"X-Crowdin-File-ID: 698\n"
"Language: he_IL\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Hungarian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: hu\n"
"X-Crowdin-File-ID: 698\n"
"Language: hu_HU\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Indonesian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: id\n"
"X-Crowdin-File-ID: 698\n"
"Language: id_ID\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: it\n"
"X-Crowdin-File-ID: 698\n"
"Language: it_IT\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Japanese\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: ja\n"
"X-Crowdin-File-ID: 698\n"
"Language: ja_JP\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: ko\n"
"X-Crowdin-File-ID: 698\n"
"Language: ko_KR\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API 키 또는 client secret이 올바르게 설정되지 않았습니다. \\n`{prefix}audioset spotifyapi`을 사용하여 지침을 확인하세요."
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "유효한 Spotify 재생 목록, 앨범 URL 또는 코드가 아닌것 같습니다."
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "지원되는 Spotify URL 또는 코드가 아닌 것 같습니다."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "재생 목록을 로드하는 동안 연결이 재설정되었습니다."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "플래이어 시간이 초과되었습니다. 남은 트랙들을 건너 뜁니다."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "트랙을 추가하지 못해 나머지는 건너 뜁니다."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "검색 결과가 없습니다.\\n 유튜브 API키가 유효하지 않거나 유튜브 검색 서비스 제한이 걸렸을 수도 있습니다. \\n유튜브 API 키를 다시 확인하고 `{prefix}audioset youtubeapi`.에서 지침을 확인하세요."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr " {bad_tracks} 을 대기열에 추가할 수 없습니다."
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "대기중인 재생 목록"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "대기열에 {num} 개의 트랙이 추가되었습니다. {maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "재생 목록 재생이 시작될 때까지 {time} 남았습니다.: 대기열의 #{position} 에 시작합니다."
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "You deleted the translation \"Spotify API 키 또는 client secret이 올바르게 설정되지 않았습니다. \\n`{prefix}audioset spotifyapi`를 사용하여 명령어들을 확인하세요.\""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File-ID: 698\n"
"Language: nl_NL\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Afspeellijst toegevoegd"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time} tot het begin van het afspelen van de afspeellijst: begint bij #{position} in de wachtrij"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Norwegian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: no\n"
"X-Crowdin-File-ID: 698\n"
"Language: no_NO\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: pl\n"
"X-Crowdin-File-ID: 698\n"
"Language: pl_PL\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Portuguese, Brazilian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: pt-BR\n"
"X-Crowdin-File-ID: 698\n"
"Language: pt_BR\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "Isto não parece ser uma URL ou código do Spotify válido."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "A conexão foi redefinida durante o carregamento da lista de reprodução."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "Tempo limite do reprodutor atingido; saltando as faixas restantes."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "Falha ao obter as faixas; saltando as faixas restantes."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "Nada encontrado.\\nA chave de API do YouTube pode ser inválida ou você pode estar sendo limitado pelas cotas do serviço de busca do YouTube.\\nVerifique a chave de API do YouTube novamente e siga as instruções em `{prefix}audioset youtubeapi`."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Lista de reprodução enfileirada"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "{num} faixas enfileiradas.{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time} até o início da reprodução da lista: começa na posição #{position} da fila"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: pt-PT\n"
"X-Crowdin-File-ID: 698\n"
"Language: pt_PT\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Romanian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: ro\n"
"X-Crowdin-File-ID: 698\n"
"Language: ro_RO\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: ru\n"
"X-Crowdin-File-ID: 698\n"
"Language: ru_RU\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "API ключ Spotify или секрет клиента были установлены неправильно. \\nДля получения инструкций используйте `{prefix}audioset spotifyapi`."
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "Не является правильным Spotify плейлистом/альбомом URL или кодом."
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "Это не похоже на поддерживаемый Spotify URL или код."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "Соединение было сброшено при загрузке плейлиста."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "Тайм-аут проигрывателя, пропуск оставшихся треков."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "Не удалось получить треки, пропускаю оставшиеся треки."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "Ничего не найдено.\\nКлюч YouTube API может быть недействительным или вы можете оценить его в поисковой службе YouTube.\\nПроверьте YouTube API еще раз и следуйте инструкциям в `{prefix}audioset youtubeapi`."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr " {bad_tracks} невозможно добавить в очередь."
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Плейлист добавлен в очередь"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "{num} треков добавлено в очередь.{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time} до начала воспроизведения плейлиста: начинается с #{position} в очереди"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "API ключ Spotify или секрет клиента были установлены неправильно. \\nДля получения инструкций используйте `{prefix}audioset spotifyapi`."

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Slovak\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: sk\n"
"X-Crowdin-File-ID: 698\n"
"Language: sk_SK\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Serbian (Cyrillic)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: sr\n"
"X-Crowdin-File-ID: 698\n"
"Language: sr_SP\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: sv-SE\n"
"X-Crowdin-File-ID: 698\n"
"Language: sv_SE\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: tr\n"
"X-Crowdin-File-ID: 698\n"
"Language: tr_TR\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API veya client secret'ı düzgün bir şekilde ayarlanmamış. \\n `{prefix}audioset spotifyapi` komutundan bilgi alabilirsiniz."
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "Bu geçerli bir Spotify çalma listesi / albüm URL'si veya Kodu gibi görünmüyor."
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "Bu geçerli bir Spotify URL'si ya da kodu gibi gözükmüyor."
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "Playlist yüklenirken bağlantı yenilendi."
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "Oynatıcı zaman aşımına uğradı, kalan parçalar atlanıyor."
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "Parça alınamıyor, atlanıyor."
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "Hiçbir şey bulunamadı.\\nYouTube API keyi yanlış ya da API kullanımınız sınırlandırılmış.\\nYouTube API keyinizi kontrol edin ve `{prefix}audioset youtubeapi`'de ki yönlendirmeleri takip edin."
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr " {bad_tracks} parçalar sıraya alınamaz."
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "Playlist sıraya alındı"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "{num} adet şarkı sıraya eklendi.{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "Playlistin başlamasına {time} süre var: #{position} sırasında başlar"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API veya client secret'ı düzgün bir şekilde ayarlanmamış. \\n `{prefix}audioset spotifyapi` komutundan bilgi alabilirsiniz."

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: uk\n"
"X-Crowdin-File-ID: 698\n"
"Language: uk_UA\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Vietnamese\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: vi\n"
"X-Crowdin-File-ID: 698\n"
"Language: vi_VN\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: zh-CN\n"
"X-Crowdin-File-ID: 698\n"
"Language: zh_CN\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional, Hong Kong\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: zh-HK\n"
"X-Crowdin-File-ID: 698\n"
"Language: zh_HK\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr ""
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr ""
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr ""
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr ""

View File

@@ -0,0 +1,65 @@
msgid ""
msgstr ""
"Project-Id-Version: red-discordbot\n"
"POT-Creation-Date: 2020-05-21 12:08+0000\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: red-discordbot\n"
"X-Crowdin-Project-ID: 289505\n"
"X-Crowdin-Language: zh-TW\n"
"X-Crowdin-File-ID: 698\n"
"Language: zh_TW\n"
#: redbot/cogs/audio/apis/interface.py:280
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API key或client secret未正確設置。\\n請使用`{prefix} audioset spotifyapi`獲取說明。"
#: redbot/cogs/audio/apis/interface.py:322
msgid "This doesn't seem to be a valid Spotify playlist/album URL or code."
msgstr "這似乎不是有效的Spotify播放列表/專輯URL或代碼。"
#: redbot/cogs/audio/apis/interface.py:443
msgid "This doesn't seem to be a supported Spotify URL or code."
msgstr "這似乎不是支持的Spotify URL或代碼。"
#: redbot/cogs/audio/apis/interface.py:509
msgid "The connection was reset while loading the playlist."
msgstr "加載播放列表時重置了連接。"
#: redbot/cogs/audio/apis/interface.py:518
msgid "Player timeout, skipping remaining tracks."
msgstr "播放器超時,跳過剩餘歌曲。"
#: redbot/cogs/audio/apis/interface.py:542
msgid "Failing to get tracks, skipping remaining."
msgstr "無法取得歌曲,跳過剩餘的歌曲。"
#: redbot/cogs/audio/apis/interface.py:592
msgid "Nothing found.\\nThe YouTube API key may be invalid or you may be rate limited on YouTube's search service.\\nCheck the YouTube API key again and follow the instructions at `{prefix}audioset youtubeapi`."
msgstr "找不到任何內容。\\n您的YouTube API key可能是無效的或者您在YouTube的搜索服務上受到速率限制。\\n請檢查YouTube API key然後按照`{prefix}audioset youtubeapi`中的說明進行操作。"
#: redbot/cogs/audio/apis/interface.py:602
msgid " {bad_tracks} tracks cannot be queued."
msgstr "{bad_tracks}首歌曲加載失敗。"
#: redbot/cogs/audio/apis/interface.py:610
msgid "Playlist Enqueued"
msgstr "已加入播放清單"
#: redbot/cogs/audio/apis/interface.py:611
msgid "Added {num} tracks to the queue.{maxlength_msg}"
msgstr "已將{num}首歌曲添加到播放清單中。{maxlength_msg}"
#: redbot/cogs/audio/apis/interface.py:617
msgid "{time} until start of playlist playback: starts at #{position} in queue"
msgstr "{time}後播放: 在播放清單的#{position}首之後"
#: redbot/cogs/audio/apis/spotify.py:165
msgid "The Spotify API key or client secret has not been set properly. \\nUse `{prefix}audioset spotifyapi` for instructions."
msgstr "Spotify API key或client secret未正確設置。\\n請使用`{prefix} audioset spotifyapi`獲取說明。"

View File

@@ -0,0 +1,647 @@
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 ..errors import NotAllowed
from ..utils import PlaylistScope
from .api_utils import PlaylistFetchResult, prepare_config_scope, standardize_scope
from .playlist_wrapper import PlaylistWrapper
log = logging.getLogger("red.cogs.Audio.api.PlaylistsInterface")
class Playlist:
"""A single playlist."""
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.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)
self.guild_id = (
getattr(guild, "id", guild) if self.scope == PlaylistScope.GLOBAL.value else None
)
self.id = playlist_id
self.name = name
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 (
f"Playlist(name={self.name}, id={self.id}, scope={self.scope}, "
f"scope_id={self.scope_id}, author={self.author_id}, "
f"tracks={len(self.tracks)}, url={self.url})"
)
async def edit(self, data: MutableMapping):
"""
Edits a Playlist.
Parameters
----------
data: dict
The attributes to change.
"""
# Disallow ID editing
if "id" in data:
raise NotAllowed("Playlist ID cannot be edited.")
for item in list(data.keys()):
setattr(self, item, data[item])
await self.save()
return self
async def save(self):
"""Saves a Playlist."""
scope, scope_id = self.config_scope
await self.playlist_api.upsert(
scope,
playlist_id=int(self.id),
playlist_name=self.name,
scope_id=scope_id,
author_id=self.author_id,
playlist_url=self.url,
tracks=self.tracks,
)
def to_json(self) -> MutableMapping:
"""Transform the object to a dict.
Returns
-------
dict
The playlist in the form of a dict.
"""
data = dict(
id=self.id,
author=self.author_id,
guild=self.guild_id,
name=self.name,
playlist_url=self.url,
tracks=self.tracks,
)
return data
@classmethod
async def from_json(
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
The playlist's number.
data: PlaylistFetchResult
The PlaylistFetchResult 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.scope_id if scope == PlaylistScope.GUILD.value else kwargs.get("guild")
author = data.author_id
playlist_id = data.playlist_id or playlist_number
name = data.playlist_name
playlist_url = data.playlist_url
tracks = data.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,
)
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:
"""
Gets the playlist with the associated playlist number.
Parameters
----------
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
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.
bot: Red
The bot's instance.
Returns
-------
Playlist
The playlist associated with the playlist number.
Raises
------
`RuntimeError`
If there is no playlist for the specified number.
`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.
"""
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,
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,
) -> List[Playlist]:
"""
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.
author: int
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
------
`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.
"""
scope_standard, scope_id = prepare_config_scope(bot, scope, author, guild)
if specified_user:
user_id = getattr(author, "id", author)
playlists = await playlist_api.fetch_all(scope_standard, scope_id, author_id=user_id)
else:
playlists = await playlist_api.fetch_all(scope_standard, scope_id)
playlist_list = []
async for playlist in AsyncIter(playlists):
playlist_list.append(
await Playlist.from_json(
bot,
playlist_api,
scope,
playlist.playlist_id,
playlist,
guild=guild,
author=author,
)
)
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,
) -> List[Playlist]:
"""
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.
author: int
The ID of the user to get the playlist from if scope is USERPLAYLIST.
bot: Red
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
------
`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.
"""
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 = []
async for playlist in AsyncIter(playlists):
playlist_list.append(
await Playlist.from_json(
bot,
playlist_api,
scope,
playlist.playlist_id,
playlist,
guild=guild,
author=author,
)
)
return playlist_list
async def create_playlist(
ctx: commands.Context,
playlist_api: PlaylistWrapper,
scope: str,
playlist_name: str,
playlist_url: Optional[str] = None,
tracks: Optional[List[MutableMapping]] = None,
author: Optional[discord.User] = None,
guild: Optional[discord.Guild] = None,
) -> Optional[Playlist]:
"""Creates a new Playlist.
Parameters
----------
ctx: commands.Context
The context in which the play list is being created.
scope: str
The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'.
playlist_name: str
The name of the new playlist.
playlist_url:str
the url of the new playlist.
tracks: List[MutableMapping]
A list of tracks to add to the playlist.
author: discord.User
The Author of the playlist.
If provided it will create a playlist under this user.
This is only required when creating a playlist in User scope.
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
------
`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.
"""
playlist = Playlist(
ctx.bot,
playlist_api,
scope,
author.id if author else None,
ctx.message.id,
playlist_name,
playlist_url,
tracks,
guild or ctx.guild,
)
await playlist.save()
return 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,
) -> None:
"""Wipes all playlists for the specified scope.
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
------
`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.
"""
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,
author: Union[discord.abc.User, int] = None,
) -> None:
"""Deletes the specified 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]
The ID of the playlist.
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
------
`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.
"""
scope, scope_id = prepare_config_scope(bot, scope, author, guild)
await playlist_api.delete(scope, int(playlist_id), scope_id)

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

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

View 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

View File

@@ -0,0 +1,683 @@
import contextlib
import glob
import logging
import ntpath
import os
import posixpath
import re
from pathlib import Path, PosixPath, WindowsPath
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
_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)
_FULLY_SUPPORTED_MUSIC_EXT: Final[Tuple[str, ...]] = (".mp3", ".flac", ".ogg")
_PARTIALLY_SUPPORTED_MUSIC_EXT: Tuple[str, ...] = (
".m3u",
".m4a",
".aac",
".ra",
".wav",
".opus",
".wma",
".ts",
".au",
# These do not work
# ".mid",
# ".mka",
# ".amr",
# ".aiff",
# ".ac3",
# ".voc",
# ".dsf",
)
_PARTIALLY_SUPPORTED_VIDEO_EXT: Tuple[str, ...] = (
".mp4",
".mov",
".flv",
".webm",
".mkv",
".wmv",
".3gp",
".m4v",
".mk3d", # https://github.com/Devoxin/lavaplayer
".mka", # https://github.com/Devoxin/lavaplayer
".mks", # https://github.com/Devoxin/lavaplayer
# These do not work
# ".vob",
# ".mts",
# ".avi",
# ".mpg",
# ".mpeg",
# ".swf",
)
_PARTIALLY_SUPPORTED_MUSIC_EXT += _PARTIALLY_SUPPORTED_VIDEO_EXT
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`.
"""
_all_music_ext = _FULLY_SUPPORTED_MUSIC_EXT + _PARTIALLY_SUPPORTED_MUSIC_EXT
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())
elif path is not None:
path = str(path)
self.cwd = Path.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"):
if _path.parts[-1].lower() == "localtracks":
self.localtrack_folder = _path
else:
self.localtrack_folder = _path / "localtracks"
else:
self.localtrack_folder = _lt_folder / "localtracks"
try:
_path = Path(path)
_path.relative_to(self.localtrack_folder)
self.path = _path
except (ValueError, TypeError):
for sep in _PATH_SEPS:
if path and path.startswith(f"localtracks{sep}{sep}"):
path = path.replace(f"localtracks{sep}{sep}", "", 1)
elif path and path.startswith(f"localtracks{sep}"):
path = path.replace(f"localtracks{sep}", "", 1)
self.path = self.localtrack_folder.joinpath(path) if path else self.localtrack_folder
try:
if self.path.is_file():
parent = self.path.parent
else:
parent = self.path
self.parent = Path(parent)
except OSError:
self.parent = None
@property
def name(self):
return str(self.path.name)
@property
def suffix(self):
return str(self.path.suffix)
def is_dir(self):
try:
return self.path.is_dir()
except OSError:
return False
def exists(self):
try:
return self.path.exists()
except OSError:
return False
def is_file(self):
try:
return self.path.is_file()
except OSError:
return False
def absolute(self):
try:
return self.path.absolute()
except OSError:
return self._path
@classmethod
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"{glob.escape(self.path)}{os.sep}**{os.sep}", recursive=True)
else:
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"{glob.escape(self.path)}{os.sep}*{os.sep}", recursive=False)
else:
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"]:
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"]:
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()
def __repr__(self):
return str(self)
def to_string(self):
try:
return str(self.path.absolute())
except OSError:
return str(self._path)
def to_string_user(self, arg: str = None):
string = str(self.absolute()).replace(
(str(self.localtrack_folder.absolute()) + os.sep) if arg is None else arg, ""
)
chunked = False
while len(string) > 145 and os.sep in string:
string = string.split(os.sep, 1)[-1]
chunked = True
if chunked:
string = f"...{os.sep}{string}"
return string
async def tracks_in_tree(self):
tracks = []
async for track in self.multirglob(*[f"{ext}" for ext in self._all_music_ext]):
with contextlib.suppress(ValueError):
if track.path.parent != self.localtrack_folder and track.path.relative_to(
self.path
):
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):
return_folders = []
async for f in self.multirglob("", folder=True):
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)
):
return_folders.append(f)
return sorted(return_folders, key=lambda x: x.to_string_user().lower())
async def tracks_in_folder(self):
tracks = []
async for track in self.multiglob(*[f"{ext}" for ext in self._all_music_ext]):
with contextlib.suppress(ValueError):
if track.path.parent != self.localtrack_folder and track.path.relative_to(
self.path
):
tracks.append(Query.process_input(track, self._localtrack_folder))
return sorted(tracks, key=lambda x: x.to_string_user().lower())
async def subfolders(self):
return_folders = []
async for f in self.multiglob("", folder=True):
with contextlib.suppress(ValueError):
if (
f not in return_folders
and f.path != self.localtrack_folder
and f.path.relative_to(self.path)
):
return_folders.append(f)
return sorted(return_folders, key=lambda x: x.to_string_user().lower())
def __eq__(self, other):
if isinstance(other, LocalPath):
return self.path._cparts == other.path._cparts
elif isinstance(other, Path):
return self.path._cparts == other._cpart
return NotImplemented
def __hash__(self):
try:
return self._hash
except AttributeError:
self._hash = hash(tuple(self.path._cparts))
return self._hash
def __lt__(self, other):
if isinstance(other, LocalPath):
return self.path._cparts < other.path._cparts
elif isinstance(other, Path):
return self.path._cparts < other._cpart
return NotImplemented
def __le__(self, other):
if isinstance(other, LocalPath):
return self.path._cparts <= other.path._cparts
elif isinstance(other, Path):
return self.path._cparts <= other._cpart
return NotImplemented
def __gt__(self, other):
if isinstance(other, LocalPath):
return self.path._cparts > other.path._cparts
elif isinstance(other, Path):
return self.path._cparts > other._cpart
return NotImplemented
def __ge__(self, other):
if isinstance(other, LocalPath):
return self.path._cparts >= other.path._cparts
elif isinstance(other, Path):
return self.path._cparts >= other._cpart
return NotImplemented
class Query:
"""Query data class.
Use: Query.process_input(query, localtrack_folder) to generate the Query object.
"""
def __init__(self, query: Union[LocalPath, str], local_folder_current_path: Path, **kwargs):
query = kwargs.get("queryforced", query)
self._raw: Union[LocalPath, str] = 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)
self.is_spotify: bool = kwargs.get("spotify", False)
self.is_youtube: bool = kwargs.get("youtube", False)
self.is_soundcloud: bool = kwargs.get("soundcloud", False)
self.is_bandcamp: bool = kwargs.get("bandcamp", False)
self.is_vimeo: bool = kwargs.get("vimeo", False)
self.is_mixer: bool = kwargs.get("mixer", False)
self.is_twitch: bool = kwargs.get("twitch", False)
self.is_other: bool = kwargs.get("other", False)
self.is_playlist: bool = kwargs.get("playlist", False)
self.is_album: bool = kwargs.get("album", False)
self.is_search: bool = kwargs.get("search", False)
self.is_stream: bool = kwargs.get("stream", False)
self.single_track: bool = kwargs.get("single", False)
self.id: Optional[str] = kwargs.get("id", None)
self.invoked_from: Optional[str] = kwargs.get("invoked_from", None)
self.local_name: Optional[str] = kwargs.get("name", None)
self.search_subfolders: bool = kwargs.get("search_subfolders", False)
self.spotify_uri: Optional[str] = kwargs.get("uri", None)
self.uri: Optional[str] = kwargs.get("url", None)
self.is_url: bool = kwargs.get("is_url", False)
self.start_time: int = kwargs.get("start_time", 0)
self.track_index: Optional[int] = kwargs.get("track_index", None)
if self.invoked_from == "sc search":
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:
self.single_track = False
self._hash = hash(
(
self.valid,
self.is_local,
self.is_spotify,
self.is_youtube,
self.is_soundcloud,
self.is_bandcamp,
self.is_vimeo,
self.is_mixer,
self.is_twitch,
self.is_other,
self.is_playlist,
self.is_album,
self.is_search,
self.is_stream,
self.single_track,
self.id,
self.spotify_uri,
self.start_time,
self.track_index,
self.uri,
)
)
def __str__(self):
return str(self.lavalink_query)
@classmethod
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
Returns a parsed Query object.
"""
if not query:
query = "InvalidQueryPlaceHolderName"
possible_values = {}
if isinstance(query, str):
query = query.strip("<>")
while "ytsearch:" in query:
query = query.replace("ytsearch:", "")
while "scsearch:" in query:
query = query.replace("scsearch:", "")
elif isinstance(query, Query):
for key, val in kwargs.items():
setattr(query, key, val)
return query
elif isinstance(query, lavalink.Track):
possible_values["stream"] = query.is_stream
query = query.uri
possible_values.update(dict(**kwargs))
possible_values.update(cls._parse(query, _local_folder_current_path, **kwargs))
return cls(query, _local_folder_current_path, **possible_values)
@staticmethod
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())
and track.exists()
):
returning["local"] = True
returning["name"] = track.name
if track.is_file():
returning["single"] = True
elif track.is_dir():
returning["album"] = True
else:
track = str(track)
if track.startswith("spotify:"):
returning["spotify"] = True
if ":playlist:" in track:
returning["playlist"] = True
elif ":album:" in track:
returning["album"] = True
elif ":track:" in track:
returning["single"] = True
_id = track.split(":", 2)[-1]
_id = _id.split("?")[0]
returning["id"] = _id
if "#" in _id:
match = re.search(_RE_SPOTIFY_TIMESTAMP, track)
if match:
returning["start_time"] = (int(match.group(1)) * 60) + int(match.group(2))
returning["uri"] = track
return returning
if track.startswith("sc ") or track.startswith("list "):
if track.startswith("sc "):
returning["invoked_from"] = "sc search"
returning["soundcloud"] = True
elif track.startswith("list "):
returning["invoked_from"] = "search list"
track = _RE_REMOVE_START.sub("", track, 1)
returning["queryforced"] = track
_localtrack = LocalPath(track, _local_folder_current_path)
if _localtrack.exists():
if _localtrack.is_file():
returning["local"] = True
returning["single"] = True
returning["name"] = _localtrack.name
return returning
elif _localtrack.is_dir():
returning["album"] = True
returning["local"] = True
returning["name"] = _localtrack.name
return returning
try:
query_url = urlparse(track)
if all([query_url.scheme, query_url.netloc, query_url.path]):
returning["url"] = track
returning["is_url"] = True
url_domain = ".".join(query_url.netloc.split(".")[-2:])
if not query_url.netloc:
url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:])
if url_domain in ["youtube.com", "youtu.be"]:
returning["youtube"] = True
_has_index = "&index=" 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))
if _has_index:
match = re.search(_RE_YOUTUBE_INDEX, track)
if match:
returning["track_index"] = int(match.group(1)) - 1
if all(k in track for k in ["&list=", "watch?"]):
returning["track_index"] = 0
returning["playlist"] = True
returning["single"] = False
elif all(x in track for x in ["playlist?"]):
returning["playlist"] = not _has_index
returning["single"] = _has_index
elif any(k in track for k in ["list="]):
returning["track_index"] = 0
returning["playlist"] = True
returning["single"] = False
else:
returning["single"] = True
elif url_domain == "spotify.com":
returning["spotify"] = True
if "/playlist/" in track:
returning["playlist"] = True
elif "/album/" in track:
returning["album"] = True
elif "/track/" in track:
returning["single"] = True
val = re.sub(_RE_SPOTIFY_URL, "", track).replace("/", ":")
if "user:" in val:
val = val.split(":", 2)[-1]
_id = val.split(":", 1)[-1]
_id = _id.split("?")[0]
if "#" in _id:
_id = _id.split("#")[0]
match = re.search(_RE_SPOTIFY_TIMESTAMP, track)
if match:
returning["start_time"] = (int(match.group(1)) * 60) + int(
match.group(2)
)
returning["id"] = _id
returning["uri"] = f"spotify:{val}"
elif url_domain == "soundcloud.com":
returning["soundcloud"] = True
if "#t=" in track:
match = re.search(_RE_SOUNDCLOUD_TIMESTAMP, track)
if match:
returning["start_time"] = (int(match.group(1)) * 60) + int(
match.group(2)
)
if "/sets/" in track:
if "?in=" in track:
returning["single"] = True
else:
returning["playlist"] = True
else:
returning["single"] = True
elif url_domain == "bandcamp.com":
returning["bandcamp"] = True
if "/album/" in track:
returning["album"] = True
else:
returning["single"] = True
elif url_domain == "vimeo.com":
returning["vimeo"] = True
elif url_domain in ["mixer.com", "beam.pro"]:
returning["mixer"] = True
elif url_domain == "twitch.tv":
returning["twitch"] = True
if "?t=" in track:
match = re.search(_RE_TWITCH_TIMESTAMP, track)
if match:
returning["start_time"] = (
(int(match.group(1)) * 60 * 60)
+ (int(match.group(2)) * 60)
+ int(match.group(3))
)
if not any(x in track for x in ["/clip/", "/videos/"]):
returning["stream"] = True
else:
returning["other"] = True
returning["single"] = True
else:
if kwargs.get("soundcloud", False):
returning["soundcloud"] = True
else:
returning["youtube"] = True
returning["search"] = True
returning["single"] = True
except Exception:
returning["search"] = True
returning["youtube"] = True
returning["single"] = True
return returning
def _get_query(self):
if self.is_local:
return self.local_track_path.to_string()
elif self.is_spotify:
return self.spotify_uri
elif self.is_search and self.is_youtube:
return f"ytsearch:{self.track}"
elif self.is_search and self.is_soundcloud:
return f"scsearch:{self.track}"
return self.track
def to_string_user(self):
if self.is_local:
return str(self.local_track_path.to_string_user())
return str(self._raw)
@property
def suffix(self):
if self.is_local:
return self.local_track_path.suffix
return None
def __eq__(self, other):
if not isinstance(other, Query):
return NotImplemented
return self.to_string_user() == other.to_string_user()
def __hash__(self):
try:
return self._hash
except AttributeError:
self._hash = hash(
(
self.valid,
self.is_local,
self.is_spotify,
self.is_youtube,
self.is_soundcloud,
self.is_bandcamp,
self.is_vimeo,
self.is_mixer,
self.is_twitch,
self.is_other,
self.is_playlist,
self.is_album,
self.is_search,
self.is_stream,
self.single_track,
self.id,
self.spotify_uri,
self.start_time,
self.track_index,
self.uri,
)
)
return self._hash
def __lt__(self, other):
if not isinstance(other, Query):
return NotImplemented
return self.to_string_user() < other.to_string_user()
def __le__(self, other):
if not isinstance(other, Query):
return NotImplemented
return self.to_string_user() <= other.to_string_user()
def __gt__(self, other):
if not isinstance(other, Query):
return NotImplemented
return self.to_string_user() > other.to_string_user()
def __ge__(self, other):
if not isinstance(other, Query):
return NotImplemented
return self.to_string_user() >= other.to_string_user()

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

View File

@@ -0,0 +1,541 @@
import argparse
import functools
import re
from typing import Final, MutableMapping, Optional, Tuple, Union, Pattern
import discord
from redbot.core.utils import AsyncIter
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 .utils import PlaylistScope
_ = Translator("Audio", __file__)
__all__ = [
"ComplexScopeParser",
"PlaylistConverter",
"ScopeParser",
"LazyGreedyConverter",
"standardize_scope",
"get_lazy_converter",
"get_playlist_converter",
]
T_ = _
_ = lambda s: s
_SCOPE_HELP: Final[str] = _(
"""
Scope must be a valid version of one of the following:
Global
Guild
User
"""
)
_USER_HELP: Final[str] = _(
"""
Author must be a valid version of one of the following:
User ID
User Mention
User Name#123
"""
)
_GUILD_HELP: Final[str] = _(
"""
Guild must be a valid version of one of the following:
Guild ID
Exact guild name
"""
)
_ = T_
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: Red = ctx.bot
_id = _match_id(arg)
if _id is not None:
guild: discord.Guild = bot.get_guild(_id)
if guild is not None:
return guild
maybe_matches = []
async for obj in AsyncIter(bot.guilds):
if obj.name == arg or str(obj) == arg:
maybe_matches.append(obj)
if not maybe_matches:
raise NoMatchesFound(
_(
'"{arg}" was not found. It must be the ID or '
"complete name of a server which the bot can see."
).format(arg=arg)
)
elif len(maybe_matches) == 1:
return maybe_matches[0]
else:
raise TooManyMatches(
_(
'"{arg}" does not refer to a unique server. '
"Please use the ID for the server you're trying to specify."
).format(arg=arg)
)
async def global_unique_user_finder(
ctx: commands.Context, arg: str, guild: discord.guild = None
) -> discord.abc.User:
bot: Red = ctx.bot
guild = guild or ctx.guild
_id = _match_id(arg)
if _id is not None:
user: discord.User = bot.get_user(_id)
if user is not None:
return user
maybe_matches = []
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:
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(
_(
'"{arg}" was not found. It must be the ID or name or '
"mention a user which the bot can see."
).format(arg=arg)
)
elif len(maybe_matches) == 1:
return maybe_matches[0]
else:
raise TooManyMatches(
_(
'"{arg}" does not refer to a unique server. '
"Please use the ID for the server you're trying to specify."
).format(arg=arg)
)
class PlaylistConverter(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> MutableMapping:
"""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 {
PlaylistScope.GLOBAL.value: global_matches,
PlaylistScope.GUILD.value: guild_matches,
PlaylistScope.USER.value: user_matches,
"all": [*global_matches, *guild_matches, *user_matches],
"arg": arg,
}
class NoExitParser(argparse.ArgumentParser):
def error(self, message):
raise commands.BadArgument()
class ScopeParser(commands.Converter):
async def convert(
self, ctx: commands.Context, argument: str
) -> Tuple[Optional[str], discord.User, Optional[discord.Guild], bool]:
target_scope: Optional[str] = None
target_user: Optional[Union[discord.Member, discord.User]] = None
target_guild: Optional[discord.Guild] = None
specified_user = False
argument = argument.replace("", "--")
command, *arguments = argument.split(" -- ")
if arguments:
argument = " -- ".join(arguments)
else:
command = ""
parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False)
parser.add_argument("--scope", nargs="*", dest="scope", default=[])
parser.add_argument("--guild", nargs="*", dest="guild", default=[])
parser.add_argument("--server", nargs="*", dest="guild", default=[])
parser.add_argument("--author", nargs="*", dest="author", default=[])
parser.add_argument("--user", nargs="*", dest="author", default=[])
parser.add_argument("--member", nargs="*", dest="author", default=[])
if not command:
parser.add_argument("command", nargs="*")
try:
vals = vars(parser.parse_args(argument.split()))
except Exception as exc:
raise commands.BadArgument() from exc
if vals["scope"]:
scope_raw = " ".join(vals["scope"]).strip()
scope = scope_raw.upper().strip()
valid_scopes = PlaylistScope.list() + [
"GLOBAL",
"GUILD",
"AUTHOR",
"USER",
"SERVER",
"MEMBER",
"BOT",
]
if scope not in valid_scopes:
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))
is_owner = await ctx.bot.is_owner(ctx.author)
guild = vals.get("guild", None) or vals.get("server", None)
if is_owner and guild:
server_error = ""
target_guild = None
guild_raw = " ".join(guild).strip()
try:
target_guild = await global_unique_guild_finder(ctx, guild_raw)
except TooManyMatches as err:
server_error = f"{err}\n"
except NoMatchesFound as err:
server_error = f"{err}\n"
if target_guild is None:
raise commands.ArgParserFailure(
"--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`"))
elif any(x in argument for x in ["--guild", "--server"]):
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:
user_error = ""
target_user = None
user_raw = " ".join(author).strip()
try:
target_user = await global_unique_user_finder(ctx, user_raw, guild=target_guild)
specified_user = True
except TooManyMatches as err:
user_error = f"{err}\n"
except NoMatchesFound as err:
user_error = f"{err}\n"
if target_user is None:
raise commands.ArgParserFailure(
"--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))
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
return target_scope, target_user, target_guild, specified_user
class ComplexScopeParser(commands.Converter):
async def convert(
self, ctx: commands.Context, argument: str
) -> Tuple[
str,
discord.User,
Optional[discord.Guild],
bool,
str,
discord.User,
Optional[discord.Guild],
bool,
]:
target_scope: Optional[str] = None
target_user: Optional[Union[discord.Member, discord.User]] = None
target_guild: Optional[discord.Guild] = None
specified_target_user = False
source_scope: Optional[str] = None
source_user: Optional[Union[discord.Member, discord.User]] = None
source_guild: Optional[discord.Guild] = None
specified_source_user = False
argument = argument.replace("", "--")
command, *arguments = argument.split(" -- ")
if arguments:
argument = " -- ".join(arguments)
else:
command = ""
parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False)
parser.add_argument("--to-scope", nargs="*", dest="to_scope", default=[])
parser.add_argument("--to-guild", nargs="*", dest="to_guild", default=[])
parser.add_argument("--to-server", nargs="*", dest="to_server", default=[])
parser.add_argument("--to-author", nargs="*", dest="to_author", default=[])
parser.add_argument("--to-user", nargs="*", dest="to_user", default=[])
parser.add_argument("--to-member", nargs="*", dest="to_member", default=[])
parser.add_argument("--from-scope", nargs="*", dest="from_scope", default=[])
parser.add_argument("--from-guild", nargs="*", dest="from_guild", default=[])
parser.add_argument("--from-server", nargs="*", dest="from_server", default=[])
parser.add_argument("--from-author", nargs="*", dest="from_author", default=[])
parser.add_argument("--from-user", nargs="*", dest="from_user", default=[])
parser.add_argument("--from-member", nargs="*", dest="from_member", default=[])
if not command:
parser.add_argument("command", nargs="*")
try:
vals = vars(parser.parse_args(argument.split()))
except Exception as exc:
raise commands.BadArgument() from exc
is_owner = await ctx.bot.is_owner(ctx.author)
valid_scopes = PlaylistScope.list() + [
"GLOBAL",
"GUILD",
"AUTHOR",
"USER",
"SERVER",
"MEMBER",
"BOT",
]
if vals["to_scope"]:
to_scope_raw = " ".join(vals["to_scope"]).strip()
to_scope = to_scope_raw.upper().strip()
if to_scope not in valid_scopes:
raise commands.ArgParserFailure(
"--to-scope", to_scope_raw, custom_help=_SCOPE_HELP
)
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))
if vals["from_scope"]:
from_scope_raw = " ".join(vals["from_scope"]).strip()
from_scope = from_scope_raw.upper().strip()
if from_scope not in valid_scopes:
raise commands.ArgParserFailure(
"--from-scope", from_scope_raw, custom_help=_SCOPE_HELP
)
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))
to_guild = vals.get("to_guild", None) or vals.get("to_server", None)
if is_owner and to_guild:
target_server_error = ""
target_guild = None
to_guild_raw = " ".join(to_guild).strip()
try:
target_guild = await global_unique_guild_finder(ctx, to_guild_raw)
except TooManyMatches as err:
target_server_error = f"{err}\n"
except NoMatchesFound as err:
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)}",
)
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`"))
elif any(x in argument for x in ["--to-guild", "--to-server"]):
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(from_guild).strip()
try:
source_guild = await global_unique_guild_finder(ctx, from_guild_raw)
except TooManyMatches as err:
source_server_error = f"{err}\n"
except NoMatchesFound as err:
source_server_error = f"{err}\n"
if source_guild is None:
raise commands.ArgParserFailure(
"--from-guild",
from_guild_raw,
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`"))
elif any(x in argument for x in ["--from-guild", "--from-server"]):
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)
)
if to_author:
target_user_error = ""
target_user = None
to_user_raw = " ".join(to_author).strip()
try:
target_user = await global_unique_user_finder(ctx, to_user_raw, guild=target_guild)
specified_target_user = True
except TooManyMatches as err:
target_user_error = f"{err}\n"
except NoMatchesFound as err:
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)}"
)
elif any(x in argument for x in ["--to-author", "--to-user", "--to-member"]):
raise commands.ArgParserFailure("--to-user", _("Nothing"), custom_help=_(_USER_HELP))
from_author = (
vals.get("from_author", None)
or vals.get("from_user", None)
or vals.get("from_member", None)
)
if from_author:
source_user_error = ""
source_user = None
from_user_raw = " ".join(from_author).strip()
try:
source_user = await global_unique_user_finder(
ctx, from_user_raw, guild=target_guild
)
specified_target_user = True
except TooManyMatches as err:
source_user_error = f"{err}\n"
except NoMatchesFound as err:
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)}",
)
elif any(x in argument for x in ["--from-author", "--from-user", "--from-member"]):
raise commands.ArgParserFailure("--from-user", _("Nothing"), custom_help=_(_USER_HELP))
target_scope = target_scope or PlaylistScope.GUILD.value
target_user = target_user or ctx.author
target_guild = target_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,
source_user,
source_guild,
specified_source_user,
target_scope,
target_user,
target_guild,
specified_target_user,
)
class LazyGreedyConverter(commands.Converter):
def __init__(self, splitter: str):
self.splitter_Value = splitter
async def convert(self, ctx: commands.Context, argument: str) -> str:
full_message = ctx.message.content.partition(f" {argument} ")
if len(full_message) == 1:
full_message = (
(argument if argument not in full_message else "") + " " + full_message[0]
)
elif len(full_message) > 1:
full_message = (
(argument if argument not in full_message else "") + " " + full_message[-1]
)
greedy_output = (" " + full_message.replace("", "--")).partition(
f" {self.splitter_Value}"
)[0]
return f"{greedy_output}".strip()
def get_lazy_converter(splitter: str) -> type:
"""Returns a typechecking safe `LazyGreedyConverter` suitable for use with discord.py."""
class PartialMeta(type(LazyGreedyConverter)):
__call__ = functools.partialmethod(type(LazyGreedyConverter).__call__, splitter)
class ValidatedConverter(LazyGreedyConverter, metaclass=PartialMeta):
pass
return ValidatedConverter
def get_playlist_converter() -> type:
"""Returns a typechecking safe `PlaylistConverter` suitable for use with discord.py."""
class PartialMeta(type(PlaylistConverter)):
__call__ = functools.partialmethod(type(PlaylistConverter).__call__)
class ValidatedConverter(PlaylistConverter, metaclass=PartialMeta):
pass
return ValidatedConverter

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

View 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_length_allowed(self, track: lavalink.Track, 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()

View 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

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

File diff suppressed because it is too large Load Diff

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More