From 95e8d60729c282c8d5841bb830877a00c6113c00 Mon Sep 17 00:00:00 2001 From: Draper <27962761+Drapersniper@users.noreply.github.com> Date: Sat, 4 Jan 2020 01:36:09 +0000 Subject: [PATCH] [3.2][Audio] Part 6 (Last? maybe?) (#3244) * Removes `MAX_BALANCE` from bank, user `bank.get_max_balance()` now `[p]bankset maxbal` can be used to set the maximum bank balance Signed-off-by: Guy * Initial Commit Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * I need to make sure I keep aika on her toes. Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Fixes a few missing kwargs and case consistency Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Fixes a few missing kwargs and case consistency v2 and typos Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Reset cooldowns + add changelogs Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Add 3 extra file formats. Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * IRDUMB - fix capitalization. Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Fix a silent error, and some incorrect messages. Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Remove unnecessary emojis from queue when they are not needed Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Remove duplicated call in `[p]playlist update` Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Remove duplicated call in `[p]playlist update` Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Resolve conflicts Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Bring all files up to date + Black Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Facepalm Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * *Sigh* Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * *Sigh* 2.0 Signed-off-by: Draper <27962761+Drapersniper@users.noreply.github.com> * Merge branch 'V3/develop' of https://github.com/Cog-Creators/Red-DiscordBot into audio-misc-pt1 Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> # Resolve Conflicts: # redbot/cogs/audio/audio.py # redbot/cogs/audio/utils.py * Import missing Typecheck Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Fix Broken docstrings Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Sort Local Tracks Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * :facepalm: Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Reorder the sorting of local tracks, `alphanumerical lower then alphanumerical upper` `a comes before A, but B comes after A` Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black formatting Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Make the local file sorting case insensitive Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Add global blacklist/whitelist + fix some issues with original server based whitelist/blacklist Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Remove the pre-commit yaml Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Nottin to see Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Further improvement to the blacklists Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Further improvement to the blacklists Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Fix the __str__ method on LocalTracks Object * Rename LocalTracks.to_string_hidden() to LocalTracks.to_string_user() To keep it inline with the Query object * Remove encoding pragmas + a few typo fixes * Update some typehints + fix some typos * Remove this duplicate call * Black * fix capitalization * Address preda's review Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Remove the API from the audio cog - Is in direct conflict with goals stated in #2804 - Features this was intended to enable can be enabled in other more appropriate ways later on * changelog * Address Aika's review * Black * *sigh* dont use github web ui * Fuck windows Long live linux... *sigh* no lets ensure windows users can still use local tracks * Merge branch 'V3/develop' of https://github.com/Cog-Creators/Red-DiscordBot into refactoring Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> # Conflicts: # redbot/cogs/audio/audio.py * :eyes: + chore Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * facepalm Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * facepalm... again y u h8 me bruh Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fuk this fuk u tube fuck python fuck all Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * awyehfqwajefhnqeffawefqa eqewarfqaesf qwef qaf qwfr Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fuck everything Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * oh lord saviour resus i love you just make this work Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Change logic to be no errors within last 10 seconds... this should be a valid work around discord ratelimits caused by the spam * Remove auto deletion Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * See I did a ting Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * irdumb Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * black Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Add an is_url attribute to Query objects * chore Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black * Address Aikas review Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Hyperlink Playlist names Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Make shit bold Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * why was this here Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * why was this here Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Initial commit * Workinnng Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Improve SQL Statements + migrate from SQL Alchemy + Databases to APSW Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * apsw tested and working Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * chose Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Migrate Playlist to DB 3 TODO 1 Migrate Config to Schema 3 without playlists and update get_playlist methods Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Revert "Migrate Playlist to DB 3 TODO 1 Migrate Config to Schema 3 without playlists and update get_playlist methods" This reverts commit 4af33cff Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Implement schema migration * Lets not touch the deps since #3192 is already adding them * chore * *sigh* Black * Follow the existing logic and always default Playlist to guild scope * wghqjegqf black * Update usage of last_fetched and last_updated to be Ints... However column migration still pending * Some bug fixes * Update usage of last_fetched and last_updated to be Ints... However column migration still pending * working Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * partial match Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * better partial match Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * black Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * I thought i done this before * Delete 3195.misc.1.rst Wrong PR * Thanks Sinbad Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Thanks Sinbad Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Log Errors in init ... Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Update error logs. Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Create index Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * :Drapersweat: Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Chore Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Revert "Chore" This reverts commit edcc9a9f UGHHHH Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Allow removing tracks from queue by URL Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Words matter Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * chore Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * arghhh CONFLICTS Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Review sinbads latest comment .. ToDo.. Nuke existing playlist - check version and set version Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * migrate the DB schema to v3 (to keep in line with the schema visioning of Config * Add a Todo * *sigh* conflicts and black * *sigh* black * Passively delete playlist deletion mechanism * Delete Old entries on startup * Since we are dropping the table mightaware make these into JSON for future proofing * Don't Dump strings in JSON field ? :think: * Move some things around to make easier to use 1 connection to the Audio DB * Move some things around to make easier to use 1 connection to the Audio DB * *sigh* * Clean up api * *sigh* black * Red + reorder some variables * :facepalm: * how could i forget this ....... * Black Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * #automagically Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * FINAFUCKINGLY Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * FINAFUCKINGLY Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Remove unused config default Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Remove the API from the audio Cog (Properly) Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Missed these changes Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * ARGHHH Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Some fixes I've noticed while running through the code line by line Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Translation + UX (show playlist author ID if can't find user) Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* missed this one Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * this is no longer needed .... Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * :facepalm: Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fix new lines in error messages Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black * Sinbads Review Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Sinbads Review Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* copy paste Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * imrpove backups Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Im a fucking idiot Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Fix #3238 Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * chore Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * humans Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * humans Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * add play alias to playlists Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Im dumb ... Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Im dumb ... Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fix new line Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fix new line Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * show playlist count on playlist picker Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * DJ/Vote system fixes Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * DJ/Vote system fixes Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* fix currency check Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * show playlist count on playlist picker Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * DJ/Vote system fixes Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * DJ/Vote system fixes Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* fix currency check Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Fix duplicate messages on timeout Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fix SQL Statement logic Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * fix SQL Statement logic Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Markdown escape Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Markdown escape Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Markdown escape fix Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Markdown escape fix Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * clean up local cache more frequently Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * clean up db more frequently Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Await in hell Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* im dumb Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* im dumb Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black cuz I hate red Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Black cuz I hate red Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * StringIO to ByteIO Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * StringIO to ByteIO Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* im dumb Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * :Facepalm: the whole purpose of this is so its offline so this can be backed up without being blocking Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Run write queries on ThreadPoolExecutor Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * Backup Audio.db Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* im dumb Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * blaaaack Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * *sigh* Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * formatting Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * remove duplicated string of code Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> * ffs awaits Signed-off-by: Drapersniper <27962761+drapersniper@users.noreply.github.com> Co-authored-by: Michael H --- .github/CODEOWNERS | 2 +- .gitignore | 3 + changelog.d/3047.bugfix.3.rst | 1 + changelog.d/3193.misc.1.rst | 1 + changelog.d/audio/2904.dep.1.rst | 2 +- changelog.d/audio/2904.enhance.9.rst | 5 + changelog.d/audio/2940.bugfix.1.rst | 1 + changelog.d/audio/2940.enhancement.1.rts | 1 + changelog.d/audio/2940.enhancement.2.rst | 1 + changelog.d/audio/2940.feature.1.rst | 1 + changelog.d/audio/2940.feature.2.rst | 1 + changelog.d/audio/2940.misc.1.rst | 1 + changelog.d/audio/3047.bugfix.1.rst | 1 + changelog.d/audio/3047.bugfix.2.rst | 1 + changelog.d/audio/3047.bugfix.3.rst | 1 + changelog.d/audio/3047.feature.1.rst | 1 + changelog.d/audio/3104.misc.3.rst | 1 + changelog.d/audio/3152.misc.rst | 1 + changelog.d/audio/3165.bugfix.1.rst | 1 + changelog.d/audio/3165.enhance.1.rst | 1 + changelog.d/audio/3168.misc.1.rst | 1 + changelog.d/audio/3195.misc.1.rst | 1 + changelog.d/audio/3201.feature.1.rst | 1 + changelog.d/audio/3238.bugfix.1.rst | 1 + docs/guide_cog_creation.rst | 2 +- redbot/cogs/audio/apis.py | 428 +-- redbot/cogs/audio/audio.py | 3788 ++++++++++++++-------- redbot/cogs/audio/audio_dataclasses.py | 286 +- redbot/cogs/audio/checks.py | 18 +- redbot/cogs/audio/config.py | 18 + redbot/cogs/audio/converters.py | 57 +- redbot/cogs/audio/databases.py | 372 +++ redbot/cogs/audio/equalizer.py | 2 +- redbot/cogs/audio/errors.py | 13 +- redbot/cogs/audio/manager.py | 40 +- redbot/cogs/audio/playlists.py | 411 ++- redbot/cogs/audio/sql_statements.py | 397 +++ redbot/cogs/audio/utils.py | 194 +- redbot/cogs/permissions/permissions.py | 2 +- redbot/core/bot.py | 2 +- redbot/core/drivers/postgres/postgres.py | 2 +- setup.cfg | 1 - tools/primary_deps.ini | 1 - 43 files changed, 4128 insertions(+), 1938 deletions(-) create mode 100644 changelog.d/3047.bugfix.3.rst create mode 100644 changelog.d/3193.misc.1.rst create mode 100644 changelog.d/audio/2904.enhance.9.rst create mode 100644 changelog.d/audio/2940.bugfix.1.rst create mode 100644 changelog.d/audio/2940.enhancement.1.rts create mode 100644 changelog.d/audio/2940.enhancement.2.rst create mode 100644 changelog.d/audio/2940.feature.1.rst create mode 100644 changelog.d/audio/2940.feature.2.rst create mode 100644 changelog.d/audio/2940.misc.1.rst create mode 100644 changelog.d/audio/3047.bugfix.1.rst create mode 100644 changelog.d/audio/3047.bugfix.2.rst create mode 100644 changelog.d/audio/3047.bugfix.3.rst create mode 100644 changelog.d/audio/3047.feature.1.rst create mode 100644 changelog.d/audio/3104.misc.3.rst create mode 100644 changelog.d/audio/3152.misc.rst create mode 100644 changelog.d/audio/3165.bugfix.1.rst create mode 100644 changelog.d/audio/3165.enhance.1.rst create mode 100644 changelog.d/audio/3168.misc.1.rst create mode 100644 changelog.d/audio/3195.misc.1.rst create mode 100644 changelog.d/audio/3201.feature.1.rst create mode 100644 changelog.d/audio/3238.bugfix.1.rst create mode 100644 redbot/cogs/audio/config.py create mode 100644 redbot/cogs/audio/databases.py create mode 100644 redbot/cogs/audio/sql_statements.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 440ff654c..fd8cbe317 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,7 +30,7 @@ redbot/core/utils/dbtools.py @mikeshardmind # Cogs redbot/cogs/admin/* @tekulvw redbot/cogs/alias/* @tekulvw -redbot/cogs/audio/* @aikaterna +redbot/cogs/audio/* @aikaterna @Drapersniper redbot/cogs/bank/* @tekulvw redbot/cogs/cleanup/* @palmtree5 redbot/cogs/customcom/* @palmtree5 diff --git a/.gitignore b/.gitignore index 7f45b422d..cae373df4 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ ENV/ # pytest .pytest_cache/ + +# Pre-commit hooks +/.pre-commit-config.yaml diff --git a/changelog.d/3047.bugfix.3.rst b/changelog.d/3047.bugfix.3.rst new file mode 100644 index 000000000..26373eb2c --- /dev/null +++ b/changelog.d/3047.bugfix.3.rst @@ -0,0 +1 @@ +Escape track descriptions so that they do not break markdown. \ No newline at end of file diff --git a/changelog.d/3193.misc.1.rst b/changelog.d/3193.misc.1.rst new file mode 100644 index 000000000..54adaeebc --- /dev/null +++ b/changelog.d/3193.misc.1.rst @@ -0,0 +1 @@ +2 Changes, removed the ``Databases`` dependency and migrated it over to APSW. \ No newline at end of file diff --git a/changelog.d/audio/2904.dep.1.rst b/changelog.d/audio/2904.dep.1.rst index a8b0491e2..3960382e9 100644 --- a/changelog.d/audio/2904.dep.1.rst +++ b/changelog.d/audio/2904.dep.1.rst @@ -1 +1 @@ -New dependency: ``databases[sqlite]`` . \ No newline at end of file +New dependency: ``databases[sqlite]``. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.9.rst b/changelog.d/audio/2904.enhance.9.rst new file mode 100644 index 000000000..0453ff903 --- /dev/null +++ b/changelog.d/audio/2904.enhance.9.rst @@ -0,0 +1,5 @@ +When playing a localtrack ``[p]play`` and ``[p]bumpplay`` no longer require the use of "localtracks\\" prefix. + +Before: ``[p]bumpplay localtracks\\ENM\\501 - Inside The Machine.mp3`` +Now: ``[p]bumpplay ENM\\501 - Inside The Machine.mp3`` +Now nested folders: ``[p]bumpplay Parent Folder\\Nested Folder\\track.mp3`` \ No newline at end of file diff --git a/changelog.d/audio/2940.bugfix.1.rst b/changelog.d/audio/2940.bugfix.1.rst new file mode 100644 index 000000000..3a3d1f279 --- /dev/null +++ b/changelog.d/audio/2940.bugfix.1.rst @@ -0,0 +1 @@ +Fix track index being off by 1 on ``[p]search`` command. \ No newline at end of file diff --git a/changelog.d/audio/2940.enhancement.1.rts b/changelog.d/audio/2940.enhancement.1.rts new file mode 100644 index 000000000..00e4817aa --- /dev/null +++ b/changelog.d/audio/2940.enhancement.1.rts @@ -0,0 +1 @@ +Expanded local track support to all file formats (m3u, m4a, mp4, etc). \ No newline at end of file diff --git a/changelog.d/audio/2940.enhancement.2.rst b/changelog.d/audio/2940.enhancement.2.rst new file mode 100644 index 000000000..fad806552 --- /dev/null +++ b/changelog.d/audio/2940.enhancement.2.rst @@ -0,0 +1 @@ +Reset cooldown upon failure of commands that has a cooldown timer. \ No newline at end of file diff --git a/changelog.d/audio/2940.feature.1.rst b/changelog.d/audio/2940.feature.1.rst new file mode 100644 index 000000000..aeb254189 --- /dev/null +++ b/changelog.d/audio/2940.feature.1.rst @@ -0,0 +1 @@ +``[p]bumpplay`` command has been added. \ No newline at end of file diff --git a/changelog.d/audio/2940.feature.2.rst b/changelog.d/audio/2940.feature.2.rst new file mode 100644 index 000000000..50fdcd4dc --- /dev/null +++ b/changelog.d/audio/2940.feature.2.rst @@ -0,0 +1 @@ +``[p]shuffle`` command has an additional argument to tell the bot whether it should shuffle bumped tracks. \ No newline at end of file diff --git a/changelog.d/audio/2940.misc.1.rst b/changelog.d/audio/2940.misc.1.rst new file mode 100644 index 000000000..3127cfc4e --- /dev/null +++ b/changelog.d/audio/2940.misc.1.rst @@ -0,0 +1 @@ +DJ_ENABLED and DJ_ROLE settings are now stored on memory after first fetch, to reduce duplicated calls. \ No newline at end of file diff --git a/changelog.d/audio/3047.bugfix.1.rst b/changelog.d/audio/3047.bugfix.1.rst new file mode 100644 index 000000000..a71e056f2 --- /dev/null +++ b/changelog.d/audio/3047.bugfix.1.rst @@ -0,0 +1 @@ +Fix an issue where updating your Spotify and YouTube Data API tokens did not refresh them. \ No newline at end of file diff --git a/changelog.d/audio/3047.bugfix.2.rst b/changelog.d/audio/3047.bugfix.2.rst new file mode 100644 index 000000000..d94a23155 --- /dev/null +++ b/changelog.d/audio/3047.bugfix.2.rst @@ -0,0 +1 @@ +Fix an issue where the blacklist was not being applied correctly. \ No newline at end of file diff --git a/changelog.d/audio/3047.bugfix.3.rst b/changelog.d/audio/3047.bugfix.3.rst new file mode 100644 index 000000000..ad7f29b31 --- /dev/null +++ b/changelog.d/audio/3047.bugfix.3.rst @@ -0,0 +1 @@ +Fix an issue in ``[p]audioset restrictions blacklist list`` where it would call the list a `Whitelist`. diff --git a/changelog.d/audio/3047.feature.1.rst b/changelog.d/audio/3047.feature.1.rst new file mode 100644 index 000000000..2d9ea1372 --- /dev/null +++ b/changelog.d/audio/3047.feature.1.rst @@ -0,0 +1 @@ +Add global whitelist/blacklist commands. \ No newline at end of file diff --git a/changelog.d/audio/3104.misc.3.rst b/changelog.d/audio/3104.misc.3.rst new file mode 100644 index 000000000..6b96d3aa9 --- /dev/null +++ b/changelog.d/audio/3104.misc.3.rst @@ -0,0 +1 @@ +Add `cache.db` to the list of items not included in a backup. \ No newline at end of file diff --git a/changelog.d/audio/3152.misc.rst b/changelog.d/audio/3152.misc.rst new file mode 100644 index 000000000..806b4eb1a --- /dev/null +++ b/changelog.d/audio/3152.misc.rst @@ -0,0 +1 @@ +remove an undocumented API from audio diff --git a/changelog.d/audio/3165.bugfix.1.rst b/changelog.d/audio/3165.bugfix.1.rst new file mode 100644 index 000000000..2c29f9902 --- /dev/null +++ b/changelog.d/audio/3165.bugfix.1.rst @@ -0,0 +1 @@ +Fixed an error that was thrown when running ``[p]audioset dj``. \ No newline at end of file diff --git a/changelog.d/audio/3165.enhance.1.rst b/changelog.d/audio/3165.enhance.1.rst new file mode 100644 index 000000000..007718297 --- /dev/null +++ b/changelog.d/audio/3165.enhance.1.rst @@ -0,0 +1 @@ +Better error handling the player is unable to play multiple tracks in sequence. \ No newline at end of file diff --git a/changelog.d/audio/3168.misc.1.rst b/changelog.d/audio/3168.misc.1.rst new file mode 100644 index 000000000..4c9aa44a8 --- /dev/null +++ b/changelog.d/audio/3168.misc.1.rst @@ -0,0 +1 @@ +Fixed an attribute error raised in :meth:`event_handler`. \ No newline at end of file diff --git a/changelog.d/audio/3195.misc.1.rst b/changelog.d/audio/3195.misc.1.rst new file mode 100644 index 000000000..82e5be66d --- /dev/null +++ b/changelog.d/audio/3195.misc.1.rst @@ -0,0 +1 @@ +Migrate Playlists to its dedicated playlist table and remove them from the Config driver. \ No newline at end of file diff --git a/changelog.d/audio/3201.feature.1.rst b/changelog.d/audio/3201.feature.1.rst new file mode 100644 index 000000000..7c5beb4ca --- /dev/null +++ b/changelog.d/audio/3201.feature.1.rst @@ -0,0 +1 @@ +``[p]remove`` command now accepts an URL or Index, if an URL is used it will remove all tracks in the queue with that URL. \ No newline at end of file diff --git a/changelog.d/audio/3238.bugfix.1.rst b/changelog.d/audio/3238.bugfix.1.rst new file mode 100644 index 000000000..088c643f3 --- /dev/null +++ b/changelog.d/audio/3238.bugfix.1.rst @@ -0,0 +1 @@ +Fixed a crash that could happen when the bot can't connect to the lavalink node, \ No newline at end of file diff --git a/docs/guide_cog_creation.rst b/docs/guide_cog_creation.rst index 06f905efa..50f7bc397 100644 --- a/docs/guide_cog_creation.rst +++ b/docs/guide_cog_creation.rst @@ -21,7 +21,7 @@ To start off, be sure that you have installed Python 3.7. Next, you need to decide if you want to develop against the Stable or Develop version of Red. Depending on what your goal is should help determine which version you need. -.. attention:: +.. attention:: The Develop version may have changes on it which break compatibility with the Stable version and other cogs. If your goal is to support both versions, make sure you build compatibility layers or use separate branches to keep compatibility until the next Red release diff --git a/redbot/cogs/audio/apis.py b/redbot/cogs/audio/apis.py index dd77733c1..d15fb13c7 100644 --- a/redbot/cogs/audio/apis.py +++ b/redbot/cogs/audio/apis.py @@ -4,25 +4,10 @@ import contextlib import datetime import json import logging -import os import random import time -import traceback from collections import namedtuple -from typing import Callable, Dict, List, Mapping, Optional, Tuple, Union - -try: - from sqlite3 import Error as SQLError - from databases import Database - - HAS_SQL = True - _ERROR = None -except ImportError as err: - _ERROR = "".join(traceback.format_exception_only(type(err), err)).strip() - HAS_SQL = False - SQLError = err.__class__ - Database = None - +from typing import Callable, List, MutableMapping, Optional, TYPE_CHECKING, Tuple, Union, NoReturn import aiohttp import discord @@ -32,129 +17,38 @@ from lavalink.rest_api import LoadResult from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.i18n import Translator, cog_i18n + from . import audio_dataclasses -from .errors import InvalidTableError, SpotifyFetchError, YouTubeApiError, DatabaseError +from .databases import CacheInterface, SQLError +from .errors import DatabaseError, SpotifyFetchError, YouTubeApiError, TrackEnqueueError from .playlists import get_playlist from .utils import CacheLevel, Notifier, is_allowed, queue_duration, track_limit log = logging.getLogger("red.audio.cache") _ = Translator("Audio", __file__) -_DROP_YOUTUBE_TABLE = "DROP TABLE youtube;" - -_CREATE_YOUTUBE_TABLE = """ - CREATE TABLE IF NOT EXISTS youtube( - id INTEGER PRIMARY KEY AUTOINCREMENT, - track_info TEXT, - youtube_url TEXT, - last_updated TEXT, - last_fetched TEXT - ); - """ - -_CREATE_UNIQUE_INDEX_YOUTUBE_TABLE = ( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_youtube_url ON youtube (track_info, youtube_url);" -) - -_INSERT_YOUTUBE_TABLE = """ - INSERT OR REPLACE INTO - youtube(track_info, youtube_url, last_updated, last_fetched) - VALUES (:track_info, :track_url, :last_updated, :last_fetched); - """ -_QUERY_YOUTUBE_TABLE = "SELECT * FROM youtube WHERE track_info=:track;" -_UPDATE_YOUTUBE_TABLE = """UPDATE youtube - SET last_fetched=:last_fetched - WHERE track_info=:track;""" - -_DROP_SPOTIFY_TABLE = "DROP TABLE spotify;" - -_CREATE_UNIQUE_INDEX_SPOTIFY_TABLE = ( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_spotify_uri ON spotify (id, type, uri);" -) - -_CREATE_SPOTIFY_TABLE = """ - CREATE TABLE IF NOT EXISTS spotify( - id TEXT, - type TEXT, - uri TEXT, - track_name TEXT, - artist_name TEXT, - song_url TEXT, - track_info TEXT, - last_updated TEXT, - last_fetched TEXT - ); - """ - -_INSERT_SPOTIFY_TABLE = """ - INSERT OR REPLACE INTO - spotify(id, type, uri, track_name, artist_name, - song_url, track_info, last_updated, last_fetched) - VALUES (:id, :type, :uri, :track_name, :artist_name, - :song_url, :track_info, :last_updated, :last_fetched); - """ -_QUERY_SPOTIFY_TABLE = "SELECT * FROM spotify WHERE uri=:uri;" -_UPDATE_SPOTIFY_TABLE = """UPDATE spotify - SET last_fetched=:last_fetched - WHERE uri=:uri;""" - -_DROP_LAVALINK_TABLE = "DROP TABLE lavalink;" - -_CREATE_LAVALINK_TABLE = """ - CREATE TABLE IF NOT EXISTS lavalink( - query TEXT, - data BLOB, - last_updated TEXT, - last_fetched TEXT - - ); - """ - -_CREATE_UNIQUE_INDEX_LAVALINK_TABLE = ( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_lavalink_query ON lavalink (query);" -) - -_INSERT_LAVALINK_TABLE = """ - INSERT OR REPLACE INTO - lavalink(query, data, last_updated, last_fetched) - VALUES (:query, :data, :last_updated, :last_fetched); - """ -_QUERY_LAVALINK_TABLE = "SELECT * FROM lavalink WHERE query=:query;" -_QUERY_LAST_FETCHED_LAVALINK_TABLE = ( - "SELECT * FROM lavalink " - "WHERE last_fetched LIKE :day1" - " OR last_fetched LIKE :day2" - " OR last_fetched LIKE :day3" - " OR last_fetched LIKE :day4" - " OR last_fetched LIKE :day5" - " OR last_fetched LIKE :day6" - " OR last_fetched LIKE :day7;" -) -_UPDATE_LAVALINK_TABLE = """UPDATE lavalink - SET last_fetched=:last_fetched - WHERE query=:query;""" - -_PARSER = { - "youtube": { - "insert": _INSERT_YOUTUBE_TABLE, - "youtube_url": {"query": _QUERY_YOUTUBE_TABLE}, - "update": _UPDATE_YOUTUBE_TABLE, - }, - "spotify": { - "insert": _INSERT_SPOTIFY_TABLE, - "track_info": {"query": _QUERY_SPOTIFY_TABLE}, - "update": _UPDATE_SPOTIFY_TABLE, - }, - "lavalink": { - "insert": _INSERT_LAVALINK_TABLE, - "data": {"query": _QUERY_LAVALINK_TABLE, "played": _QUERY_LAST_FETCHED_LAVALINK_TABLE}, - "update": _UPDATE_LAVALINK_TABLE, - }, -} - _TOP_100_GLOBALS = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i" _TOP_100_US = "https://www.youtube.com/playlist?list=PL4fGSI1pDJn5rWitrRWFKdm-ulaFiIyoK" +if TYPE_CHECKING: + _database: CacheInterface + _bot: Red + _config: Config +else: + _database = None + _bot = None + _config = None + + +def _pass_config_to_apis(config: Config, bot: Red): + global _database, _config, _bot + if _config is None: + _config = config + if _bot is None: + _bot = bot + if _database is None: + _database = CacheInterface() + class SpotifyAPI: """Wrapper for the Spotify API.""" @@ -162,17 +56,19 @@ class SpotifyAPI: def __init__(self, bot: Red, session: aiohttp.ClientSession): self.bot = bot self.session = session - self.spotify_token = None + self.spotify_token: Optional[MutableMapping[str, Union[str, int]]] = None self.client_id = None self.client_secret = None @staticmethod - async def _check_token(token: dict): + async def _check_token(token: MutableMapping): now = int(time.time()) return token["expires_at"] - now < 60 @staticmethod - def _make_token_auth(client_id: Optional[str], client_secret: Optional[str]) -> dict: + def _make_token_auth( + client_id: Optional[str], client_secret: Optional[str] + ) -> MutableMapping[str, Union[str, int]]: if client_id is None: client_id = "" if client_secret is None: @@ -181,7 +77,9 @@ class SpotifyAPI: auth_header = base64.b64encode((client_id + ":" + client_secret).encode("ascii")) return {"Authorization": "Basic %s" % auth_header.decode("ascii")} - async def _make_get(self, url: str, headers: dict = None, params: dict = None) -> dict: + async def _make_get( + self, url: str, headers: MutableMapping = None, params: MutableMapping = None + ) -> MutableMapping[str, str]: if params is None: params = {} async with self.session.request("GET", url, params=params, headers=headers) as r: @@ -193,13 +91,12 @@ class SpotifyAPI: ) return await r.json() - async def _get_auth(self): - if self.client_id is None or self.client_secret is None: - tokens = await self.bot.get_shared_api_tokens("spotify") - self.client_id = tokens.get("client_id", "") - self.client_secret = tokens.get("client_secret", "") + async def _get_auth(self) -> NoReturn: + tokens = await self.bot.get_shared_api_tokens("spotify") + self.client_id = tokens.get("client_id", "") + self.client_secret = tokens.get("client_secret", "") - async def _request_token(self) -> dict: + async def _request_token(self) -> MutableMapping[str, Union[str, int]]: await self._get_auth() payload = {"grant_type": "client_credentials"} @@ -223,7 +120,9 @@ class SpotifyAPI: log.debug("Created a new access token for Spotify: {0}".format(token)) return self.spotify_token["access_token"] - async def post_call(self, url: str, payload: dict, headers: dict = None) -> dict: + async def post_call( + self, url: str, payload: MutableMapping, headers: MutableMapping = None + ) -> MutableMapping[str, Union[str, int]]: async with self.session.post(url, data=payload, headers=headers) as r: if r.status != 200: log.debug( @@ -233,13 +132,15 @@ class SpotifyAPI: ) return await r.json() - async def get_call(self, url: str, params: dict) -> dict: + async def get_call( + self, url: str, params: MutableMapping + ) -> MutableMapping[str, Union[str, int]]: token = await self._get_spotify_token() return await self._make_get( url, params=params, headers={"Authorization": "Bearer {0}".format(token)} ) - async def get_categories(self) -> List[Dict[str, str]]: + async def get_categories(self) -> List[MutableMapping]: url = "https://api.spotify.com/v1/browse/categories" params = {} result = await self.get_call(url, params=params) @@ -278,10 +179,9 @@ class YouTubeAPI: self.session = session self.api_key = None - async def _get_api_key(self,) -> Optional[str]: - if self.api_key is None: - tokens = await self.bot.get_shared_api_tokens("youtube") - self.api_key = tokens.get("api_key", "") + async def _get_api_key(self,) -> str: + tokens = await self.bot.get_shared_api_tokens("youtube") + self.api_key = tokens.get("api_key", "") return self.api_key async def get_call(self, query: str) -> Optional[str]: @@ -310,122 +210,39 @@ class YouTubeAPI: @cog_i18n(_) class MusicCache: - """ - Handles music queries to the Spotify and Youtube Data API. + """Handles music queries to the Spotify and Youtube Data API. + Always tries the Cache first. """ - def __init__(self, bot: Red, session: aiohttp.ClientSession, path: str): + def __init__(self, bot: Red, session: aiohttp.ClientSession): self.bot = bot self.spotify_api: SpotifyAPI = SpotifyAPI(bot, session) self.youtube_api: YouTubeAPI = YouTubeAPI(bot, session) self._session: aiohttp.ClientSession = session - if HAS_SQL: - self.database: Database = Database( - f'sqlite:///{os.path.abspath(str(os.path.join(path, "cache.db")))}' - ) - else: - self.database = None + self.database = _database - self._tasks: dict = {} + self._tasks: MutableMapping = {} self._lock: asyncio.Lock = asyncio.Lock() self.config: Optional[Config] = None async def initialize(self, config: Config): - if HAS_SQL: - await self.database.connect() - - await self.database.execute(query="PRAGMA temp_store = 2;") - await self.database.execute(query="PRAGMA journal_mode = wal;") - await self.database.execute(query="PRAGMA wal_autocheckpoint;") - await self.database.execute(query="PRAGMA read_uncommitted = 1;") - - await self.database.execute(query=_CREATE_LAVALINK_TABLE) - await self.database.execute(query=_CREATE_UNIQUE_INDEX_LAVALINK_TABLE) - await self.database.execute(query=_CREATE_YOUTUBE_TABLE) - await self.database.execute(query=_CREATE_UNIQUE_INDEX_YOUTUBE_TABLE) - await self.database.execute(query=_CREATE_SPOTIFY_TABLE) - await self.database.execute(query=_CREATE_UNIQUE_INDEX_SPOTIFY_TABLE) self.config = config - - async def close(self): - if HAS_SQL: - await self.database.execute(query="PRAGMA optimize;") - await self.database.disconnect() - - async def insert(self, table: str, values: List[dict]): - # if table == "spotify": - # return - if HAS_SQL: - query = _PARSER.get(table, {}).get("insert") - if query is None: - raise InvalidTableError(f"{table} is not a valid table in the database.") - - await self.database.execute_many(query=query, values=values) - - async def update(self, table: str, values: Dict[str, str]): - # if table == "spotify": - # return - if HAS_SQL: - table = _PARSER.get(table, {}) - sql_query = table.get("update") - time_now = str(datetime.datetime.now(datetime.timezone.utc)) - values["last_fetched"] = time_now - if not table: - raise InvalidTableError(f"{table} is not a valid table in the database.") - await self.database.fetch_one(query=sql_query, values=values) - - async def fetch_one( - self, table: str, query: str, values: Dict[str, str] - ) -> Tuple[Optional[str], bool]: - table = _PARSER.get(table, {}) - sql_query = table.get(query, {}).get("query") - if HAS_SQL: - if not table: - raise InvalidTableError(f"{table} is not a valid table in the database.") - - row = await self.database.fetch_one(query=sql_query, values=values) - last_updated = getattr(row, "last_updated", None) - need_update = True - with contextlib.suppress(TypeError): - if last_updated: - last_update = datetime.datetime.fromisoformat( - last_updated - ) + datetime.timedelta(days=await self.config.cache_age()) - last_update.replace(tzinfo=datetime.timezone.utc) - - need_update = last_update < datetime.datetime.now(datetime.timezone.utc) - - return getattr(row, query, None), need_update if table != "spotify" else True - else: - return None, True - - # TODO: Create a task to remove entries - # from DB that haven't been fetched in x days ... customizable by Owner - - async def fetch_all(self, table: str, query: str, values: Dict[str, str]) -> List[Mapping]: - if HAS_SQL: - table = _PARSER.get(table, {}) - sql_query = table.get(query, {}).get("played") - if not table: - raise InvalidTableError(f"{table} is not a valid table in the database.") - - return await self.database.fetch_all(query=sql_query, values=values) - return [] + await _database.init() @staticmethod - def _spotify_format_call(qtype: str, key: str) -> Tuple[str, dict]: + def _spotify_format_call(qtype: str, key: str) -> Tuple[str, MutableMapping]: params = {} if qtype == "album": - query = "https://api.spotify.com/v1/albums/{0}/tracks".format(key) + query = f"https://api.spotify.com/v1/albums/{key}/tracks" elif qtype == "track": - query = "https://api.spotify.com/v1/tracks/{0}".format(key) + query = f"https://api.spotify.com/v1/tracks/{key}" else: - query = "https://api.spotify.com/v1/playlists/{0}/tracks".format(key) + query = f"https://api.spotify.com/v1/playlists/{key}/tracks" return query, params @staticmethod - def _get_spotify_track_info(track_data: dict) -> Tuple[str, ...]: + def _get_spotify_track_info(track_data: MutableMapping) -> Tuple[str, ...]: artist_name = track_data["artists"][0]["name"] track_name = track_data["name"] track_info = f"{track_name} {artist_name}" @@ -451,7 +268,7 @@ class MusicCache: total_tracks = len(tracks) database_entries = [] track_count = 0 - time_now = str(datetime.datetime.now(datetime.timezone.utc)) + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level) for track in tracks: if track.get("error", {}).get("message") == "invalid id": @@ -484,7 +301,7 @@ class MusicCache: if youtube_cache: update = True with contextlib.suppress(SQLError): - val, update = await self.fetch_one( + (val, update) = await self.database.fetch_one( "youtube", "youtube_url", {"track": track_info} ) if update: @@ -517,7 +334,7 @@ class MusicCache: ) -> str: track_url = await self.youtube_api.get_call(track_info) if CacheLevel.set_youtube().is_subset(current_cache_level) and track_url: - time_now = str(datetime.datetime.now(datetime.timezone.utc)) + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) task = ( "insert", ( @@ -540,12 +357,12 @@ class MusicCache: query_type: str, uri: str, recursive: Union[str, bool] = False, - params=None, + params: MutableMapping = None, notifier: Optional[Notifier] = None, - ) -> Union[dict, List[str]]: + ) -> Union[MutableMapping, List[str]]: if recursive is False: - call, params = self._spotify_format_call(query_type, uri) + (call, params) = self._spotify_format_call(query_type, uri) results = await self.spotify_api.get_call(call, params) else: results = await self.spotify_api.get_call(recursive, params) @@ -608,8 +425,7 @@ class MusicCache: skip_youtube: bool = False, notifier: Optional[Notifier] = None, ) -> List[str]: - """ - Queries the Database then falls back to Spotify and YouTube APIs. + """Queries the Database then falls back to Spotify and YouTube APIs. Parameters ---------- @@ -628,14 +444,12 @@ class MusicCache: List[str] List of Youtube URLs. """ - current_cache_level = ( - CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() - ) + 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: update = True with contextlib.suppress(SQLError): - val, update = await self.fetch_one( + (val, update) = await self.database.fetch_one( "spotify", "track_info", {"uri": f"spotify:track:{uri}"} ) if update: @@ -673,9 +487,7 @@ class MusicCache: track_list = [] has_not_allowed = False try: - current_cache_level = ( - CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() - ) + current_cache_level = CacheLevel(await self.config.cache_level()) guild_data = await self.config.guild(ctx.guild).all() # now = int(time.time()) @@ -698,7 +510,7 @@ class MusicCache: return track_list database_entries = [] - time_now = str(datetime.datetime.now(datetime.timezone.utc)) + 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) @@ -730,7 +542,7 @@ class MusicCache: if youtube_cache: update = True with contextlib.suppress(SQLError): - val, update = await self.fetch_one( + (val, update) = await self.database.fetch_one( "youtube", "youtube_url", {"track": track_info} ) if update: @@ -745,7 +557,7 @@ class MusicCache: if val: try: - result, called_api = await self.lavalink_query( + (result, called_api) = await self.lavalink_query( ctx, player, audio_dataclasses.Query.process_input(val) ) except (RuntimeError, aiohttp.ServerDisconnectedError): @@ -760,7 +572,7 @@ class MusicCache: lock(ctx, False) error_embed = discord.Embed( colour=await ctx.embed_colour(), - title=_("Player timedout, skipping remaning tracks."), + title=_("Player timeout, skipping remaining tracks."), ) await notifier.update_embed(error_embed) break @@ -771,16 +583,6 @@ class MusicCache: key = "lavalink" seconds = "???" second_key = None - # if track_count == 2: - # five_time = int(time.time()) - now - # if track_count >= 2: - # remain_tracks = total_tracks - track_count - # time_remain = (remain_tracks / 2) * five_time - # if track_count < total_tracks: - # seconds = dynamic_time(int(time_remain)) - # if track_count == total_tracks: - # seconds = "0s" - # second_key = "lavalink_time" await notifier.notify_user( current=track_count, total=total_tracks, @@ -837,16 +639,14 @@ class MusicCache: await player.play() if len(track_list) == 0: if not has_not_allowed: - embed3 = discord.Embed( - colour=await ctx.embed_colour(), - title=_( + 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`." - ).format(prefix=ctx.prefix), + ).format(prefix=ctx.prefix) ) - await ctx.send(embed=embed3) player.maybe_shuffle() if enqueue and tracks_from_spotify: if total_tracks > enqueued_tracks: @@ -885,15 +685,15 @@ class MusicCache: return track_list async def youtube_query(self, ctx: commands.Context, track_info: str) -> str: - current_cache_level = ( - CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() - ) + current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_youtube().is_subset(current_cache_level) val = None if cache_enabled: update = True with contextlib.suppress(SQLError): - val, update = await self.fetch_one("youtube", "youtube_url", {"track": track_info}) + (val, update) = await self.database.fetch_one( + "youtube", "youtube_url", {"track": track_info} + ) if update: val = None if val is None: @@ -914,10 +714,8 @@ class MusicCache: query: audio_dataclasses.Query, forced: bool = False, ) -> 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. + """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 ---------- @@ -934,9 +732,7 @@ class MusicCache: 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()) if HAS_SQL else CacheLevel.none() - ) + current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) val = None _raw_query = audio_dataclasses.Query.process_input(query) @@ -944,14 +740,15 @@ class MusicCache: if cache_enabled and not forced and not _raw_query.is_local: update = True with contextlib.suppress(SQLError): - val, update = await self.fetch_one("lavalink", "data", {"query": query}) + (val, update) = await self.database.fetch_one("lavalink", "data", {"query": query}) if update: val = None - if val: + if val and not isinstance(val, str): + log.debug(f"Querying Local Database for {query}") task = ("update", ("lavalink", {"query": query})) self.append_task(ctx, *task) if val and not forced: - data = json.loads(val) + data = val data["query"] = query results = LoadResult(data) called_api = False @@ -965,6 +762,8 @@ class MusicCache: results = await player.load_tracks(query) except KeyError: results = None + except RuntimeError: + raise TrackEnqueueError if results is None: results = LoadResult({"loadType": "LOAD_FAILED", "playlistInfo": {}, "tracks": []}) if ( @@ -975,7 +774,7 @@ class MusicCache: and results.tracks ): with contextlib.suppress(SQLError): - time_now = str(datetime.datetime.now(datetime.timezone.utc)) + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) task = ( "insert", ( @@ -1003,10 +802,12 @@ class MusicCache: tasks = self._tasks[ctx.message.id] del self._tasks[ctx.message.id] await asyncio.gather( - *[self.insert(*a) for a in tasks["insert"]], return_exceptions=True + *[self.database.insert(*a) for a in tasks["insert"]], + return_exceptions=True, ) await asyncio.gather( - *[self.update(*a) for a in tasks["update"]], return_exceptions=True + *[self.database.update(*a) for a in tasks["update"]], + return_exceptions=True, ) log.debug(f"Completed database writes for {lock_id} " f"({lock_author})") @@ -1015,16 +816,16 @@ class MusicCache: log.debug("Running pending writes to database") with contextlib.suppress(Exception): tasks = {"update": [], "insert": []} - for k, task in self._tasks.items(): + for (k, task) in self._tasks.items(): for t, args in task.items(): tasks[t].append(args) self._tasks = {} await asyncio.gather( - *[self.insert(*a) for a in tasks["insert"]], return_exceptions=True + *[self.database.insert(*a) for a in tasks["insert"]], return_exceptions=True ) await asyncio.gather( - *[self.update(*a) for a in tasks["update"]], return_exceptions=True + *[self.database.update(*a) for a in tasks["update"]], return_exceptions=True ) log.debug("Completed pending writes to database have finished") @@ -1034,29 +835,26 @@ class MusicCache: self._tasks[lock_id] = {"update": [], "insert": []} self._tasks[lock_id][event].append(task) - async def play_random(self): + async def get_random_from_db(self): tracks = [] try: query_data = {} - for i in range(1, 8): - date = ( - "%" - + str( - ( - datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=i) - ).date() - ) - + "%" - ) - query_data[f"day{i}"] = date + date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7) + date = int(date.timestamp()) + query_data["day"] = date + 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 - vals = await self.fetch_all("lavalink", "data", query_data) - recently_played = [r.data for r in vals if r] + vals = await self.database.fetch_all("lavalink", "data", query_data) + recently_played = [r.tracks for r in vals if r] if recently_played: track = random.choice(recently_played) - results = LoadResult(json.loads(track)) + results = LoadResult(track) tracks = list(results.tracks) except Exception: tracks = [] @@ -1065,9 +863,7 @@ class MusicCache: async def autoplay(self, player: lavalink.Player): autoplaylist = await self.config.guild(player.channel.guild).autoplaylist() - current_cache_level = ( - CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() - ) + current_cache_level = CacheLevel(await self.config.cache_level()) cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) playlist = None tracks = None @@ -1084,10 +880,10 @@ class MusicCache: if not tracks or not getattr(playlist, "tracks", None): if cache_enabled: - tracks = await self.play_random() + tracks = await self.get_random_from_db() if not tracks: ctx = namedtuple("Context", "message") - results, called_api = await self.lavalink_query( + (results, called_api) = await self.lavalink_query( ctx(player.channel.guild), player, audio_dataclasses.Query.process_input(_TOP_100_US), @@ -1124,7 +920,7 @@ class MusicCache: continue valid = True - track.extras = {"autoplay": 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 diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 00af5e97e..f9b7211d9 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import asyncio import contextlib import datetime @@ -10,17 +9,20 @@ import random import re import time import traceback -from collections import namedtuple -from io import StringIO -from typing import List, Optional, Tuple, Union, cast +from collections import Counter, namedtuple +from io import BytesIO +from pathlib import Path +from typing import List, Optional, Tuple, Union, cast, MutableMapping, Mapping import aiohttp import discord import lavalink +from discord.embeds import EmptyEmbed +from discord.utils import escape_markdown as escape from fuzzywuzzy import process -import redbot.core from redbot.core import Config, bank, checks, commands +from redbot.core.bot import Red from redbot.core.data_manager import cog_data_path from redbot.core.i18n import Translator, cog_i18n from redbot.core.utils.chat_formatting import bold, box, humanize_number, inline, pagify @@ -35,57 +37,40 @@ from redbot.core.utils.menus import ( from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate from . import audio_dataclasses -from .apis import _ERROR, HAS_SQL, MusicCache -from .checks import can_have_caching +from .apis import MusicCache +from .config import pass_config_to_dependencies from .converters import ComplexScopeParser, ScopeParser, get_lazy_converter, get_playlist_converter from .equalizer import Equalizer from .errors import ( DatabaseError, LavalinkDownloadFailed, MissingGuild, + QueryUnauthorized, SpotifyFetchError, TooManyMatches, + TrackEnqueueError, ) from .manager import ServerManager from .playlists import ( FakePlaylist, Playlist, - PlaylistScope, create_playlist, delete_playlist, get_all_playlist, + get_all_playlist_for_migration23, get_playlist, - humanize_scope, -) -from .utils import ( - CacheLevel, - Notifier, - clear_react, - draw_time, - dynamic_time, - get_description, - is_allowed, - match_url, - match_yt_playlist, - pass_config_to_dependencies, - queue_duration, - remove_react, - rgetattr, - time_convert, - track_creator, - track_limit, - url_check, - userlimit, + get_playlist_database, ) +from .utils import * _ = Translator("Audio", __file__) -__version__ = "1.0.0" +__version__ = "1.1.0" __author__ = ["aikaterna", "Draper"] log = logging.getLogger("red.audio") -_SCHEMA_VERSION = 2 +_SCHEMA_VERSION = 3 LazyGreedyConverter = get_lazy_converter("--") PlaylistConverter = get_playlist_converter() @@ -103,31 +88,32 @@ class Audio(commands.Cog): def __init__(self, bot): super().__init__() - self.bot = bot - self.config = Config.get_conf(self, 2711759130, force_registration=True) - self.skip_votes = {} - self.session = aiohttp.ClientSession() - self._connect_task = None - self._disconnect_task = None - self._cleaned_up = False - self._connection_aborted = False - self.play_lock = {} + self.bot: Red = bot + self.config: Config = Config.get_conf(self, 2711759130, force_registration=True) + self.skip_votes: MutableMapping[discord.Guild, List[discord.Member]] = {} + self.play_lock: MutableMapping[int, bool] = {} + self._dj_status_cache: MutableMapping[int, Optional[bool]] = {} + self._dj_role_cache: MutableMapping[int, Optional[int]] = {} + self.session: aiohttp.ClientSession = aiohttp.ClientSession() + self._connect_task: Optional[asyncio.Task] = None + self._disconnect_task: Optional[asyncio.Task] = None + self._cleaned_up: bool = False + self._connection_aborted: bool = False self._manager: Optional[ServerManager] = None - self._cog_name = None - self._cog_id = None - default_global = dict( + default_global: Mapping = dict( schema_version=1, cache_level=0, cache_age=365, status=False, use_external_lavalink=False, restrict=True, - current_version=redbot.core.VersionInfo.from_str("3.0.0a0").to_json(), localpath=str(cog_data_path(raw_name="Audio")), + url_keyword_blacklist=[], + url_keyword_whitelist=[], **self._default_lavalink_settings, ) - default_guild = dict( + default_guild: Mapping = dict( auto_play=False, autoplaylist=dict(enabled=False, id=None, name=None, scope=None), disconnect=False, @@ -143,6 +129,7 @@ class Audio(commands.Cog): notify=False, repeat=False, shuffle=False, + shuffle_bumped=True, thumbnail=False, volume=100, vote_enabled=False, @@ -151,7 +138,7 @@ class Audio(commands.Cog): url_keyword_blacklist=[], url_keyword_whitelist=[], ) - _playlist = dict(id=None, author=None, name=None, playlist_url=None, tracks=[]) + _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) @@ -162,36 +149,16 @@ class Audio(commands.Cog): self.config.register_custom(PlaylistScope.USER.value, **_playlist) self.config.register_guild(**default_guild) self.config.register_global(**default_global) - self.music_cache = MusicCache(bot, self.session, path=str(cog_data_path(raw_name="Audio"))) - self.play_lock = {} + self.music_cache: Optional[MusicCache] = None + self._error_counter: Counter = Counter() + self._error_timer: MutableMapping[int, int] = {} + self._disconnected_players: MutableMapping[int, bool] = {} - self._manager: Optional[ServerManager] = None # These has to be a task since this requires the bot to be ready # If it waits for ready in startup, we cause a deadlock during initial load # as initial load happens before the bot can ever be ready. - self._init_task = self.bot.loop.create_task(self.initialize()) - self._ready_event = asyncio.Event() - - @property - def owns_autoplay(self): - c = self.bot.get_cog(self._cog_name) - if c and id(c) == self._cog_id: - return c - - @owns_autoplay.setter - def owns_autoplay(self, value: commands.Cog): - if self.owns_autoplay: - raise RuntimeError( - f"`{self._cog_name}` already has ownership of autoplay, " - f"please unload it if you wish to load `{value.qualified_name}`." - ) - self._cog_name = value.qualified_name - self._cog_id = id(value) - - @owns_autoplay.deleter - def owns_autoplay(self): - self._cog_name = None - self._cog_id = None + self._init_task: asyncio.Task = self.bot.loop.create_task(self.initialize()) + self._ready_event: asyncio.Event = asyncio.Event() async def cog_before_invoke(self, ctx: commands.Context): await self._ready_event.wait() @@ -203,62 +170,64 @@ class Audio(commands.Cog): elif self._connect_task and self._connect_task.cancelled(): await ctx.send( - "You have attempted to run Audio's Lavalink server on an unsupported" - " architecture. Only settings related commands will be available." + _( + "You have attempted to run Audio's Lavalink server on an unsupported" + " architecture. Only settings related commands will be available." + ) ) raise RuntimeError( "Not running audio command due to invalid machine architecture for Lavalink." ) - - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if dj_enabled: - dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role()) + dj_role = self._dj_role_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_role() + ) + dj_role_obj = ctx.guild.get_role(dj_role) if not dj_role_obj: await self.config.guild(ctx.guild).dj_enabled.set(None) + self._dj_status_cache[ctx.guild.id] = None await self.config.guild(ctx.guild).dj_role.set(None) - await self._embed_msg(ctx, _("No DJ role found. Disabling DJ mode.")) + self._dj_role_cache[ctx.guild.id] = None + await self._embed_msg(ctx, title=_("No DJ role found. Disabling DJ mode.")) - async def initialize(self): + async def initialize(self) -> None: await self.bot.wait_until_ready() # Unlike most cases, we want the cache to exit before migration. - await self.music_cache.initialize(self.config) - await self._migrate_config( - from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION - ) - pass_config_to_dependencies(self.config, self.bot, await self.config.localpath()) - self._restart_connect() - self._disconnect_task = self.bot.loop.create_task(self.disconnect_timer()) - lavalink.register_event_listener(self.event_handler) - if not HAS_SQL: - error_message = ( - "Audio version: {version}\nThis version requires some SQL dependencies to " - "access the caching features, " - "your Python install is missing some of them.\n\n" - "For instructions on how to fix it Google " - f"`{_ERROR}`.\n" - "You will need to install the missing SQL dependency.\n\n" - ).format(version=__version__) - with contextlib.suppress(discord.HTTPException): - for page in pagify(error_message): - await self.bot.send_to_owners(page) - log.critical(error_message) + try: + pass_config_to_dependencies(self.config, self.bot, await self.config.localpath()) + self.music_cache = MusicCache(self.bot, self.session) + await self.music_cache.initialize(self.config) + await self._migrate_config( + from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION + ) + dat = get_playlist_database() + if dat: + dat.delete_scheduled() + self._restart_connect() + self._disconnect_task = self.bot.loop.create_task(self.disconnect_timer()) + lavalink.register_event_listener(self.event_handler) + except Exception as err: + log.exception("Audio failed to start up, please report this issue.", exc_info=err) + raise err self._ready_event.set() - self.bot.dispatch("red_audio_initialized", self) - async def _migrate_config(self, from_version: int, to_version: int): + async def _migrate_config(self, from_version: int, to_version: int) -> None: database_entries = [] time_now = str(datetime.datetime.now(datetime.timezone.utc)) if from_version == to_version: return - elif from_version < to_version: + if from_version < 2 <= to_version: all_guild_data = await self.config.all_guilds() all_playlist = {} - for guild_id, guild_data in all_guild_data.items(): + for (guild_id, guild_data) in all_guild_data.items(): temp_guild_playlist = guild_data.pop("playlists", None) if temp_guild_playlist: guild_playlist = {} - for count, (name, data) in enumerate(temp_guild_playlist.items(), 1): + for (count, (name, data)) in enumerate(temp_guild_playlist.items(), 1): if not data or not name: continue playlist = {"id": count, "name": name, "guild": int(guild_id)} @@ -269,7 +238,7 @@ class Audio(commands.Cog): for t in tracks_in_playlist: uri = t.get("info", {}).get("uri") if uri: - t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri} + t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri} database_entries.append( { "query": uri, @@ -289,8 +258,16 @@ class Audio(commands.Cog): await self.config.guild( cast(discord.Guild, discord.Object(id=guild_id)) ).clear_raw("playlists") - if database_entries and HAS_SQL: - await self.music_cache.insert("lavalink", database_entries) + if from_version < 3 <= to_version: + for scope in PlaylistScope.list(): + scope_playlist = await get_all_playlist_for_migration23(scope) + for p in scope_playlist: + await p.save() + await self.config.custom(scope).clear() + await self.config.schema_version.set(_SCHEMA_VERSION) + + if database_entries: + await self.music_cache.database.insert("lavalink", database_entries) def _restart_connect(self): if self._connect_task: @@ -391,106 +368,109 @@ class Audio(commands.Cog): "tracebacks for details." ) + async def error_reset(self, player: lavalink.Player): + guild = rgetattr(player, "channel.guild.id", None) + if not guild: + return + now = time.time() + seconds_allowed = 10 + last_error = self._error_timer.setdefault(guild, now) + if now - seconds_allowed > last_error: + self._error_timer[guild] = 0 + self._error_counter[guild] = 0 + + async def increase_error_counter(self, player: lavalink.Player) -> bool: + guild = rgetattr(player, "channel.guild.id", None) + if not guild: + return False + now = time.time() + self._error_counter[guild] += 1 + self._error_timer[guild] = now + return self._error_counter[guild] >= 5 + + @staticmethod + async def _players_check(): + try: + current = next( + ( + player.current + for player in lavalink.active_players() + if player.current is not None + ), + None, + ) + get_single_title = get_track_description_unformatted(current) + playing_servers = len(lavalink.active_players()) + except IndexError: + get_single_title = None + playing_servers = 0 + return get_single_title, playing_servers + + async def _status_check(self, track, playing_servers): + if playing_servers == 0: + await self.bot.change_presence(activity=None) + elif playing_servers == 1: + await self.bot.change_presence( + activity=discord.Activity(name=track, type=discord.ActivityType.listening) + ) + elif playing_servers > 1: + await self.bot.change_presence( + activity=discord.Activity( + name=_("music in {} servers").format(playing_servers), + type=discord.ActivityType.playing, + ) + ) + async def event_handler( self, player: lavalink.Player, event_type: lavalink.LavalinkEvents, extra ): - disconnect = await self.config.guild(player.channel.guild).disconnect() - autoplay = await self.config.guild(player.channel.guild).auto_play() or self.owns_autoplay - notify = await self.config.guild(player.channel.guild).notify() + current_track = player.current + current_channel = player.channel + guild = rgetattr(current_channel, "guild", None) + guild_id = rgetattr(guild, "id", None) + current_requester = rgetattr(current_track, "requester", None) + current_stream = rgetattr(current_track, "is_stream", None) + current_length = rgetattr(current_track, "length", None) + current_thumbnail = rgetattr(current_track, "thumbnail", None) + current_extras = rgetattr(current_track, "extras", {}) + guild_data = await self.config.guild(guild).all() + repeat = guild_data["repeat"] + notify = guild_data["notify"] + disconnect = guild_data["disconnect"] + autoplay = guild_data["auto_play"] + description = get_track_description(current_track) status = await self.config.status() - repeat = await self.config.guild(player.channel.guild).repeat() - async def _players_check(): - try: - get_single_title = lavalink.active_players()[0].current.title - query = audio_dataclasses.Query.process_input( - lavalink.active_players()[0].current.uri - ) - if get_single_title == "Unknown title": - get_single_title = lavalink.active_players()[0].current.uri - if not get_single_title.startswith("http"): - get_single_title = get_single_title.rsplit("/", 1)[-1] - elif query.is_local: - get_single_title = "{} - {}".format( - lavalink.active_players()[0].current.author, - lavalink.active_players()[0].current.title, - ) - else: - get_single_title = lavalink.active_players()[0].current.title - playing_servers = len(lavalink.active_players()) - except IndexError: - get_single_title = None - playing_servers = 0 - return get_single_title, playing_servers - - async def _status_check(playing_servers): - if playing_servers == 0: - await self.bot.change_presence(activity=None) - if playing_servers == 1: - single_title = await _players_check() - await self.bot.change_presence( - activity=discord.Activity( - name=single_title[0], type=discord.ActivityType.listening - ) - ) - if playing_servers > 1: - await self.bot.change_presence( - activity=discord.Activity( - name=_("music in {} servers").format(playing_servers), - type=discord.ActivityType.playing, - ) - ) + await self.error_reset(player) if event_type == lavalink.LavalinkEvents.TRACK_START: - self.skip_votes[player.channel.guild] = [] + self.skip_votes[guild] = [] playing_song = player.fetch("playing_song") requester = player.fetch("requester") player.store("prev_song", playing_song) player.store("prev_requester", requester) - player.store("playing_song", player.current) - player.store( - "requester", player.current.requester if player.current else player.current - ) - self.bot.dispatch( - "red_audio_track_start", - player.channel.guild, - player.current, - player.current.requester, - ) + player.store("playing_song", current_track) + player.store("requester", current_requester) + self.bot.dispatch("red_audio_track_start", guild, current_track, current_requester) if event_type == lavalink.LavalinkEvents.TRACK_END: prev_song = player.fetch("prev_song") prev_requester = player.fetch("prev_requester") - self.bot.dispatch( - "red_audio_track_end", player.channel.guild, prev_song, prev_requester - ) - + self.bot.dispatch("red_audio_track_end", guild, prev_song, prev_requester) if event_type == lavalink.LavalinkEvents.QUEUE_END: prev_song = player.fetch("prev_song") prev_requester = player.fetch("prev_requester") - self.bot.dispatch( - "red_audio_queue_end", player.channel.guild, prev_song, prev_requester - ) + self.bot.dispatch("red_audio_queue_end", guild, prev_song, prev_requester) if autoplay and not player.queue and player.fetch("playing_song") is not None: - if self.owns_autoplay is None: - try: - await self.music_cache.autoplay(player) - except DatabaseError: - notify_channel = player.fetch("channel") - if notify_channel: - notify_channel = self.bot.get_channel(notify_channel) - await self._embed_msg( - notify_channel, _("Autoplay: Couldn't get a valid track.") - ) - return - else: - self.bot.dispatch( - "red_audio_should_auto_play", - player, - player.channel.guild, - player.channel, - self.play_query, - ) - + try: + await self.music_cache.autoplay(player) + except DatabaseError: + notify_channel = player.fetch("channel") + if notify_channel: + notify_channel = self.bot.get_channel(notify_channel) + await self._embed_msg( + notify_channel, title=_("Couldn't get a valid track.") + ) + return if event_type == lavalink.LavalinkEvents.TRACK_START and notify: notify_channel = player.fetch("channel") prev_song = player.fetch("prev_song") @@ -502,109 +482,112 @@ class Audio(commands.Cog): if ( autoplay - and player.current.extras.get("autoplay") - and (prev_song is None or not prev_song.extras.get("autoplay")) - ): - embed = discord.Embed( - colour=(await self.bot.get_embed_colour(notify_channel)), - title=_("Auto play started."), + and current_extras.get("autoplay") + and ( + prev_song is None + or (hasattr(prev_song, "extras") and not prev_song.extras.get("autoplay")) ) - await notify_channel.send(embed=embed) + ): + await self._embed_msg(notify_channel, title=_("Auto Play Started.")) - query = audio_dataclasses.Query.process_input(player.current.uri) - - if query.is_local if player.current else False: - if player.current.title != "Unknown title": - description = "**{} - {}**\n{}".format( - player.current.author, - player.current.title, - audio_dataclasses.LocalPath(player.current.uri).to_string_hidden(), - ) - else: - description = "{}".format( - audio_dataclasses.LocalPath(player.current.uri).to_string_hidden() - ) - else: - description = "**[{}]({})**".format(player.current.title, player.current.uri) - if player.current.is_stream: + if not description: + return + if current_stream: dur = "LIVE" else: - dur = lavalink.utils.format_time(player.current.length) - embed = discord.Embed( - colour=(await self.bot.get_embed_color(notify_channel)), + dur = lavalink.utils.format_time(current_length) + + thumb = None + if await self.config.guild(guild).thumbnail() and current_thumbnail: + thumb = current_thumbnail + + notify_message = await self._embed_msg( + notify_channel, title=_("Now Playing"), description=description, + footer=_("Track length: {length} | Requested by: {user}").format( + length=dur, user=current_requester + ), + thumbnail=thumb, ) - embed.set_footer( - text=_("Track length: {length} | Requested by: {user}").format( - length=dur, user=player.current.requester - ) - ) - if ( - await self.config.guild(player.channel.guild).thumbnail() - and player.current.thumbnail - ): - embed.set_thumbnail(url=player.current.thumbnail) - notify_message = await notify_channel.send(embed=embed) player.store("notify_message", notify_message) - if event_type == lavalink.LavalinkEvents.TRACK_START and status: - player_check = await _players_check() - await _status_check(player_check[1]) + player_check = await self._players_check() + await self._status_check(*player_check) if event_type == lavalink.LavalinkEvents.TRACK_END and status: await asyncio.sleep(1) if not player.is_playing: - player_check = await _players_check() - await _status_check(player_check[1]) + player_check = await self._players_check() + await self._status_check(*player_check) - if event_type == lavalink.LavalinkEvents.QUEUE_END and notify and not autoplay: + if not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and notify: notify_channel = player.fetch("channel") if notify_channel: notify_channel = self.bot.get_channel(notify_channel) - embed = discord.Embed( - colour=(await self.bot.get_embed_colour(notify_channel)), - title=_("Queue ended."), - ) - await notify_channel.send(embed=embed) - - elif event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect and not autoplay: - self.bot.dispatch("red_audio_audio_disconnect", player.channel.guild) + await self._embed_msg(notify_channel, title=_("Queue Ended.")) + elif not autoplay and event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect: + self.bot.dispatch("red_audio_audio_disconnect", guild) await player.disconnect() - if event_type == lavalink.LavalinkEvents.QUEUE_END and status: - player_check = await _players_check() - await _status_check(player_check[1]) + player_check = await self._players_check() + await self._status_check(*player_check) - if event_type == lavalink.LavalinkEvents.TRACK_EXCEPTION: + if event_type in [ + lavalink.LavalinkEvents.TRACK_EXCEPTION, + lavalink.LavalinkEvents.TRACK_STUCK, + ]: message_channel = player.fetch("channel") - if message_channel: - message_channel = self.bot.get_channel(message_channel) - query = audio_dataclasses.Query.process_input(player.current.uri) - if player.current and query.is_local: - query = audio_dataclasses.Query.process_input(player.current.uri) - if player.current.title == "Unknown title": - description = "{}".format(query.track.to_string_hidden()) - else: - song = bold("{} - {}").format(player.current.author, player.current.title) - description = "{}\n{}".format(song, query.track.to_string_hidden()) - else: - description = bold("[{}]({})").format(player.current.title, player.current.uri) - - embed = discord.Embed( - colour=(await self.bot.get_embed_color(message_channel)), - title=_("Track Error"), - description="{}\n{}".format(extra, description), - ) - embed.set_footer(text=_("Skipping...")) - await message_channel.send(embed=embed) while True: - if player.current in player.queue: - player.queue.remove(player.current) + if current_track in player.queue: + player.queue.remove(current_track) else: break if repeat: player.current = None + if not guild_id: + return + self._error_counter.setdefault(guild_id, 0) + if guild_id not in self._error_counter: + self._error_counter[guild_id] = 0 + early_exit = await self.increase_error_counter(player) + if early_exit: + self._disconnected_players[guild_id] = True + self.play_lock[guild_id] = False + eq = player.fetch("eq") + player.queue = [] + player.store("playing_song", None) + if eq: + await self.config.custom("EQUALIZER", guild_id).eq_bands.set(eq.bands) + await player.stop() + await player.disconnect() + self.bot.dispatch("red_audio_audio_disconnect", guild) + if message_channel: + message_channel = self.bot.get_channel(message_channel) + if early_exit: + embed = discord.Embed( + colour=(await self.bot.get_embed_color(message_channel)), + title=_("Multiple errors detected"), + description=_( + "Closing the audio player " + "due to multiple errors being detected. " + "If this persists, please inform the bot owner " + "as the Audio cog may be temporally unavailable." + ), + ) + return await message_channel.send(embed=embed) + else: + description = description or "" + if event_type == lavalink.LavalinkEvents.TRACK_STUCK: + embed = discord.Embed( + title=_("Track Stuck"), description="{}".format(description) + ) + else: + embed = discord.Embed( + title=_("Track Error"), + description="{}\n{}".format(extra.replace("\n", ""), description), + ) + await message_channel.send(embed=embed) await player.skip() async def play_query( @@ -632,15 +615,22 @@ class Audio(commands.Cog): f" while trying to connect to to {channel} in {guild}." ) return + query = audio_dataclasses.Query.process_input(query) + restrict = await self.config.restrict() + if restrict and match_url(query): + valid_url = url_check(query) + if not valid_url: + raise QueryUnauthorized(f"{query} is not an allowed query.") + elif not await is_allowed(guild, f"{query}", query_obj=query): + raise QueryUnauthorized(f"{query} is not an allowed query.") player = lavalink.get_player(guild.id) - player.store("channel", channel.id) player.store("guild", guild.id) await self._data_check(guild.me) - query = audio_dataclasses.Query.process_input(query) + ctx = namedtuple("Context", "message") - results, called_api = await self.music_cache.lavalink_query(ctx(guild), player, query) + (results, called_api) = await self.music_cache.lavalink_query(ctx(guild), player, query) if not results.tracks: log.debug(f"Query returned no tracks.") @@ -652,7 +642,7 @@ class Audio(commands.Cog): ): log.debug(f"Query is not allowed in {guild} ({guild.id})") return - track.extras = {"autoplay": is_autoplay} + track.extras["autoplay"] = is_autoplay player.add(player.channel.guild.me, track) self.bot.dispatch( "red_audio_track_auto_play", player.channel.guild, track, player.channel.guild.me @@ -660,25 +650,11 @@ class Audio(commands.Cog): if not player.current: await player.play() - async def delegate_autoplay(self, cog: commands.Cog = None): - """ - Parameters - ---------- - cog: Optional[commands.Cog] - The Cog who is taking ownership of Audio's autoplay. - If :code:`None` gives ownership back to Audio - """ - if isinstance(cog, commands.Cog): - self.owns_autoplay = cog - else: - del self.owns_autoplay - @commands.group() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) async def audioset(self, ctx: commands.Context): """Music configuration options.""" - pass @audioset.command() @checks.mod_or_permissions(manage_messages=True) @@ -701,28 +677,206 @@ class Audio(commands.Cog): await self.config.guild(ctx.guild).disconnect.set(not disconnect) - embed = discord.Embed( - title=_("Auto-disconnection settings changed"), - description=msg, - colour=await ctx.embed_colour(), - ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, title=_("Setting Changed"), description=msg) @audioset.group(name="restrictions") @checks.mod_or_permissions(manage_messages=True) async def _perms(self, ctx: commands.Context): """Manages the keyword whitelist and blacklist.""" - pass + + @checks.is_owner() + @_perms.group(name="global") + async def _perms_global(self, ctx: commands.Context): + """Manages the global keyword whitelist/blacklist.""" + + @_perms_global.group(name="whitelist") + async def _perms_global_whitelist(self, ctx: commands.Context): + """Manages the global keyword whitelist.""" + + @_perms_global.group(name="blacklist") + async def _perms_global_blacklist(self, ctx: commands.Context): + """Manages the global keyword blacklist.""" + + @_perms_global_blacklist.command(name="add") + async def _perms_global_blacklist_add(self, ctx: commands.Context, *, keyword: str): + """Adds a keyword to the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.url_keyword_blacklist() as blacklist: + if keyword in blacklist: + exists = True + else: + blacklist.append(keyword) + if exists: + return await self._embed_msg(ctx, title=_("Keyword already in the blacklist.")) + else: + return await self._embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Added: `{blacklisted}` to the blacklist.").format( + blacklisted=keyword + ), + ) + + @_perms_global_whitelist.command(name="add") + async def _perms_global_whitelist_add(self, ctx: commands.Context, *, keyword: str): + """Adds a keyword to the whitelist. + + If anything is added to whitelist, it will blacklist everything else. + """ + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.url_keyword_whitelist() as whitelist: + if keyword in whitelist: + exists = True + else: + whitelist.append(keyword) + if exists: + return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) + else: + return await self._embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Added: `{whitelisted}` to the whitelist.").format( + whitelisted=keyword + ), + ) + + @_perms_global_blacklist.command(name="delete", aliases=["del", "remove"]) + async def _perms_global_blacklist_delete(self, ctx: commands.Context, *, keyword: str): + """Removes a keyword from the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = True + async with self.config.url_keyword_blacklist() as blacklist: + if keyword not in blacklist: + exists = False + else: + blacklist.remove(keyword) + if not exists: + return await self._embed_msg(ctx, title=_("Keyword is not in the blacklist.")) + else: + return await self._embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Removed: `{blacklisted}` from the blacklist.").format( + blacklisted=keyword + ), + ) + + @_perms_global_whitelist.command(name="delete", aliases=["del", "remove"]) + async def _perms_global_whitelist_delete(self, ctx: commands.Context, *, keyword: str): + """Removes a keyword from the whitelist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = True + async with self.config.url_keyword_whitelist() as whitelist: + if keyword not in whitelist: + exists = False + else: + whitelist.remove(keyword) + if not exists: + return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) + else: + return await self._embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Removed: `{whitelisted}` from the whitelist.").format( + whitelisted=keyword + ), + ) + + @_perms_global_whitelist.command(name="list") + async def _perms_global_whitelist_list(self, ctx: commands.Context): + """List all keywords added to the whitelist.""" + whitelist = await self.config.url_keyword_whitelist() + if not whitelist: + return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) + whitelist.sort() + text = "" + total = len(whitelist) + pages = [] + for i, entry in enumerate(whitelist, 1): + text += f"{i}. [{entry}]" + if i != total: + text += "\n" + if i % 10 == 0: + pages.append(box(text, lang="ini")) + text = "" + else: + pages.append(box(text, lang="ini")) + embed_colour = await ctx.embed_colour() + pages = list( + discord.Embed(title="Global Whitelist", description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @_perms_global_blacklist.command(name="list") + async def _perms_global_blacklist_list(self, ctx: commands.Context): + """List all keywords added to the blacklist.""" + blacklist = await self.config.url_keyword_blacklist() + if not blacklist: + return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) + blacklist.sort() + text = "" + total = len(blacklist) + pages = [] + for i, entry in enumerate(blacklist, 1): + text += f"{i}. [{entry}]" + if i != total: + text += "\n" + if i % 10 == 0: + pages.append(box(text, lang="ini")) + text = "" + else: + pages.append(box(text, lang="ini")) + embed_colour = await ctx.embed_colour() + pages = list( + discord.Embed(title="Global Blacklist", description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @_perms_global_whitelist.command(name="clear") + async def _perms_global_whitelist_clear(self, ctx: commands.Context): + """Clear all keywords from the whitelist.""" + whitelist = await self.config.url_keyword_whitelist() + if not whitelist: + return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) + await self.config.url_keyword_whitelist.clear() + return await self._embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("All entries have been removed from the whitelist."), + ) + + @_perms_global_blacklist.command(name="clear") + async def _perms_global_blacklist_clear(self, ctx: commands.Context): + """Clear all keywords added to the blacklist.""" + blacklist = await self.config.url_keyword_blacklist() + if not blacklist: + return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) + await self.config.url_keyword_blacklist.clear() + return await self._embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("All entries have been removed from the blacklist."), + ) @_perms.group(name="whitelist") async def _perms_whitelist(self, ctx: commands.Context): """Manages the keyword whitelist.""" - pass @_perms.group(name="blacklist") async def _perms_blacklist(self, ctx: commands.Context): """Manages the keyword blacklist.""" - pass @_perms_blacklist.command(name="add") async def _perms_blacklist_add(self, ctx: commands.Context, *, keyword: str): @@ -737,13 +891,15 @@ class Audio(commands.Cog): else: blacklist.append(keyword) if exists: - return await self._embed_msg(ctx, _("Keyword already in the blacklist.")) + return await self._embed_msg(ctx, title=_("Keyword already in the blacklist.")) else: - embed = discord.Embed(title=_("Blacklist modified"), colour=await ctx.embed_colour()) - embed.description = _("Added: `{blacklisted}` to the blacklist.").format( - blacklisted=keyword + return await self._embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Added: `{blacklisted}` to the blacklist.").format( + blacklisted=keyword + ), ) - await ctx.send(embed=embed) @_perms_whitelist.command(name="add") async def _perms_whitelist_add(self, ctx: commands.Context, *, keyword: str): @@ -761,13 +917,15 @@ class Audio(commands.Cog): else: whitelist.append(keyword) if exists: - return await self._embed_msg(ctx, _("Keyword already in the whitelist.")) + return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) else: - embed = discord.Embed(title=_("Whitelist modified"), colour=await ctx.embed_colour()) - embed.description = _("Added: `{whitelisted}` to the whitelist.").format( - whitelisted=keyword + return await self._embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Added: `{whitelisted}` to the whitelist.").format( + whitelisted=keyword + ), ) - await ctx.send(embed=embed) @_perms_blacklist.command(name="delete", aliases=["del", "remove"]) async def _perms_blacklist_delete(self, ctx: commands.Context, *, keyword: str): @@ -782,13 +940,15 @@ class Audio(commands.Cog): else: blacklist.remove(keyword) if not exists: - return await self._embed_msg(ctx, _("Keyword is not in the blacklist.")) + return await self._embed_msg(ctx, title=_("Keyword is not in the blacklist.")) else: - embed = discord.Embed(title=_("Blacklist modified"), colour=await ctx.embed_colour()) - embed.description = _("Removed: `{blacklisted}` from the blacklist.").format( - blacklisted=keyword + return await self._embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("Removed: `{blacklisted}` from the blacklist.").format( + blacklisted=keyword + ), ) - await ctx.send(embed=embed) @_perms_whitelist.command(name="delete", aliases=["del", "remove"]) async def _perms_whitelist_delete(self, ctx: commands.Context, *, keyword: str): @@ -803,20 +963,22 @@ class Audio(commands.Cog): else: whitelist.remove(keyword) if not exists: - return await self._embed_msg(ctx, _("Keyword already in the whitelist.")) + return await self._embed_msg(ctx, title=_("Keyword already in the whitelist.")) else: - embed = discord.Embed(title=_("Whitelist modified"), colour=await ctx.embed_colour()) - embed.description = _("Removed: `{whitelisted}` from the whitelist.").format( - whitelisted=keyword + return await self._embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("Removed: `{whitelisted}` from the whitelist.").format( + whitelisted=keyword + ), ) - await ctx.send(embed=embed) @_perms_whitelist.command(name="list") async def _perms_whitelist_list(self, ctx: commands.Context): """List all keywords added to the whitelist.""" whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() if not whitelist: - return await self._embed_msg(ctx, _("Nothing in the whitelist.")) + return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) whitelist.sort() text = "" total = len(whitelist) @@ -842,7 +1004,7 @@ class Audio(commands.Cog): """List all keywords added to the blacklist.""" blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() if not blacklist: - return await self._embed_msg(ctx, _("Nothing in the blacklist.")) + return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) blacklist.sort() text = "" total = len(blacklist) @@ -858,7 +1020,7 @@ class Audio(commands.Cog): pages.append(box(text, lang="ini")) embed_colour = await ctx.embed_colour() pages = list( - discord.Embed(title="Whitelist", description=page, colour=embed_colour) + discord.Embed(title="Blacklist", description=page, colour=embed_colour) for page in pages ) await menu(ctx, pages, DEFAULT_CONTROLS) @@ -868,18 +1030,26 @@ class Audio(commands.Cog): """Clear all keywords from the whitelist.""" whitelist = await self.config.guild(ctx.guild).url_keyword_whitelist() if not whitelist: - return await self._embed_msg(ctx, _("Nothing in the whitelist.")) + return await self._embed_msg(ctx, title=_("Nothing in the whitelist.")) await self.config.guild(ctx.guild).url_keyword_whitelist.clear() - return await self._embed_msg(ctx, _("All entries have been removed from the whitelist.")) + return await self._embed_msg( + ctx, + title=_("Whitelist Modified"), + description=_("All entries have been removed from the whitelist."), + ) @_perms_blacklist.command(name="clear") async def _perms_blacklist_clear(self, ctx: commands.Context): """Clear all keywords added to the blacklist.""" blacklist = await self.config.guild(ctx.guild).url_keyword_blacklist() if not blacklist: - return await self._embed_msg(ctx, _("Nothing in the blacklist.")) + return await self._embed_msg(ctx, title=_("Nothing in the blacklist.")) await self.config.guild(ctx.guild).url_keyword_blacklist.clear() - return await self._embed_msg(ctx, _("All entries have been removed from the blacklist.")) + return await self._embed_msg( + ctx, + title=_("Blacklist Modified"), + description=_("All entries have been removed from the blacklist."), + ) @audioset.group(name="autoplay") @checks.mod_or_permissions(manage_messages=True) @@ -903,15 +1073,12 @@ class Audio(commands.Cog): msg += _("\nAuto-disconnecting at queue end has been disabled.") await self.config.guild(ctx.guild).disconnect.set(False) - embed = discord.Embed( - title=_("Auto-play settings changed"), description=msg, colour=await ctx.embed_colour() - ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, title=_("Setting Changed"), description=msg) if self._player_check(ctx): await self._data_check(ctx) @_autoplay.command(name="playlist", usage=" [args]") - async def __autoplay_playlist( + async def _autoplay_playlist( self, ctx: commands.Context, playlist_matches: PlaylistConverter, @@ -929,17 +1096,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: - ​ ​ ​ ​ Global + **Scope** is one of the following: + ​Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -957,35 +1124,45 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist").format(arg=playlist_arg) + ctx, + title=_("No Playlist Found"), + description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), ) try: playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) tracks = playlist.tracks if not tracks: return await self._embed_msg( - ctx, _("Playlist {name} has no tracks.").format(name=playlist.name) + ctx, + title=_("No Tracks Found"), + description=_("Playlist {name} has no tracks.").format(name=playlist.name), ) playlist_data = dict(enabled=True, id=playlist.id, name=playlist.name, scope=scope) await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("No Playlist Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) else: return await self._embed_msg( ctx, - _("Playlist {name} (`{id}`) [**{scope}**] will be used for autoplay.").format( + title=_("Setting Changed"), + description=_( + "Playlist {name} (`{id}`) [**{scope}**] will be used for autoplay." + ).format( name=playlist.name, id=playlist.id, scope=humanize_scope( @@ -999,7 +1176,11 @@ class Audio(commands.Cog): """Resets auto-play to the default playlist.""" playlist_data = dict(enabled=False, id=None, name=None, scope=None) await self.config.guild(ctx.guild).autoplaylist.set(playlist_data) - return await self._embed_msg(ctx, _("Set auto-play playlist to default value.")) + return await self._embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Set auto-play playlist to default value."), + ) @audioset.command() @checks.admin_or_permissions(manage_roles=True) @@ -1008,24 +1189,34 @@ class Audio(commands.Cog): DJ mode allows users with the DJ role to use audio commands. """ - dj_role = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role()) + dj_role = self._dj_role_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_role() + ) + dj_role = ctx.guild.get_role(dj_role) if dj_role is None: await self._embed_msg( - ctx, _("Please set a role to use with DJ mode. Enter the role name or ID now.") + ctx, + title=_("Missing DJ Role"), + description=_( + "Please set a role to use with DJ mode. Enter the role name or ID now." + ), ) try: pred = MessagePredicate.valid_role(ctx) await ctx.bot.wait_for("message", timeout=15.0, check=pred) - await ctx.invoke(self.role, pred.result) + await ctx.invoke(self.role, role_name=pred.result) except asyncio.TimeoutError: - return await self._embed_msg(ctx, _("Response timed out, try again later.")) - - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._embed_msg(ctx, title=_("Response timed out, try again later.")) + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) await self.config.guild(ctx.guild).dj_enabled.set(not dj_enabled) + self._dj_status_cache[ctx.guild.id] = not dj_enabled await self._embed_msg( ctx, - _("DJ role: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("DJ role: {true_or_false}.").format( true_or_false=_("Enabled") if not dj_enabled else _("Disabled") ), ) @@ -1033,19 +1224,24 @@ class Audio(commands.Cog): @audioset.command() @checks.mod_or_permissions(administrator=True) async def emptydisconnect(self, ctx: commands.Context, seconds: int): - """Auto-disconnect from channel when bot is alone in it for x seconds. 0 to disable.""" + """Auto-disconnect from channel when bot is alone in it for x seconds, 0 to disable.""" if seconds < 0: - return await self._embed_msg(ctx, _("Can't be less than zero.")) + return await self._embed_msg( + ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") + ) if 10 > seconds > 0: seconds = 10 if seconds == 0: enabled = False - await self._embed_msg(ctx, _("Empty disconnect disabled.")) + await self._embed_msg( + ctx, title=_("Setting Changed"), description=_("Empty disconnect disabled.") + ) else: enabled = True await self._embed_msg( ctx, - _("Empty disconnect timer set to {num_seconds}.").format( + title=_("Setting Changed"), + description=_("Empty disconnect timer set to {num_seconds}.").format( num_seconds=dynamic_time(seconds) ), ) @@ -1056,19 +1252,24 @@ class Audio(commands.Cog): @audioset.command() @checks.mod_or_permissions(administrator=True) async def emptypause(self, ctx: commands.Context, seconds: int): - """Auto-pause after x seconds when room is empty. 0 to disable.""" + """Auto-pause after x seconds when room is empty, 0 to disable.""" if seconds < 0: - return await self._embed_msg(ctx, _("Can't be less than zero.")) + return await self._embed_msg( + ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") + ) if 10 > seconds > 0: seconds = 10 if seconds == 0: enabled = False - await self._embed_msg(ctx, _("Empty pause disabled.")) + await self._embed_msg( + ctx, title=_("Setting Changed"), description=_("Empty pause disabled.") + ) else: enabled = True await self._embed_msg( ctx, - _("Empty pause timer set to {num_seconds}.").format( + title=_("Setting Changed"), + description=_("Empty pause timer set to {num_seconds}.").format( num_seconds=dynamic_time(seconds) ), ) @@ -1078,17 +1279,22 @@ class Audio(commands.Cog): @audioset.command() @checks.mod_or_permissions(administrator=True) async def jukebox(self, ctx: commands.Context, price: int): - """Set a price for queueing tracks for non-mods. 0 to disable.""" + """Set a price for queueing tracks for non-mods, 0 to disable.""" if price < 0: - return await self._embed_msg(ctx, _("Can't be less than zero.")) + return await self._embed_msg( + ctx, title=_("Invalid Price"), description=_("Price can't be less than zero.") + ) if price == 0: jukebox = False - await self._embed_msg(ctx, _("Jukebox mode disabled.")) + await self._embed_msg( + ctx, title=_("Setting Changed"), description=_("Jukebox mode disabled.") + ) else: jukebox = True await self._embed_msg( ctx, - _("Track queueing command price set to {price} {currency}.").format( + title=_("Setting Changed"), + description=_("Track queueing command price set to {price} {currency}.").format( price=humanize_number(price), currency=await bank.get_currency_name(ctx.guild) ), ) @@ -1110,7 +1316,11 @@ class Audio(commands.Cog): self.config, self.bot, str(cog_data_path(raw_name="Audio")) ) return await self._embed_msg( - ctx, _("The localtracks path location has been reset to the default location.") + ctx, + title=_("Setting Changed"), + description=_( + "The localtracks path location has been reset to {localpath}" + ).format(localpath=str(cog_data_path(raw_name="Audio").absolute())), ) info_msg = _( @@ -1146,7 +1356,10 @@ class Audio(commands.Cog): if not temp.exists() or not temp.is_dir(): return await self._embed_msg( ctx, - _("{local_path} does not seem like a valid path.").format(local_path=local_path), + title=_("Invalid Path"), + description=_("{local_path} does not seem like a valid path.").format( + local_path=local_path + ), ) if not temp.localtrack_folder.exists(): @@ -1156,38 +1369,44 @@ class Audio(commands.Cog): "create a localtracks folder in `{localfolder}` before attempting " "to play local tracks." ).format(localfolder=temp.absolute(), localtracks=temp.localtrack_folder.absolute()) - await ctx.send( - embed=discord.Embed( - title=_("Incorrect environment."), - description=warn_msg, - colour=await ctx.embed_colour(), - ) - ) + await self._embed_msg(ctx, title=_("Invalid Environment"), description=warn_msg) local_path = str(temp.localtrack_folder.absolute()) await self.config.localpath.set(local_path) pass_config_to_dependencies(self.config, self.bot, local_path) - await self._embed_msg( - ctx, _("Localtracks path set to: {local_path}.").format(local_path=local_path) + return await self._embed_msg( + ctx, + title=_("Setting Changed"), + description=_("The localtracks path location has been set to {localpath}").format( + localpath=local_path + ), ) @audioset.command() @checks.mod_or_permissions(administrator=True) async def maxlength(self, ctx: commands.Context, seconds: Union[int, str]): - """Max length of a track to queue in seconds. 0 to disable. + """Max length of a track to queue in seconds, 0 to disable. - Accepts seconds or a value formatted like 00:00:00 (`hh:mm:ss`) or 00:00 (`mm:ss`). - Invalid input will turn the max length setting off.""" + Accepts seconds or a value formatted like 00:00:00 (`hh:mm:ss`) or 00:00 (`mm:ss`). Invalid + input will turn the max length setting off. + """ if not isinstance(seconds, int): seconds = time_convert(seconds) if seconds < 0: - return await self._embed_msg(ctx, _("Can't be less than zero.")) + return await self._embed_msg( + ctx, title=_("Invalid length"), description=_("Length can't be less than zero.") + ) if seconds == 0: - await self._embed_msg(ctx, _("Track max length disabled.")) + await self._embed_msg( + ctx, title=_("Setting Changed"), description=_("Track max length disabled.") + ) else: await self._embed_msg( - ctx, _("Track max length set to {seconds}.").format(seconds=dynamic_time(seconds)) + ctx, + title=_("Setting Changed"), + description=_("Track max length set to {seconds}.").format( + seconds=dynamic_time(seconds) + ), ) - await self.config.guild(ctx.guild).maxlength.set(seconds) @audioset.command() @@ -1198,7 +1417,8 @@ class Audio(commands.Cog): await self.config.guild(ctx.guild).notify.set(not notify) await self._embed_msg( ctx, - _("Verbose mode: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("Notify mode: {true_or_false}.").format( true_or_false=_("Enabled") if not notify else _("Disabled") ), ) @@ -1209,24 +1429,34 @@ class Audio(commands.Cog): """Toggle the domain restriction on Audio. When toggled off, users will be able to play songs from non-commercial websites and links. - When toggled on, users are restricted to YouTube, SoundCloud, - Mixer, Vimeo, Twitch, and Bandcamp links.""" + When toggled on, users are restricted to YouTube, SoundCloud, Mixer, Vimeo, Twitch, and + Bandcamp links. + """ restrict = await self.config.restrict() await self.config.restrict.set(not restrict) await self._embed_msg( ctx, - _("Commercial links only: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("Commercial links only: {true_or_false}.").format( true_or_false=_("Enabled") if not restrict else _("Disabled") ), ) @audioset.command() @checks.admin_or_permissions(manage_roles=True) - async def role(self, ctx: commands.Context, role_name: discord.Role): + async def role(self, ctx: commands.Context, *, role_name: discord.Role): """Set the role to use for DJ mode.""" await self.config.guild(ctx.guild).dj_role.set(role_name.id) - dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role()) - await self._embed_msg(ctx, _("DJ role set to: {role.name}.").format(role=dj_role_obj)) + self._dj_role_cache[ctx.guild.id] = role_name.id + dj_role = self._dj_role_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_role() + ) + dj_role_obj = ctx.guild.get_role(dj_role) + await self._embed_msg( + ctx, + title=_("Settings Changed"), + description=_("DJ role set to: {role.name}.").format(role=dj_role_obj), + ) @audioset.command() async def settings(self, ctx: commands.Context): @@ -1250,6 +1480,7 @@ class Audio(commands.Cog): current_level = CacheLevel(global_data["cache_level"]) song_repeat = _("Enabled") if data["repeat"] else _("Disabled") song_shuffle = _("Enabled") if data["shuffle"] else _("Disabled") + bumpped_shuffle = _("Enabled") if data["shuffle_bumped"] else _("Disabled") song_notify = _("Enabled") if data["notify"] else _("Disabled") song_status = _("Enabled") if global_data["status"] else _("Disabled") @@ -1288,9 +1519,16 @@ class Audio(commands.Cog): msg += _( "Repeat: [{repeat}]\n" "Shuffle: [{shuffle}]\n" + "Shuffle bumped: [{bumpped_shuffle}]\n" "Song notify msgs: [{notify}]\n" "Songs as status: [{status}]\n" - ).format(repeat=song_repeat, shuffle=song_shuffle, notify=song_notify, status=song_status) + ).format( + repeat=song_repeat, + shuffle=song_shuffle, + notify=song_notify, + status=song_status, + bumpped_shuffle=bumpped_shuffle, + ) if thumbnail: msg += _("Thumbnails: [{0}]\n").format( _("Enabled") if thumbnail else _("Disabled") @@ -1303,14 +1541,7 @@ class Audio(commands.Cog): vote_enabled=_("Enabled") if vote_enabled else _("Disabled"), ) - if self.owns_autoplay is not None: - msg += ( - "\n---" - + _("Auto-play Settings") - + "--- \n" - + _("Owning Cog: [{name}]\n").format(name=self._cog_name) - ) - elif autoplay or autoplaylist["enabled"]: + if autoplay or autoplaylist["enabled"]: if autoplaylist["enabled"]: pname = autoplaylist["name"] pid = autoplaylist["id"] @@ -1365,8 +1596,7 @@ class Audio(commands.Cog): if is_owner: msg += _("Localtracks path: [{localpath}]\n").format(**global_data) - embed = discord.Embed(colour=await ctx.embed_colour(), description=box(msg, lang="ini")) - return await ctx.send(embed=embed) + await self._embed_msg(ctx, description=box(msg, lang="ini")) @audioset.command() @checks.is_owner() @@ -1393,7 +1623,8 @@ class Audio(commands.Cog): await self.config.status.set(not status) await self._embed_msg( ctx, - _("Song titles as status: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("Song titles as status: {true_or_false}.").format( true_or_false=_("Enabled") if not status else _("Disabled") ), ) @@ -1406,7 +1637,8 @@ class Audio(commands.Cog): await self.config.guild(ctx.guild).thumbnail.set(not thumbnail) await self._embed_msg( ctx, - _("Thumbnail display: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("Thumbnail display: {true_or_false}.").format( true_or_false=_("Enabled") if not thumbnail else _("Disabled") ), ) @@ -1414,20 +1646,26 @@ class Audio(commands.Cog): @audioset.command() @checks.mod_or_permissions(administrator=True) async def vote(self, ctx: commands.Context, percent: int): - """Percentage needed for non-mods to skip tracks. 0 to disable.""" + """Percentage needed for non-mods to skip tracks, 0 to disable.""" if percent < 0: - return await self._embed_msg(ctx, _("Can't be less than zero.")) + return await self._embed_msg( + ctx, title=_("Invalid Time"), description=_("Seconds can't be less than zero.") + ) elif percent > 100: percent = 100 if percent == 0: enabled = False await self._embed_msg( - ctx, _("Voting disabled. All users can use queue management commands.") + ctx, + title=_("Setting Changed"), + description=_("Voting disabled. All users can use queue management commands."), ) else: enabled = True await self._embed_msg( - ctx, _("Vote percentage set to {percent}%.").format(percent=percent) + ctx, + title=_("Setting Changed"), + description=_("Vote percentage set to {percent}%.").format(percent=percent), ) await self.config.guild(ctx.guild).vote_percent.set(percent) @@ -1454,7 +1692,6 @@ class Audio(commands.Cog): @audioset.command(name="cache", usage="level=[5, 3, 2, 1, 0, -1, -2, -3]") @checks.is_owner() - @can_have_caching() async def _storage(self, ctx: commands.Context, *, level: int = None): """Sets the caching level. @@ -1467,7 +1704,6 @@ class Audio(commands.Cog): 5: Enables all Caches If you wish to disable a specific cache use a negative number. - """ current_level = CacheLevel(await self.config.cache_level()) spotify_cache = CacheLevel.set_spotify() @@ -1479,10 +1715,7 @@ class Audio(commands.Cog): if level is None: msg = ( - "---" - + _("Cache Settings") - + "--- \n" - + _("Max age: [{max_age}]\n") + _("Max age: [{max_age}]\n") + _("Spotify cache: [{spotify_status}]\n") + _("Youtube cache: [{youtube_status}]\n") + _("Lavalink cache: [{lavalink_status}]\n") @@ -1492,11 +1725,7 @@ class Audio(commands.Cog): youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), ) - await ctx.send( - embed=discord.Embed( - colour=await ctx.embed_colour(), description=box(msg, lang="ini") - ) - ) + await self._embed_msg(ctx, title=_("Cache Settings"), description=box(msg, lang="ini")) return await ctx.send_help() if level not in [5, 3, 2, 1, 0, -1, -2, -3]: return await ctx.send_help() @@ -1529,10 +1758,7 @@ class Audio(commands.Cog): has_youtube_cache = newcache.is_superset(youtube_cache) has_lavalink_cache = newcache.is_superset(lavalink_cache) msg = ( - "---" - + _("Cache Settings") - + "--- \n" - + _("Max age: [{max_age}]\n") + _("Max age: [{max_age}]\n") + _("Spotify cache: [{spotify_status}]\n") + _("Youtube cache: [{youtube_status}]\n") + _("Lavalink cache: [{lavalink_status}]\n") @@ -1542,20 +1768,18 @@ class Audio(commands.Cog): youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), ) - await ctx.send( - embed=discord.Embed(colour=await ctx.embed_colour(), description=box(msg, lang="ini")) - ) + + await self._embed_msg(ctx, title=_("Cache Settings"), description=box(msg, lang="ini")) await self.config.cache_level.set(newcache.value) @audioset.command(name="cacheage") @checks.is_owner() - @can_have_caching() async def _cacheage(self, ctx: commands.Context, age: int): """Sets the cache max age. - This commands allows you to set the max number of days - before an entry in the cache becomes invalid. + This commands allows you to set the max number of days before an entry in the cache becomes + invalid. """ msg = "" if age < 7: @@ -1566,7 +1790,7 @@ class Audio(commands.Cog): age = 7 msg += _("I've set the cache age to {age} days").format(age=age) await self.config.cache_age.set(age) - await self._embed_msg(ctx, msg) + await self._embed_msg(ctx, title=_("Setting Changed"), description=msg) @commands.command() @commands.guild_only() @@ -1587,7 +1811,7 @@ class Audio(commands.Cog): query = audio_dataclasses.Query.process_input(p.current.uri) if query.is_local: if p.current.title == "Unknown title": - current_title = localtracks.LocalPath(p.current.uri).to_string_hidden() + current_title = localtracks.LocalPath(p.current.uri).to_string_user() msg += "{} [`{}`]: **{}**\n".format( p.channel.guild.name, connect_dur, current_title ) @@ -1606,7 +1830,7 @@ class Audio(commands.Cog): ) if total_num == 0: - return await self._embed_msg(ctx, _("Not connected anywhere.")) + return await self._embed_msg(ctx, title=_("Not connected anywhere.")) servers_embed = [] pages = 1 for page in pagify(msg, delims=["\n"], page_length=1500): @@ -1632,45 +1856,43 @@ class Audio(commands.Cog): @commands.bot_has_permissions(embed_links=True) async def bump(self, ctx: commands.Context, index: int): """Bump a track number to the top of the queue.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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._embed_msg(ctx, _("Nothing playing.")) + return await self._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 await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to bump a track.") + ctx, + title=_("Unable To Bump Track"), + description=_("You must be in the voice channel to bump a track."), ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to bump tracks.")) + return await self._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._embed_msg( - ctx, _("Song number must be greater than 1 and within the queue limit.") + 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) - query = audio_dataclasses.Query.process_input(removed.uri) - if query.is_local: - localtrack = audio_dataclasses.LocalPath(removed.uri) - if removed.title != "Unknown title": - description = "**{} - {}**\n{}".format( - removed.author, removed.title, localtrack.to_string_hidden() - ) - else: - description = localtrack.to_string_hidden() - else: - description = "**[{}]({})**".format(removed.title, removed.uri) - await ctx.send( - embed=discord.Embed( - title=_("Moved track to the top of the queue."), - colour=await ctx.embed_colour(), - description=description, - ) + description = get_track_description(removed) + await self._embed_msg( + ctx, title=_("Moved track to the top of the queue."), description=description ) @commands.command() @@ -1679,18 +1901,26 @@ class Audio(commands.Cog): async def disconnect(self, ctx: commands.Context): """Disconnect from the voice channel.""" if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) + return await self._embed_msg(ctx, title=_("Nothing playing.")) else: - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to disconnect.")) + return await self._embed_msg( + ctx, + title=_("Unable to disconnect"), + description=_("You need the DJ role to disconnect."), + ) if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg(ctx, _("There are other people listening to music.")) + return await self._embed_msg( + ctx, title=_("There are other people listening to music.") + ) else: - await self._embed_msg(ctx, _("Disconnecting...")) + await self._embed_msg(ctx, title=_("Disconnecting...")) self.bot.dispatch("red_audio_audio_disconnect", ctx.guild) self._play_lock(ctx, False) eq = player.fetch("eq") @@ -1709,19 +1939,30 @@ class Audio(commands.Cog): """Equalizer management.""" if not self._player_check(ctx): ctx.command.reset_cooldown(ctx) - return await self._embed_msg(ctx, _("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._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 = ["◀", "⬅", "⏫", "🔼", "🔽", "⏬", "➡", "▶", "⏺", "ℹ"] + 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): - try: - await eq_message.add_reaction("ℹ") - except discord.errors.NotFound: - pass + with contextlib.suppress(discord.HTTPException): + await eq_message.add_reaction("\N{INFORMATION SOURCE}") else: start_adding_reactions(eq_message, reactions, self.bot.loop) @@ -1739,13 +1980,16 @@ class Audio(commands.Cog): "author" ] != ctx.author.id and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You are not the author of that preset setting.") + 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._embed_msg( ctx, - _( + title=_("Unable To Delete Preset"), + description=_( "{eq_preset} is not in the eq preset list.".format( eq_preset=eq_preset.capitalize() ) @@ -1756,11 +2000,13 @@ class Audio(commands.Cog): del eq_presets[eq_preset] else: return await self._embed_msg( - ctx, _("You are not the author of that preset setting.") + ctx, + title=_("Unable To Delete Preset"), + description=_("You are not the author of that preset setting."), ) await self._embed_msg( - ctx, _("The {preset_name} preset was deleted.".format(preset_name=eq_preset)) + ctx, title=_("The {preset_name} preset was deleted.".format(preset_name=eq_preset)) ) @eq.command(name="list") @@ -1768,7 +2014,7 @@ class Audio(commands.Cog): """List saved eq presets.""" eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets() if not eq_presets.keys(): - return await self._embed_msg(ctx, _("No saved equalizer presets.")) + return await self._embed_msg(ctx, title=_("No saved equalizer presets.")) space = "\N{EN SPACE}" header_name = _("Preset Name") @@ -1789,17 +2035,14 @@ class Audio(commands.Cog): 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=await ctx.embed_colour(), description=f"{header}\n{formatted_page}" - ) + 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) - if len(page_list) == 1: - return await ctx.send(embed=page_list[0]) await menu(ctx, page_list, DEFAULT_CONTROLS) @eq.command(name="load") @@ -1811,20 +2054,28 @@ class Audio(commands.Cog): eq_values = eq_presets[eq_preset]["bands"] except KeyError: return await self._embed_msg( - ctx, _("No preset named {eq_preset}.".format(eq_preset=eq_preset)) + 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._embed_msg(ctx, _("Nothing playing.")) + return await self._embed_msg(ctx, title=_("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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: if not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You need the DJ role to load equalizer presets.") + 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) @@ -1844,12 +2095,16 @@ class Audio(commands.Cog): async def _eq_reset(self, ctx: commands.Context): """Reset the eq to 0 across all bands.""" if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._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: if not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You need the DJ role to reset the equalizer.") + 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()) @@ -1874,15 +2129,20 @@ class Audio(commands.Cog): async def _eq_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._embed_msg(ctx, _("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._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: if not await self._can_instaskip(ctx, ctx.author): + ctx.command.reset_cooldown(ctx) return await self._embed_msg( - ctx, _("You need the DJ role to save equalizer presets.") + ctx, + title=_("Unable To Save Preset"), + description=_("You need the DJ role to save equalizer presets."), ) if not eq_preset: - await self._embed_msg(ctx, _("Please enter a name for this equalizer preset.")) + await self._embed_msg(ctx, title=_("Please enter a name for this equalizer preset.")) try: eq_name_msg = await ctx.bot.wait_for( "message", @@ -1891,8 +2151,13 @@ class Audio(commands.Cog): ) eq_preset = eq_name_msg.content.split(" ")[0].strip('"').lower() except asyncio.TimeoutError: + ctx.command.reset_cooldown(ctx) return await self._embed_msg( - ctx, _("No equalizer preset name entered, try the command again later.") + ctx, + title=_("Unable To Save Preset"), + description=_( + "No equalizer preset name entered, try the command again later." + ), ) eq_exists_msg = None @@ -1901,13 +2166,16 @@ class Audio(commands.Cog): eq_list = list(eq_presets.keys()) if len(eq_preset) > 20: - return await self._embed_msg(ctx, _("Try the command again with a shorter name.")) - if eq_preset in eq_list: - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Preset name already exists, do you want to replace it?"), + ctx.command.reset_cooldown(ctx) + return await self._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._embed_msg( + ctx, title=_("Preset name already exists, do you want to replace it?") ) - eq_exists_msg = await ctx.send(embed=embed) start_adding_reactions(eq_exists_msg, ReactionPredicate.YES_OR_NO_EMOJIS) pred = ReactionPredicate.yes_or_no(eq_exists_msg, ctx.author) await ctx.bot.wait_for("reaction_add", check=pred) @@ -1916,6 +2184,7 @@ class Audio(commands.Cog): 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) @@ -1935,7 +2204,7 @@ class Audio(commands.Cog): await self._clear_react(eq_exists_msg) await eq_exists_msg.edit(embed=embed3) else: - await ctx.send(embed=embed3) + await self._embed_msg(ctx, embed=embed3) @eq.command(name="set") async def _eq_set(self, ctx: commands.Context, band_name_or_position, band_value: float): @@ -1947,13 +2216,17 @@ class Audio(commands.Cog): Setting a band value to -0.25 nullifies it while +0.25 is double. """ if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) + return await self._embed_msg(ctx, title=_("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You need the DJ role to set equalizer presets.") + ctx, + title=_("Unable To Set Preset"), + description=_("You need the DJ role to set equalizer presets."), ) player = lavalink.get_player(ctx.guild.id) @@ -1992,7 +2265,8 @@ class Audio(commands.Cog): if band_number not in range(0, bands_num) and band_name_or_position not in band_names: return await self._embed_msg( ctx, - _( + title=_("Invalid Band"), + description=_( "Valid band numbers are 1-15 or the band names listed in " "the help for this command." ), @@ -2016,7 +2290,8 @@ class Audio(commands.Cog): content=box(eq.visualise(), lang="ini"), embed=discord.Embed( colour=await ctx.embed_colour(), - title=_( + title=_("Preset Modified"), + description=_( "The {band_name}Hz band has been set to {band_value}.".format( band_name=band_name, band_value=band_value ) @@ -2046,7 +2321,11 @@ class Audio(commands.Cog): _dir = audio_dataclasses.LocalPath.joinpath(folder) if not _dir.exists(): return await self._embed_msg( - ctx, _("No localtracks folder named {name}.").format(name=folder) + ctx, + title=_("Folder Not Found"), + description=_("Localtracks folder named {name} does not exist.").format( + name=folder + ), ) query = audio_dataclasses.Query.process_input(_dir, search_subfolders=play_subfolders) await self._local_play_all(ctx, query, from_search=False if not folder else True) @@ -2060,7 +2339,7 @@ class Audio(commands.Cog): ctx, search_subfolders=play_subfolders ) if not localtracks_folders: - return await self._embed_msg(ctx, _("No album folders found.")) + return await self._embed_msg(ctx, title=_("No album folders found.")) async with ctx.typing(): len_folder_pages = math.ceil(len(localtracks_folders) / 5) folder_page_list = [] @@ -2071,7 +2350,7 @@ class Audio(commands.Cog): async def _local_folder_menu( ctx: commands.Context, pages: list, - controls: dict, + controls: MutableMapping, message: discord.Message, page: int, timeout: float, @@ -2084,14 +2363,14 @@ class Audio(commands.Cog): return None local_folder_controls = { - "1⃣": _local_folder_menu, - "2⃣": _local_folder_menu, - "3⃣": _local_folder_menu, - "4⃣": _local_folder_menu, - "5⃣": _local_folder_menu, - "⬅": prev_page, - "❌": close_menu, - "➡": next_page, + "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu, + "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu, + "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu, + "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu, + "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _local_folder_menu, + "\N{LEFTWARDS BLACK ARROW}": prev_page, + "\N{CROSS MARK}": close_menu, + "\N{BLACK RIGHTWARDS ARROW}": next_page, } dj_enabled = await self.config.guild(ctx.guild).dj_enabled() @@ -2102,7 +2381,7 @@ class Audio(commands.Cog): @local.command(name="search") async def local_search( - self, ctx: commands.Context, play_subfolders: Optional[bool] = True, *, search_words + self, ctx: commands.Context, search_subfolders: Optional[bool] = True, *, search_words ): """Search for songs across all localtracks folders.""" if not await self._localtracks_check(ctx): @@ -2114,19 +2393,21 @@ class Audio(commands.Cog): audio_dataclasses.LocalPath( await self.config.localpath() ).localtrack_folder.absolute(), - search_subfolders=play_subfolders, + search_subfolders=search_subfolders, ) ), ) if not all_tracks: - return await self._embed_msg(ctx, _("No album folders found.")) + return await self._embed_msg(ctx, title=_("No album folders found.")) async with ctx.typing(): search_list = await self._build_local_search_list(all_tracks, search_words) if not search_list: - return await self._embed_msg(ctx, _("No matches.")) + return await self._embed_msg(ctx, title=_("No matches.")) return await ctx.invoke(self.search, query=search_list) - async def _localtracks_folders(self, ctx: commands.Context, search_subfolders=False): + async def _localtracks_folders( + self, ctx: commands.Context, search_subfolders=False + ) -> Optional[List[Union[Path, audio_dataclasses.LocalPath]]]: audio_data = audio_dataclasses.LocalPath( audio_dataclasses.LocalPath(None).localtrack_folder.absolute() ) @@ -2135,7 +2416,9 @@ class Audio(commands.Cog): return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders() - async def _folder_list(self, ctx: commands.Context, query: audio_dataclasses.Query): + async def _folder_list( + self, ctx: commands.Context, query: audio_dataclasses.Query + ) -> Optional[List[audio_dataclasses.Query]]: if not await self._localtracks_check(ctx): return query = audio_dataclasses.Query.process_input(query) @@ -2149,7 +2432,7 @@ class Audio(commands.Cog): async def _folder_tracks( self, ctx, player: lavalink.player_manager.Player, query: audio_dataclasses.Query - ): + ) -> Optional[List[lavalink.rest_api.Track]]: if not await self._localtracks_check(ctx): return @@ -2167,7 +2450,7 @@ class Audio(commands.Cog): async def _local_play_all( self, ctx: commands.Context, query: audio_dataclasses.Query, from_search=False - ): + ) -> None: if not await self._localtracks_check(ctx): return if from_search: @@ -2176,7 +2459,9 @@ class Audio(commands.Cog): ) await ctx.invoke(self.search, query=query) - async def _all_folder_tracks(self, ctx: commands.Context, query: audio_dataclasses.Query): + async def _all_folder_tracks( + self, ctx: commands.Context, query: audio_dataclasses.Query + ) -> Optional[List[audio_dataclasses.Query]]: if not await self._localtracks_check(ctx): return @@ -2186,12 +2471,14 @@ class Audio(commands.Cog): else query.track.tracks_in_folder() ) - async def _localtracks_check(self, ctx: commands.Context): + async def _localtracks_check(self, ctx: commands.Context) -> bool: folder = audio_dataclasses.LocalPath(None) if folder.localtrack_folder.exists(): return True if ctx.invoked_with != "start": - await self._embed_msg(ctx, _("No localtracks folder.")) + await self._embed_msg( + ctx, title=_("Invalid Environment"), description=_("No localtracks folder.") + ) return False @staticmethod @@ -2202,7 +2489,7 @@ class Audio(commands.Cog): for track_match, percent_match in search_results: if percent_match > 60: search_list.extend( - [i.track.to_string_hidden() for i in to_search if i.track.name == track_match] + [i.track.to_string_user() for i in to_search if i.track.name == track_match] ) return search_list @@ -2212,7 +2499,7 @@ class Audio(commands.Cog): async def now(self, ctx: commands.Context): """Now playing.""" if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) + return await self._embed_msg(ctx, title=_("Nothing playing.")) expected = ("⏮", "⏹", "⏯", "⏭") emoji = {"prev": "⏮", "stop": "⏹", "pause": "⏯", "next": "⏭"} player = lavalink.get_player(ctx.guild.id) @@ -2223,25 +2510,10 @@ class Audio(commands.Cog): dur = "LIVE" else: dur = lavalink.utils.format_time(player.current.length) - query = audio_dataclasses.Query.process_input(player.current.uri) - if query.is_local: - if not player.current.title == "Unknown title": - song = "**{track.author} - {track.title}**\n{uri}\n" - else: - song = "{uri}\n" - else: - song = "**[{track.title}]({track.uri})**\n" - song += _("Requested by: **{track.requester}**") + song = get_track_description(player.current) + song += _("\n Requested by: **{track.requester}**") song += "\n\n{arrow}`{pos}`/`{dur}`" - song = song.format( - track=player.current, - uri=audio_dataclasses.LocalPath(player.current.uri).to_string_hidden() - if audio_dataclasses.Query.process_input(player.current.uri).is_local - else player.current.uri, - arrow=arrow, - pos=pos, - dur=dur, - ) + song = song.format(track=player.current, arrow=arrow, pos=pos, dur=dur) else: song = _("Nothing.") @@ -2249,16 +2521,14 @@ class Audio(commands.Cog): with contextlib.suppress(discord.HTTPException): await player.fetch("np_message").delete() - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Now Playing"), description=song - ) + embed = discord.Embed(title=_("Now Playing"), description=song) if await self.config.guild(ctx.guild).thumbnail() and player.current: if player.current.thumbnail: embed.set_thumbnail(url=player.current.thumbnail) shuffle = await self.config.guild(ctx.guild).shuffle() repeat = await self.config.guild(ctx.guild).repeat() - autoplay = await self.config.guild(ctx.guild).auto_play() or self.owns_autoplay + autoplay = await self.config.guild(ctx.guild).auto_play() text = "" text += ( _("Auto-Play") @@ -2277,18 +2547,21 @@ class Audio(commands.Cog): + ": " + ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}") ) - embed.set_footer(text=text) - message = await ctx.send(embed=embed) + message = await self._embed_msg(ctx, embed=embed, footer=text) player.store("np_message", message) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): return + if not player.queue: + expected = ("⏹", "⏯") if player.current: task = start_adding_reactions(message, expected[:4], ctx.bot.loop) else: @@ -2325,49 +2598,40 @@ class Audio(commands.Cog): @commands.bot_has_permissions(embed_links=True) async def pause(self, ctx: commands.Context): """Pause or resume a playing track.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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._embed_msg(ctx, _("Nothing playing.")) + return await self._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 await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel pause or resume.") + ctx, + title=_("Unable To Manage Tracks"), + description=_("You must be in the voice channel to pause or resume."), ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): return await self._embed_msg( - ctx, _("You need the DJ role to pause or resume tracks.") + ctx, + title=_("Unable To Manage Tracks"), + description=_("You need the DJ role to pause or resume tracks."), ) if not player.current: - return await self._embed_msg(ctx, _("Nothing playing.")) - query = audio_dataclasses.Query.process_input(player.current.uri) - if query.is_local: - query = audio_dataclasses.Query.process_input(player.current.uri) - if player.current.title == "Unknown title": - description = "{}".format(query.track.to_string_hidden()) - else: - song = bold("{} - {}").format(player.current.author, player.current.title) - description = "{}\n{}".format(song, query.track.to_string_hidden()) - else: - description = bold("[{}]({})").format(player.current.title, player.current.uri) + return await self._embed_msg(ctx, title=_("Nothing playing.")) + description = get_track_description(player.current) if player.current and not player.paused: await player.pause() - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Track Paused"), description=description - ) - return await ctx.send(embed=embed) + return await self._embed_msg(ctx, title=_("Track Paused"), description=description) if player.current and player.paused: await player.pause(False) - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Track Resumed"), description=description - ) - return await ctx.send(embed=embed) + return await self._embed_msg(ctx, title=_("Track Resumed"), description=description) - await self._embed_msg(ctx, _("Nothing playing.")) + await self._embed_msg(ctx, title=_("Nothing playing.")) @commands.command() @commands.guild_only() @@ -2375,7 +2639,7 @@ class Audio(commands.Cog): async def percent(self, ctx: commands.Context): """Queue percentage.""" if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) + return await self._embed_msg(ctx, title=_("Nothing playing.")) player = lavalink.get_player(ctx.guild.id) queue_tracks = player.queue requesters = {"total": 0, "users": {}} @@ -2399,7 +2663,7 @@ class Audio(commands.Cog): ) await _usercount(req_username) except AttributeError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) for req_username in requesters["users"]: percentage = float(requesters["users"][req_username]["songcount"]) / float( @@ -2419,30 +2683,37 @@ class Audio(commands.Cog): ) queue_user = ["{}: {:g}%".format(x[0], x[1]) for x in top_queue_users] queue_user_list = "\n".join(queue_user) - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Queued and playing tracks:"), - description=queue_user_list, + await self._embed_msg( + ctx, title=_("Queued and playing tracks:"), description=queue_user_list ) - await ctx.send(embed=embed) @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) async def play(self, ctx: commands.Context, *, query: str): """Play a URL or search for a track.""" + query = audio_dataclasses.Query.process_input(query) guild_data = await self.config.guild(ctx.guild).all() restrict = await self.config.restrict() if restrict and match_url(query): valid_url = url_check(query) if not valid_url: - return await self._embed_msg(ctx, _("That URL is not allowed.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("That URL is not allowed."), + ) + elif not await is_allowed(ctx.guild, f"{query}", query_obj=query): + return await self._embed_msg( + ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.") + ) if not self._player_check(ctx): if self._connection_aborted: - msg = _("Connection to Lavalink has failed.") + msg = _("Connection to Lavalink has failed") + desc = EmptyEmbed if await ctx.bot.is_owner(ctx.author): - msg += " " + _("Please check your console or logs for details.") - return await self._embed_msg(ctx, msg) + desc = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=msg, description=desc) try: if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect @@ -2450,20 +2721,32 @@ class Audio(commands.Cog): and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + ctx, + title=_("Unable To Play Tracks"), + description=_("I don't have permission to connect to your channel."), ) await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) except AttributeError: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("Connect to a voice channel first."), + ) except IndexError: return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Play Tracks"), + description=_("Connection to Lavalink has not yet been established."), ) if guild_data["dj_enabled"]: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to queue tracks.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to queue tracks."), + ) player = lavalink.get_player(ctx.guild.id) player.store("channel", ctx.channel.id) @@ -2474,16 +2757,224 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to use the play command.") + ctx, + title=_("Unable To Play Tracks"), + description=_("You must be in the voice channel to use the play command."), + ) + if not query.valid: + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("No tracks found for `{query}`.").format( + query=query.to_string_user() + ), ) if not await self._currency_check(ctx, guild_data["jukebox_price"]): return - query = audio_dataclasses.Query.process_input(query) - if not query.valid: - return await self._embed_msg(ctx, _("No tracks to play.")) if query.is_spotify: return await self._get_spotify_tracks(ctx, query) - await self._enqueue_tracks(ctx, query) + try: + await self._enqueue_tracks(ctx, query) + except QueryUnauthorized as err: + return await self._embed_msg( + ctx, title=_("Unable To Play Tracks"), description=err.message + ) + + @commands.command() + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + async def bumpplay( + self, ctx: commands.Context, play_now: Optional[bool] = False, *, query: str + ): + """Force play a URL or search for a track.""" + query = audio_dataclasses.Query.process_input(query) + if not query.single_track: + return await self._embed_msg( + ctx, + title=_("Unable to bump track"), + description=_("Only single tracks work with bump play."), + ) + guild_data = await self.config.guild(ctx.guild).all() + restrict = await self.config.restrict() + if restrict and match_url(query): + valid_url = url_check(query) + if not valid_url: + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("That URL is not allowed."), + ) + elif not await is_allowed(ctx.guild, f"{query}", query_obj=query): + return await self._embed_msg( + ctx, title=_("Unable To Play Tracks"), description=_("That track is not allowed.") + ) + if not self._player_check(ctx): + if self._connection_aborted: + msg = _("Connection to Lavalink has failed") + desc = EmptyEmbed + if await ctx.bot.is_owner(ctx.author): + desc = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=msg, description=desc) + try: + if ( + not ctx.author.voice.channel.permissions_for(ctx.me).connect + or not ctx.author.voice.channel.permissions_for(ctx.me).move_members + and userlimit(ctx.author.voice.channel) + ): + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("I don't have permission to connect to your channel."), + ) + await lavalink.connect(ctx.author.voice.channel) + player = lavalink.get_player(ctx.guild.id) + player.store("connect", datetime.datetime.utcnow()) + except AttributeError: + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("Connect to a voice channel first."), + ) + except IndexError: + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("Connection to Lavalink has not yet been established."), + ) + if guild_data["dj_enabled"]: + if not await self._can_instaskip(ctx, ctx.author): + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to queue tracks."), + ) + player = lavalink.get_player(ctx.guild.id) + + player.store("channel", ctx.channel.id) + player.store("guild", ctx.guild.id) + await self._eq_check(ctx, player) + await self._data_check(ctx) + if ( + not ctx.author.voice or ctx.author.voice.channel != player.channel + ) and not await self._can_instaskip(ctx, ctx.author): + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You must be in the voice channel to use the play command."), + ) + if not query.valid: + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("No tracks found for `{query}`.").format( + query=query.to_string_user() + ), + ) + if not await self._currency_check(ctx, guild_data["jukebox_price"]): + return + try: + if query.is_spotify: + tracks = await self._get_spotify_tracks(ctx, query) + else: + tracks = await self._enqueue_tracks(ctx, query, enqueue=False) + except QueryUnauthorized as err: + return await self._embed_msg( + ctx, title=_("Unable To Play Tracks"), description=err.message + ) + if isinstance(tracks, discord.Message): + return + elif not tracks: + self._play_lock(ctx, False) + title = _("Unable To Play Tracks") + desc = _("No tracks found for `{query}`.").format(query=query.to_string_user()) + embed = discord.Embed(title=title, description=desc) + if await self.config.use_external_lavalink() and query.is_local: + embed.description = _( + "Local tracks will not work " + "if the `Lavalink.jar` cannot see the track.\n" + "This may be due to permissions or because Lavalink.jar is being run " + "in a different machine than the local tracks." + ) + elif ( + query.is_local and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + title = _("Track is not playable.") + embed = discord.Embed(title=title) + embed.description = _( + "**{suffix}** is not a fully supported format and some " "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) + elif isinstance(tracks, discord.Message): + return + queue_dur = await queue_duration(ctx) + lavalink.utils.format_time(queue_dur) + index = query.track_index + seek = 0 + if query.start_time: + seek = query.start_time + single_track = ( + tracks + if isinstance(tracks, lavalink.rest_api.Track) + else tracks[index] + if index + else tracks[0] + ) + if seek and seek > 0: + single_track.start_timestamp = seek * 1000 + if not await is_allowed( + ctx.guild, + ( + f"{single_track.title} {single_track.author} {single_track.uri} " + f"{str(audio_dataclasses.Query.process_input(single_track))}" + ), + ): + log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("This track is not allowed in this server."), + ) + elif guild_data["maxlength"] > 0: + if track_limit(single_track, guild_data["maxlength"]): + single_track.requester = ctx.author + player.queue.insert(0, single_track) + player.maybe_shuffle() + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, single_track, ctx.author + ) + else: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("Track exceeds maximum length."), + ) + + else: + single_track.requester = ctx.author + single_track.extras["bumped"] = True + player.queue.insert(0, single_track) + player.maybe_shuffle() + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, single_track, ctx.author + ) + description = get_track_description(single_track) + footer = None + if not play_now and not guild_data["shuffle"] and queue_dur > 0: + footer = _("{time} until track playback: #1 in queue").format( + time=lavalink.utils.format_time(queue_dur) + ) + await self._embed_msg( + ctx, title=_("Track Enqueued"), description=description, footer=footer + ) + + if not player.current: + await player.play() + elif play_now: + await player.skip() + + self._play_lock(ctx, False) @commands.command() @commands.guild_only() @@ -2494,7 +2985,7 @@ class Audio(commands.Cog): async def _category_search_menu( ctx: commands.Context, pages: list, - controls: dict, + controls: MutableMapping, message: discord.Message, page: int, timeout: float, @@ -2509,7 +3000,7 @@ class Audio(commands.Cog): async def _playlist_search_menu( ctx: commands.Context, pages: list, - controls: dict, + controls: MutableMapping, message: discord.Message, page: int, timeout: float, @@ -2524,24 +3015,24 @@ class Audio(commands.Cog): return output category_search_controls = { - "1⃣": _category_search_menu, - "2⃣": _category_search_menu, - "3⃣": _category_search_menu, - "4⃣": _category_search_menu, - "5⃣": _category_search_menu, - "⬅": prev_page, - "❌": close_menu, - "➡": next_page, + "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu, + "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu, + "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu, + "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu, + "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _category_search_menu, + "\N{LEFTWARDS BLACK ARROW}": prev_page, + "\N{CROSS MARK}": close_menu, + "\N{BLACK RIGHTWARDS ARROW}": next_page, } playlist_search_controls = { - "1⃣": _playlist_search_menu, - "2⃣": _playlist_search_menu, - "3⃣": _playlist_search_menu, - "4⃣": _playlist_search_menu, - "5⃣": _playlist_search_menu, - "⬅": prev_page, - "❌": close_menu, - "➡": next_page, + "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu, + "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu, + "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu, + "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu, + "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _playlist_search_menu, + "\N{LEFTWARDS BLACK ARROW}": prev_page, + "\N{CROSS MARK}": close_menu, + "\N{BLACK RIGHTWARDS ARROW}": next_page, } api_data = await self._check_api_tokens() @@ -2554,7 +3045,8 @@ class Audio(commands.Cog): ): return await self._embed_msg( ctx, - _( + title=_("Invalid Environment"), + description=_( "The owner needs to set the Spotify client ID, Spotify client secret, " "and YouTube API key before Spotify URLs or codes can be used. " "\nSee `{prefix}audioset youtubeapi` and `{prefix}audioset spotifyapi` " @@ -2564,10 +3056,11 @@ class Audio(commands.Cog): guild_data = await self.config.guild(ctx.guild).all() if not self._player_check(ctx): if self._connection_aborted: - msg = _("Connection to Lavalink has failed.") + msg = _("Connection to Lavalink has failed") + desc = EmptyEmbed if await ctx.bot.is_owner(ctx.author): - msg += " " + _("Please check your console or logs for details.") - return await self._embed_msg(ctx, msg) + desc = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=msg, description=desc) try: if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect @@ -2575,20 +3068,32 @@ class Audio(commands.Cog): and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + ctx, + title=_("Unable To Play Tracks"), + description=_("I don't have permission to connect to your channel."), ) await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) except AttributeError: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("Connect to a voice channel first."), + ) except IndexError: return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Play Tracks"), + description=_("Connection to Lavalink has not yet been established."), ) if guild_data["dj_enabled"]: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to queue tracks.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to queue tracks."), + ) player = lavalink.get_player(ctx.guild.id) player.store("channel", ctx.channel.id) @@ -2599,14 +3104,20 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to use the genre command.") + ctx, + title=_("Unable To Play Tracks"), + description=_("You must be in the voice channel to use the genre command."), ) try: category_list = await self.music_cache.spotify_api.get_categories() except SpotifyFetchError as error: - return await self._embed_msg(ctx, _(error.message).format(prefix=ctx.prefix)) + return await self._embed_msg( + ctx, + title=_("No categories found"), + description=_(error.message).format(prefix=ctx.prefix), + ) if not category_list: - return await self._embed_msg(ctx, _("No categories found, try again later.")) + return await self._embed_msg(ctx, title=_("No categories found, try again later.")) len_folder_pages = math.ceil(len(category_list) / 5) category_search_page_list = [] for page_num in range(1, len_folder_pages + 1): @@ -2616,13 +3127,13 @@ class Audio(commands.Cog): category_search_page_list.append(embed) cat_menu_output = await menu(ctx, category_search_page_list, category_search_controls) if not cat_menu_output: - return await self._embed_msg(ctx, _("No categories selected, try again later.")) + return await self._embed_msg(ctx, title=_("No categories selected, try again later.")) category_name, category_pick = cat_menu_output playlists_list = await self.music_cache.spotify_api.get_playlist_from_category( category_pick ) if not playlists_list: - return await self._embed_msg(ctx, _("No categories found, try again later.")) + return await self._embed_msg(ctx, title=_("No categories found, try again later.")) len_folder_pages = math.ceil(len(playlists_list) / 5) playlists_search_page_list = [] for page_num in range(1, len_folder_pages + 1): @@ -2637,31 +3148,32 @@ class Audio(commands.Cog): playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls) query = audio_dataclasses.Query.process_input(playlists_pick) if not query.valid: - return await self._embed_msg(ctx, _("No tracks to play.")) + return await self._embed_msg(ctx, title=_("No tracks to play.")) if not await self._currency_check(ctx, guild_data["jukebox_price"]): return if query.is_spotify: return await self._get_spotify_tracks(ctx, query) - return await self._embed_msg(ctx, _("Couldn't find tracks for the selected playlist.")) + return await self._embed_msg( + ctx, title=_("Couldn't find tracks for the selected playlist.") + ) @staticmethod async def _genre_search_button_action( ctx: commands.Context, options, emoji, page, playlist=False ): try: - if emoji == "1⃣": + if emoji == "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": search_choice = options[0 + (page * 5)] - elif emoji == "2⃣": + elif emoji == "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": search_choice = options[1 + (page * 5)] - elif emoji == "3⃣": + elif emoji == "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": search_choice = options[2 + (page * 5)] - elif emoji == "4⃣": + elif emoji == "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": search_choice = options[3 + (page * 5)] - elif emoji == "5⃣": + elif emoji == "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": search_choice = options[4 + (page * 5)] else: search_choice = options[0 + (page * 5)] - # TODO: Verify this doesn't break exit and arrows except IndexError: search_choice = options[-1] if not playlist: @@ -2711,10 +3223,11 @@ class Audio(commands.Cog): """Starts auto play.""" if not self._player_check(ctx): if self._connection_aborted: - msg = _("Connection to Lavalink has failed.") + msg = _("Connection to Lavalink has failed") + desc = EmptyEmbed if await ctx.bot.is_owner(ctx.author): - msg += " " + _("Please check your console or logs for details.") - return await self._embed_msg(ctx, msg) + desc = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=msg, description=desc) try: if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect @@ -2722,21 +3235,33 @@ class Audio(commands.Cog): and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + ctx, + title=_("Unable To Play Tracks"), + description=_("I don't have permission to connect to your channel."), ) await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) except AttributeError: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("Connect to a voice channel first."), + ) except IndexError: return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Play Tracks"), + description=_("Connection to Lavalink has not yet been established."), ) guild_data = await self.config.guild(ctx.guild).all() if guild_data["dj_enabled"]: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to queue tracks.")) + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to queue tracks."), + ) player = lavalink.get_player(ctx.guild.id) player.store("channel", ctx.channel.id) @@ -2747,37 +3272,30 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to use the autoplay command.") + ctx, + title=_("Unable To Play Tracks"), + description=_("You must be in the voice channel to use the autoplay command."), ) if not await self._currency_check(ctx, guild_data["jukebox_price"]): return - if self.owns_autoplay is None: - try: - await self.music_cache.autoplay(player) - except DatabaseError: - notify_channel = player.fetch("channel") - if notify_channel: - notify_channel = self.bot.get_channel(notify_channel) - await self._embed_msg( - notify_channel, _("Autoplay: Couldn't get a valid track.") - ) - return - else: - self.bot.dispatch( - "red_audio_should_auto_play", - player, - player.channel.guild, - player.channel, - self.play_query, - ) + + try: + await self.music_cache.autoplay(player) + except DatabaseError: + notify_channel = player.fetch("channel") + if notify_channel: + notify_channel = self.bot.get_channel(notify_channel) + await self._embed_msg(notify_channel, title=_("Couldn't get a valid track.")) + return + if not guild_data["auto_play"]: await ctx.invoke(self._autoplay_toggle) if not guild_data["notify"] and ( (player.current and not player.current.extras.get("autoplay")) or not player.current ): - await self._embed_msg(ctx, _("Auto play started.")) + await self._embed_msg(ctx, title=_("Auto play started.")) elif player.current: - await self._embed_msg(ctx, _("Adding a track to queue.")) + await self._embed_msg(ctx, title=_("Adding a track to queue.")) async def _get_spotify_tracks(self, ctx: commands.Context, query: audio_dataclasses.Query): if ctx.invoked_with in ["play", "genre"]: @@ -2794,7 +3312,8 @@ class Audio(commands.Cog): ): return await self._embed_msg( ctx, - _( + title=_("Invalid Environment"), + description=_( "The owner needs to set the Spotify client ID, Spotify client secret, " "and YouTube API key before Spotify URLs or codes can be used. " "\nSee `{prefix}audioset youtubeapi` and `{prefix}audioset spotifyapi` " @@ -2804,7 +3323,9 @@ class Audio(commands.Cog): try: if self.play_lock[ctx.message.guild.id]: return await self._embed_msg( - ctx, _("Wait until the playlist has finished loading.") + ctx, + title=_("Unable To Get Tracks"), + description=_("Wait until the playlist has finished loading."), ) except KeyError: pass @@ -2815,10 +3336,22 @@ class Audio(commands.Cog): ctx, "track", query.id, skip_youtube=True, notifier=None ) if not res: - return await self._embed_msg(ctx, _("Nothing found.")) + title = _("Nothing found.") + embed = discord.Embed(title=title) + if ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + title = _("Track is not playable.") + description = _( + "**{suffix}** is not a fully supported " + "format and some tracks may not play." + ).format(suffix=query.suffix) + embed = discord.Embed(title=title, description=description) + return await self._embed_msg(ctx, embed=embed) except SpotifyFetchError as error: self._play_lock(ctx, False) - return await self._embed_msg(ctx, _(error.message).format(prefix=ctx.prefix)) + return await self._embed_msg(ctx, title=_(error.message).format(prefix=ctx.prefix)) self._play_lock(ctx, False) try: if enqueue_tracks: @@ -2826,12 +3359,33 @@ class Audio(commands.Cog): new_query.start_time = query.start_time return await self._enqueue_tracks(ctx, new_query) else: - result, called_api = await self.music_cache.lavalink_query( - ctx, player, audio_dataclasses.Query.process_input(res[0]) - ) + query = audio_dataclasses.Query.process_input(res[0]) + try: + result, called_api = await self.music_cache.lavalink_query( + ctx, player, query + ) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a few minutes." + ), + ) tracks = result.tracks if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) + embed = discord.Embed(title=_("Nothing found.")) + if ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + embed = discord.Embed(title=_("Track is not playable.")) + embed.description = _( + "**{suffix}** is not a fully supported format and some " + "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) single_track = tracks[0] single_track.start_timestamp = query.start_time * 1000 single_track = [single_track] @@ -2842,7 +3396,8 @@ class Audio(commands.Cog): self._play_lock(ctx, False) return await self._embed_msg( ctx, - _( + title=_("Invalid Environment"), + description=_( "The Spotify API key or client secret has not been set properly. " "\nUse `{prefix}audioset spotifyapi` for instructions." ).format(prefix=ctx.prefix), @@ -2856,17 +3411,24 @@ class Audio(commands.Cog): return track_list else: return await self._embed_msg( - ctx, _("This doesn't seem to be a supported Spotify URL or code.") + ctx, + title=_("Unable To Find Tracks"), + description=_("This doesn't seem to be a supported Spotify URL or code."), ) async def _enqueue_tracks( - self, ctx: commands.Context, query: Union[audio_dataclasses.Query, list] + self, + ctx: commands.Context, + query: Union[audio_dataclasses.Query, list], + enqueue: bool = True, ): player = lavalink.get_player(ctx.guild.id) try: if self.play_lock[ctx.message.guild.id]: return await self._embed_msg( - ctx, _("Wait until the playlist has finished loading.") + ctx, + title=_("Unable To Get Tracks"), + description=_("Wait until the playlist has finished loading."), ) except KeyError: self._play_lock(ctx, True) @@ -2874,22 +3436,39 @@ class Audio(commands.Cog): first_track_only = False index = None playlist_data = None + playlist_url = None seek = 0 if type(query) is not list: - + if not await is_allowed(ctx.guild, f"{query}", query_obj=query): + raise QueryUnauthorized( + _("{query} is not an allowed query.").format(query=query.to_string_user()) + ) if query.single_track: first_track_only = True index = query.track_index if query.start_time: seek = query.start_time - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + try: + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a few minutes." + ), + ) tracks = result.tracks playlist_data = result.playlist_info + if not enqueue: + return tracks if not tracks: self._play_lock(ctx, False) - embed = discord.Embed(title=_("Nothing found."), colour=await ctx.embed_colour()) + title = _("Nothing found.") + embed = discord.Embed(title=title) if result.exception_message: - embed.set_footer(text=result.exception_message) + embed.set_footer(text=result.exception_message[:2000].replace("\n", "")) if await self.config.use_external_lavalink() and query.is_local: embed.description = _( "Local tracks will not work " @@ -2897,7 +3476,17 @@ class Audio(commands.Cog): "This may be due to permissions or because Lavalink.jar is being run " "in a different machine than the local tracks." ) - return await ctx.send(embed=embed) + elif ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + title = _("Track is not playable.") + embed = discord.Embed(title=title) + embed.description = _( + "**{suffix}** is not a fully supported format and some " + "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) else: tracks = query queue_dur = await queue_duration(ctx) @@ -2906,7 +3495,7 @@ class Audio(commands.Cog): if not first_track_only and len(tracks) > 1: # a list of Tracks where all should be enqueued - # this is a Spotify playlist aleady made into a list of Tracks or a + # this is a Spotify playlist already made into a list of Tracks or a # url where Lavalink handles providing all Track objects to use, like a # YouTube or Soundcloud playlist track_len = 0 @@ -2943,11 +3532,11 @@ class Audio(commands.Cog): ) else: maxlength_msg = "" + playlist_name = escape(playlist_data.name if playlist_data else _("No Title")) embed = discord.Embed( - colour=await ctx.embed_colour(), - description="{name}".format( - name=playlist_data.name if playlist_data else _("No Title") - ), + description=bold(f"[{playlist_name}]({playlist_url})") + if playlist_url + else playlist_name, title=_("Playlist Enqueued"), ) embed.set_footer( @@ -2963,13 +3552,22 @@ class Audio(commands.Cog): ) if not player.current: await player.play() + self._play_lock(ctx, False) + message = await self._embed_msg(ctx, embed=embed) + return tracks or message else: + single_track = None # a ytsearch: prefixed item where we only need the first Track returned # this is in the case of [p]play , a single Spotify url/code # or this is a localtrack item try: - - single_track = tracks[index] if index else tracks[0] + single_track = ( + tracks + if isinstance(tracks, lavalink.rest_api.Track) + else tracks[index] + if index + else tracks[0] + ) if seek and seek > 0: single_track.start_timestamp = seek * 1000 if not await is_allowed( @@ -2982,7 +3580,7 @@ class Audio(commands.Cog): log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") self._play_lock(ctx, False) return await self._embed_msg( - ctx, _("This track is not allowed in this server.") + ctx, title=_("This track is not allowed in this server.") ) elif guild_data["maxlength"] > 0: if track_limit(single_track, guild_data["maxlength"]): @@ -2996,7 +3594,7 @@ class Audio(commands.Cog): ) else: self._play_lock(ctx, False) - return await self._embed_msg(ctx, _("Track exceeds maximum length.")) + return await self._embed_msg(ctx, title=_("Track exceeds maximum length.")) else: player.add(ctx.author, single_track) @@ -3006,26 +3604,13 @@ class Audio(commands.Cog): ) except IndexError: self._play_lock(ctx, False) - return await self._embed_msg( - ctx, _("Nothing found. Check your Lavalink logs for details.") - ) - query = audio_dataclasses.Query.process_input(single_track.uri) - if query.is_local: - if single_track.title != "Unknown title": - description = "**{} - {}**\n{}".format( - single_track.author, - single_track.title, - audio_dataclasses.LocalPath(single_track.uri).to_string_hidden(), - ) - else: - description = "{}".format( - audio_dataclasses.LocalPath(single_track.uri).to_string_hidden() - ) - else: - description = "**[{}]({})**".format(single_track.title, single_track.uri) - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Track Enqueued"), description=description - ) + title = _("Nothing found") + desc = EmptyEmbed + if await ctx.bot.is_owner(ctx.author): + desc = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=title, description=desc) + description = get_track_description(single_track) + embed = discord.Embed(title=_("Track Enqueued"), description=description) if not guild_data["shuffle"] and queue_dur > 0: embed.set_footer( text=_("{time} until track playback: #{position} in queue").format( @@ -3033,11 +3618,11 @@ class Audio(commands.Cog): ) ) - await ctx.send(embed=embed) if not player.current: await player.play() - self._play_lock(ctx, False) + message = await self._embed_msg(ctx, embed=embed) + return single_track or message async def _spotify_playlist( self, @@ -3049,10 +3634,8 @@ class Audio(commands.Cog): player = lavalink.get_player(ctx.guild.id) try: - embed1 = discord.Embed( - colour=await ctx.embed_colour(), title=_("Please wait, finding tracks...") - ) - playlist_msg = await ctx.send(embed=embed1) + embed1 = discord.Embed(title=_("Please wait, finding tracks...")) + playlist_msg = await self._embed_msg(ctx, embed=embed1) notifier = Notifier( ctx, playlist_msg, @@ -3074,14 +3657,17 @@ class Audio(commands.Cog): ) except SpotifyFetchError as error: self._play_lock(ctx, False) - return await self._embed_msg(ctx, _(error.message).format(prefix=ctx.prefix)) + return await self._embed_msg( + ctx, + title=_("Invalid Environment"), + description=_(error.message).format(prefix=ctx.prefix), + ) except (RuntimeError, aiohttp.ServerDisconnectedError): self._play_lock(ctx, False) error_embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("The connection was reset while loading the playlist."), + title=_("The connection was reset while loading the playlist.") ) - await ctx.send(embed=error_embed) + await self._embed_msg(ctx, embed=error_embed) return None except Exception as e: self._play_lock(ctx, False) @@ -3114,7 +3700,9 @@ class Audio(commands.Cog): has_perms = True elif playlist.scope == PlaylistScope.GUILD.value: if not is_different_guild: - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if guild.owner_id == ctx.author.id: has_perms = True elif dj_enabled and await self._has_dj_role(ctx, ctx.author): @@ -3127,7 +3715,7 @@ class Audio(commands.Cog): if has_perms is False: if hasattr(playlist, "name"): msg = _( - "You do not have the permissions to manage {name} " "(`{id}`) [**{scope}**]." + "You do not have the permissions to manage {name} (`{id}`) [**{scope}**]." ).format( user=playlist_author, name=playlist.name, @@ -3160,14 +3748,14 @@ class Audio(commands.Cog): "playlists in {scope} scope.".format(scope=humanize_scope(scope, the=True)) ) - await self._embed_msg(ctx, msg) + await self._embed_msg(ctx, title=_("No access to playlist."), description=msg) return False return True async def _get_correct_playlist_id( self, context: commands.Context, - matches: dict, + matches: MutableMapping, scope: str, author: discord.User, guild: discord.Guild, @@ -3199,79 +3787,92 @@ class Audio(commands.Cog): When multiple matches are found but none is selected. """ + correct_scope_matches: List[Playlist] original_input = matches.get("arg") - correct_scope_matches = matches.get(scope) + correct_scope_matches_temp: MutableMapping = matches.get(scope) guild_to_query = guild.id user_to_query = author.id - if not correct_scope_matches: + if not correct_scope_matches_temp: return None, original_input if scope == PlaylistScope.USER.value: correct_scope_matches = [ - (i[2]["id"], i[2]["name"], len(i[2]["tracks"]), i[2]["author"]) - for i in correct_scope_matches - if str(user_to_query) == i[0] + p for p in correct_scope_matches_temp if user_to_query == p.scope_id ] elif scope == PlaylistScope.GUILD.value: if specified_user: correct_scope_matches = [ - (i[2]["id"], i[2]["name"], len(i[2]["tracks"]), i[2]["author"]) - for i in correct_scope_matches - if str(guild_to_query) == i[0] and i[2]["author"] == user_to_query + p + for p in correct_scope_matches_temp + if guild_to_query == p.scope_id and p.author == user_to_query ] else: correct_scope_matches = [ - (i[2]["id"], i[2]["name"], len(i[2]["tracks"]), i[2]["author"]) - for i in correct_scope_matches - if str(guild_to_query) == i[0] + p for p in correct_scope_matches_temp if guild_to_query == p.scope_id ] else: if specified_user: correct_scope_matches = [ - (i[2]["id"], i[2]["name"], len(i[2]["tracks"]), i[2]["author"]) - for i in correct_scope_matches - if i[2]["author"] == user_to_query + p for p in correct_scope_matches_temp if p.author == user_to_query ] else: - correct_scope_matches = [ - (i[2]["id"], i[2]["name"], len(i[2]["tracks"]), i[2]["author"]) - for i in correct_scope_matches - ] + correct_scope_matches = [p for p in correct_scope_matches_temp] + + match_count = len(correct_scope_matches) + if match_count > 1: + correct_scope_matches2 = [ + p for p in correct_scope_matches if p.name == str(original_input).strip() + ] + if correct_scope_matches2: + correct_scope_matches = correct_scope_matches2 + elif original_input.isnumeric(): + arg = int(original_input) + correct_scope_matches3 = [p for p in correct_scope_matches if p.id == arg] + if correct_scope_matches3: + correct_scope_matches = correct_scope_matches3 match_count = len(correct_scope_matches) # We done all the trimming we can with the info available time to ask the user if match_count > 10: if original_input.isnumeric(): arg = int(original_input) - correct_scope_matches = [ - (i, n, t, a) for i, n, t, a in correct_scope_matches if i == arg - ] + correct_scope_matches = [p for p in correct_scope_matches if p.id == arg] if match_count > 10: raise TooManyMatches( - f"{match_count} playlists match {original_input}: " - f"Please try to be more specific, or use the playlist ID." + _( + "{match_count} playlists match {original_input}: " + "Please try to be more specific, or use the playlist ID." + ).format(match_count=match_count, original_input=original_input) ) elif match_count == 1: - return correct_scope_matches[0][0], original_input + return correct_scope_matches[0].id, original_input elif match_count == 0: return None, original_input # TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged pos_len = 3 playlists = f"{'#':{pos_len}}\n" - - for number, (pid, pname, ptracks, pauthor) in enumerate(correct_scope_matches, 1): - author = self.bot.get_user(pauthor) or "Unknown" - line = ( - f"{number}." - f" <{pname}>\n" - f" - Scope: < {humanize_scope(scope)} >\n" - f" - ID: < {pid} >\n" - f" - Tracks: < {ptracks} >\n" - f" - Author: < {author} >\n\n" + number = 0 + for number, playlist in enumerate(correct_scope_matches, 1): + author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown") + line = _( + "{number}." + " <{playlist.name}>\n" + " - Scope: < {scope} >\n" + " - ID: < {playlist.id} >\n" + " - Tracks: < {tracks} >\n" + " - Author: < {author} >\n\n" + ).format( + number=number, + playlist=playlist, + scope=humanize_scope(scope), + tracks=len(playlist.tracks), + author=author, ) playlists += line embed = discord.Embed( - title="Playlists found, which one would you like?", + title=_("{playlists} playlists found, which one would you like?").format( + playlists=number + ), description=box(playlists, lang="md"), colour=await context.embed_colour(), ) @@ -3279,7 +3880,7 @@ class Audio(commands.Cog): avaliable_emojis = ReactionPredicate.NUMBER_EMOJIS[1:] avaliable_emojis.append("🔟") emojis = avaliable_emojis[: len(correct_scope_matches)] - emojis.append("❌") + emojis.append("\N{CROSS MARK}") start_adding_reactions(msg, emojis) pred = ReactionPredicate.with_emojis(emojis, msg, user=context.author) try: @@ -3288,17 +3889,17 @@ class Audio(commands.Cog): with contextlib.suppress(discord.HTTPException): await msg.delete() raise TooManyMatches( - "Too many matches found and you did not select which one you wanted." + _("Too many matches found and you did not select which one you wanted.") ) - if emojis[pred.result] == "❌": + if emojis[pred.result] == "\N{CROSS MARK}": with contextlib.suppress(discord.HTTPException): await msg.delete() raise TooManyMatches( - "Too many matches found and you did not select which one you wanted." + _("Too many matches found and you did not select which one you wanted.") ) with contextlib.suppress(discord.HTTPException): await msg.delete() - return correct_scope_matches[pred.result][0], original_input + return correct_scope_matches[pred.result].id, original_input @commands.group() @commands.guild_only() @@ -3312,14 +3913,12 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ Only editable by bot owner. ​ ​ ​ ​ **Guild**: ​ ​ ​ ​ ​ ​ ​ ​ Visible to all users in this guild. - ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner, guild owner, guild admins, - ​ ​ ​ ​ ​ ​ ​ ​ guild mods, DJ role and playlist creator. + ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner, guild owner, guild admins, guild mods, DJ role and playlist creator. ​ ​ ​ ​ **User**: ​ ​ ​ ​ ​ ​ ​ ​ Visible to all bot users, if --author is passed. ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner and creator. """ - pass @playlist.command(name="append", usage=" [args]") async def _playlist_append( @@ -3343,17 +3942,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -3365,18 +3964,20 @@ class Audio(commands.Cog): """ if scope_data is None: scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] - scope, author, guild, specified_user = scope_data + (scope, author, guild, specified_user) = scope_data if not await self._playlist_check(ctx): return try: - playlist_id, playlist_arg = await self._get_correct_playlist_id( + (playlist_id, playlist_arg) = await self._get_correct_playlist_id( ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), ) try: @@ -3384,13 +3985,15 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): @@ -3399,8 +4002,14 @@ class Audio(commands.Cog): to_append = await self._playlist_tracks( ctx, player, audio_dataclasses.Query.process_input(query) ) + + if isinstance(to_append, discord.Message): + return None + if not to_append: - return await self._embed_msg(ctx, _("Could not find a track matching your query.")) + return await self._embed_msg( + ctx, title=_("Could not find a track matching your query.") + ) track_list = playlist.tracks tracks_obj_list = playlist.tracks_obj to_append_count = len(to_append) @@ -3414,7 +4023,10 @@ class Audio(commands.Cog): if to in tracks_obj_list: return await self._embed_msg( ctx, - _("{track} is already in {playlist} (`{id}`) [**{scope}**].").format( + title=_("Skipping track"), + description=_( + "{track} is already in {playlist} (`{id}`) [**{scope}**]." + ).format( track=to.title, playlist=playlist.name, id=playlist.id, scope=scope_name ), ) @@ -3437,7 +4049,8 @@ class Audio(commands.Cog): track_title = to_append[0]["info"]["title"] return await self._embed_msg( ctx, - _("{track} appended to {playlist} (`{id}`) [**{scope}**].").format( + title=_("Track added"), + description=_("{track} appended to {playlist} (`{id}`) [**{scope}**].").format( track=track_title, playlist=playlist.name, id=playlist.id, scope=scope_name ), ) @@ -3451,10 +4064,8 @@ class Audio(commands.Cog): existing=diff, plural=_("tracks are") if diff != 1 else _("track is") ) - embed = discord.Embed( - title=_("Playlist Modified"), colour=await ctx.embed_colour(), description=desc - ) - await ctx.send(embed=embed) + embed = discord.Embed(title=_("Playlist Modified"), description=desc) + await self._embed_msg(ctx, embed=embed) @playlist.command(name="copy", usage=" [args]") async def _playlist_copy( @@ -3480,17 +4091,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --to-author [user] ​ ​ ​ ​ ​ ​ ​ ​ --to-guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -3500,7 +4111,6 @@ class Audio(commands.Cog): --to-scope User ​ ​ ​ ​ [p]playlist copy MyPersonalPlaylist --from-scope user --to-author Draper#6666 --to-scope Guild --to-guild Red - Discord Bot - """ if scope_data is None: @@ -3530,11 +4140,13 @@ class Audio(commands.Cog): ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) temp_playlist = FakePlaylist(to_author.id, to_scope) @@ -3548,13 +4160,14 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(to_scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, title=_("You need to specify the Guild ID for the guild to lookup.") ) to_playlist = await create_playlist( @@ -3582,7 +4195,8 @@ class Audio(commands.Cog): return await self._embed_msg( ctx, - _( + title=_("Playlist Copied"), + description=_( "Playlist {name} (`{from_id}`) copied from {from_scope} to {to_scope} (`{to_id}`)." ).format( name=from_playlist.name, @@ -3608,17 +4222,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -3641,7 +4255,8 @@ class Audio(commands.Cog): if playlist_name.isnumeric(): return await self._embed_msg( ctx, - _( + title=_("Invalid Playlist Name"), + description=_( "Playlist names must be a single word (up to 32 " "characters) and not numbers only." ), @@ -3649,7 +4264,8 @@ class Audio(commands.Cog): playlist = await create_playlist(ctx, scope, playlist_name, None, None, author, guild) return await self._embed_msg( ctx, - _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format( + title=_("Playlist Created"), + description=_("Empty playlist {name} (`{id}`) [**{scope}**] created.").format( name=playlist.name, id=playlist.id, scope=scope_name ), ) @@ -3673,17 +4289,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -3701,10 +4317,12 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) try: @@ -3712,13 +4330,13 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, title=_("You need to specify the Guild ID for the guild to lookup.") ) if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): @@ -3730,7 +4348,8 @@ class Audio(commands.Cog): await self._embed_msg( ctx, - _("{name} (`{id}`) [**{scope}**] playlist deleted.").format( + title=_("Playlist Deleted"), + description=_("{name} (`{id}`) [**{scope}**] playlist deleted.").format( name=playlist.name, id=playlist.id, scope=scope_name ), ) @@ -3754,17 +4373,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -3786,10 +4405,14 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format( + arg=playlist_arg + ), ) try: @@ -3797,15 +4420,17 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) - if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): return @@ -3832,33 +4457,29 @@ class Audio(commands.Cog): track_obj[key] = value tracklist.append(track_obj) - final_count = len(tracklist) - if original_count - final_count != 0: - update = {"tracks": tracklist, "url": None} - await playlist.edit(update) - - if original_count - final_count != 0: - await self._embed_msg( - ctx, - _( - "Removed {track_diff} duplicated " - "tracks from {name} (`{id}`) [**{scope}**] playlist." - ).format( - name=playlist.name, - id=playlist.id, - track_diff=original_count - final_count, - scope=scope_name, - ), - ) - return - else: - await self._embed_msg( - ctx, - _("{name} (`{id}`) [**{scope}**] playlist has no duplicate tracks.").format( - name=playlist.name, id=playlist.id, scope=scope_name - ), - ) - return + final_count = len(tracklist) + if original_count - final_count != 0: + await self._embed_msg( + ctx, + title=_("Playlist Modified"), + description=_( + "Removed {track_diff} duplicated " + "tracks from {name} (`{id}`) [**{scope}**] playlist." + ).format( + name=playlist.name, + id=playlist.id, + track_diff=original_count - final_count, + scope=scope_name, + ), + ) + else: + await self._embed_msg( + ctx, + title=_("Playlist Has Not Been Modified"), + description=_( + "{name} (`{id}`) [**{scope}**] playlist has no duplicate tracks." + ).format(name=playlist.name, id=playlist.id, scope=scope_name), + ) @checks.is_owner() @playlist.command(name="download", usage=" [v2=False] [args]") @@ -3886,17 +4507,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -3914,10 +4535,12 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) try: @@ -3925,20 +4548,23 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) schema = 2 version = "v3" if v2 is False else "v2" if not playlist.tracks: - return await self._embed_msg(ctx, _("That playlist has no tracks.")) + return await self._embed_msg(ctx, title=_("That playlist has no tracks.")) if version == "v2": v2_valid_urls = ["https://www.youtube.com/watch?v=", "https://soundcloud.com/"] song_list = [] @@ -3953,24 +4579,17 @@ class Audio(commands.Cog): } file_name = playlist.name else: + # TODO: Keep new playlists backwards compatible, Remove me in a few releases playlist_data = playlist.to_json() playlist_songs_backwards_compatible = [ track["info"]["uri"] for track in playlist.tracks ] - playlist_data[ - "playlist" - ] = ( - playlist_songs_backwards_compatible - ) # TODO: Keep new playlists backwards compatible, Remove me in a few releases - playlist_data[ - "link" - ] = ( - playlist.url - ) # TODO: Keep new playlists backwards compatible, Remove me in a few releases + playlist_data["playlist"] = playlist_songs_backwards_compatible + playlist_data["link"] = playlist.url file_name = playlist.id playlist_data.update({"schema": schema, "version": version}) - playlist_data = json.dumps(playlist_data) - to_write = StringIO() + playlist_data = json.dumps(playlist_data).encode("utf-8") + to_write = BytesIO() to_write.write(playlist_data) to_write.seek(0) await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt")) @@ -3995,17 +4614,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4026,10 +4645,12 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) try: @@ -4037,13 +4658,16 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) track_len = len(playlist.tracks) @@ -4091,7 +4715,7 @@ class Audio(commands.Cog): embed = discord.Embed( colour=await ctx.embed_colour(), title=embed_title, description=page ) - author_obj = self.bot.get_user(playlist.author) + author_obj = self.bot.get_user(playlist.author) or playlist.author or _("Unknown") embed.set_footer( text=_("Page {page}/{pages} | Author: {author_name} | {num} track(s)").format( author_name=author_obj, num=track_len, pages=total_pages, page=numb @@ -4114,17 +4738,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4141,7 +4765,9 @@ class Audio(commands.Cog): playlists = await get_all_playlist(scope, self.bot, guild, author, specified_user) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) if scope == PlaylistScope.GUILD.value: @@ -4149,18 +4775,21 @@ class Audio(commands.Cog): elif scope == PlaylistScope.USER.value: name = f"{author}" else: - name = "the global scope" + name = "Global" if not playlists and specified_user: return await self._embed_msg( ctx, - _("No saved playlists for {scope} created by {author}.").format( + title=_("Playlist Not Found"), + description=_("No saved playlists for {scope} created by {author}.").format( scope=name, author=author ), ) elif not playlists: return await self._embed_msg( - ctx, _("No saved playlists for {scope}.").format(scope=name) + ctx, + title=_("Playlist Not Found"), + description=_("No saved playlists for {scope}.").format(scope=name), ) playlist_list = [] @@ -4172,7 +4801,11 @@ class Audio(commands.Cog): bold(playlist.name), _("ID: {id}").format(id=playlist.id), _("Tracks: {num}").format(num=len(playlist.tracks)), - _("Author: {name}\n").format(name=self.bot.get_user(playlist.author)), + _("Author: {name}\n").format( + name=self.bot.get_user(playlist.author) + or playlist.author + or _("Unknown") + ), ) ) ) @@ -4208,8 +4841,8 @@ class Audio(commands.Cog): ) return embed - @commands.cooldown(1, 15, commands.BucketType.guild) @playlist.command(name="queue", usage=" [args]") + @commands.cooldown(1, 15, commands.BucketType.guild) async def _playlist_queue( self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None ): @@ -4224,17 +4857,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4251,22 +4884,27 @@ class Audio(commands.Cog): ) temp_playlist = FakePlaylist(author.id, scope) if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + ctx.command.reset_cooldown(ctx) return playlist_name = playlist_name.split(" ")[0].strip('"')[:32] if playlist_name.isnumeric(): + ctx.command.reset_cooldown(ctx) return await self._embed_msg( ctx, - _( + title=_("Invalid Playlist Name"), + description=_( "Playlist names must be a single word " "(up to 32 characters) and not numbers only." ), ) if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) + ctx.command.reset_cooldown(ctx) + return await self._embed_msg(ctx, title=_("Nothing playing.")) player = lavalink.get_player(ctx.guild.id) if not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + ctx.command.reset_cooldown(ctx) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) tracklist = [] np_song = track_creator(player, "np") tracklist.append(np_song) @@ -4278,9 +4916,10 @@ class Audio(commands.Cog): playlist = await create_playlist(ctx, scope, playlist_name, None, tracklist, author, guild) await self._embed_msg( ctx, - _( - "Playlist {name} (`{id}`) [**{scope}**] saved " - "from current queue: {num} tracks added." + title=_("Playlist Created"), + description=_( + "Playlist {name} (`{id}`) [**{scope}**] " + "saved from current queue: {num} tracks added." ).format( name=playlist.name, num=len(playlist.tracks), id=playlist.id, scope=scope_name ), @@ -4306,17 +4945,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4339,24 +4978,28 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) - try: playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): @@ -4365,21 +5008,22 @@ class Audio(commands.Cog): track_list = playlist.tracks clean_list = [track for track in track_list if url != track["info"]["uri"]] if len(track_list) == len(clean_list): - return await self._embed_msg(ctx, _("URL not in playlist.")) + return await self._embed_msg(ctx, title=_("URL not in playlist.")) del_count = len(track_list) - len(clean_list) if not clean_list: await delete_playlist( scope=playlist.scope, playlist_id=playlist.id, guild=guild, author=playlist.author ) - return await self._embed_msg(ctx, _("No tracks left, removing playlist.")) + return await self._embed_msg(ctx, title=_("No tracks left, removing playlist.")) update = {"tracks": clean_list, "url": None} await playlist.edit(update) if del_count > 1: await self._embed_msg( ctx, - _( - "{num} entries have been removed from the" - " playlist {playlist_name} (`{id}`) [**{scope}**]." + title=_("Playlist Modified"), + description=_( + "{num} entries have been removed " + "from the playlist {playlist_name} (`{id}`) [**{scope}**]." ).format( num=del_count, playlist_name=playlist.name, id=playlist.id, scope=scope_name ), @@ -4387,13 +5031,15 @@ class Audio(commands.Cog): else: await self._embed_msg( ctx, - _( - "The track has been removed from the" - " playlist: {playlist_name} (`{id}`) [**{scope}**]." + title=_("Playlist Modified"), + description=_( + "The track has been removed from the playlist: " + "{playlist_name} (`{id}`) [**{scope}**]." ).format(playlist_name=playlist.name, id=playlist.id, scope=scope_name), ) @playlist.command(name="save", usage=" [args]") + @commands.cooldown(1, 15, commands.BucketType.guild) async def _playlist_save( self, ctx: commands.Context, @@ -4413,17 +5059,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4444,34 +5090,40 @@ class Audio(commands.Cog): temp_playlist = FakePlaylist(author.id, scope) if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): - return + return ctx.command.reset_cooldown(ctx) playlist_name = playlist_name.split(" ")[0].strip('"')[:32] if playlist_name.isnumeric(): + ctx.command.reset_cooldown(ctx) return await self._embed_msg( ctx, - _( + title=_("Invalid Playlist Name"), + description=_( "Playlist names must be a single word (up to 32 " "characters) and not numbers only." ), ) if not await self._playlist_check(ctx): + ctx.command.reset_cooldown(ctx) return player = lavalink.get_player(ctx.guild.id) tracklist = await self._playlist_tracks( ctx, player, audio_dataclasses.Query.process_input(playlist_url) ) + if isinstance(tracklist, discord.Message): + return None if tracklist is not None: playlist = await create_playlist( ctx, scope, playlist_name, playlist_url, tracklist, author, guild ) return await self._embed_msg( ctx, - _("Playlist {name} (`{id}`) [**{scope}**] saved: {num} tracks added.").format( - name=playlist.name, num=len(tracklist), id=playlist.id, scope=scope_name - ), + title=_("Playlist Created"), + description=_( + "Playlist {name} (`{id}`) [**{scope}**] saved: {num} tracks added." + ).format(name=playlist.name, num=len(tracklist), id=playlist.id, scope=scope_name), ) - @playlist.command(name="start", usage=" [args]") + @playlist.command(name="start", aliases=["play"], usage=" [args]") async def _playlist_start( self, ctx: commands.Context, @@ -4490,17 +5142,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4512,10 +5164,16 @@ class Audio(commands.Cog): if scope_data is None: scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] scope, author, guild, specified_user = scope_data - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): - await self._embed_msg(ctx, _("You need the DJ role to start playing playlists.")) + await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to start playing playlists."), + ) return False try: @@ -4523,10 +5181,12 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist").format(arg=playlist_arg), ) if not await self._playlist_check(ctx): @@ -4581,10 +5241,9 @@ class Audio(commands.Cog): elif scope == PlaylistScope.USER.value: scope_name = f"{author}" else: - scope_name = "the global scope" + scope_name = "Global" embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Playlist Enqueued"), description=_( "{name} - (`{id}`) [**{scope}**]\nAdded {num} " @@ -4597,20 +5256,23 @@ class Audio(commands.Cog): scope=scope_name, ), ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, embed=embed) if not player.current: await player.play() return except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) except TypeError: if playlist: @@ -4635,17 +5297,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4663,11 +5325,13 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) if not await self._playlist_check(ctx): @@ -4680,17 +5344,24 @@ class Audio(commands.Cog): player = lavalink.get_player(ctx.guild.id) added, removed, playlist = await self._maybe_update_playlist(ctx, player, playlist) else: - return await self._embed_msg(ctx, _("Custom playlists cannot be updated.")) + return await self._embed_msg( + ctx, + title=_("Invalid Playlist"), + description=_("Custom playlists cannot be updated."), + ) except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( + title=_("Playlist Not Found"), + description=_("Playlist {id} does not exist in {scope} scope.").format( id=playlist_id, scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) else: scope_name = humanize_scope( @@ -4698,7 +5369,8 @@ class Audio(commands.Cog): ) if added or removed: _colour = await ctx.embed_colour() - embeds = [] + removed_embeds = [] + added_embeds = [] total_added = len(added) total_removed = len(removed) total_pages = math.ceil(total_removed / 10) + math.ceil(total_added / 10) @@ -4721,7 +5393,7 @@ class Audio(commands.Cog): page_num=page_count, total_pages=total_pages ) embed.set_footer(text=text) - embeds.append(embed) + removed_embeds.append(embed) removed_text = "" if added: added_text = "" @@ -4741,13 +5413,15 @@ class Audio(commands.Cog): page_num=page_count, total_pages=total_pages ) embed.set_footer(text=text) - embeds.append(embed) + added_embeds.append(embed) added_text = "" + embeds = removed_embeds + added_embeds await menu(ctx, embeds, DEFAULT_CONTROLS) else: return await self._embed_msg( ctx, - _("No changes for {name} (`{id}`) [**{scope}**].").format( + title=_("Playlist Has Not Been Modified"), + description=_("No changes for {name} (`{id}`) [**{scope}**].").format( id=playlist.id, name=playlist.name, scope=scope_name ), ) @@ -4769,17 +5443,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4801,7 +5475,9 @@ class Audio(commands.Cog): await self._embed_msg( ctx, - _("Please upload the playlist file. Any other message will cancel this operation."), + title=_( + "Please upload the playlist file. Any other message will cancel this operation." + ), ) try: @@ -4809,19 +5485,19 @@ class Audio(commands.Cog): "message", timeout=30.0, check=MessagePredicate.same_context(ctx) ) except asyncio.TimeoutError: - return await self._embed_msg(ctx, _("No file detected, try again later.")) + return await self._embed_msg(ctx, title=_("No file detected, try again later.")) try: file_url = file_message.attachments[0].url except IndexError: - return await self._embed_msg(ctx, _("Upload cancelled.")) + return await self._embed_msg(ctx, title=_("Upload cancelled.")) file_suffix = file_url.rsplit(".", 1)[1] if file_suffix != "txt": - return await self._embed_msg(ctx, _("Only playlist files can be uploaded.")) + return await self._embed_msg(ctx, title=_("Only Red playlist files can be uploaded.")) try: async with self.session.request("GET", file_url) as r: - uploaded_playlist = await r.json(content_type="text/plain") + uploaded_playlist = await r.json(content_type="text/plain", encoding="utf-8") except UnicodeDecodeError: - return await self._embed_msg(ctx, _("Not a valid playlist file.")) + return await self._embed_msg(ctx, title=_("Not a valid playlist file.")) new_schema = uploaded_playlist.get("schema", 1) >= 2 version = uploaded_playlist.get("version", "v2") @@ -4892,17 +5568,17 @@ class Audio(commands.Cog): ​ ​ ​ ​ ​ ​ ​ ​ --author [user] ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - Scope is one of the following: + **Scope** is one of the following: ​ ​ ​ ​ Global ​ ​ ​ ​ Guild ​ ​ ​ ​ User - Author can be one of the following: + **Author** can be one of the following: ​ ​ ​ ​ User ID ​ ​ ​ ​ User Mention ​ ​ ​ ​ User Name#123 - Guild can be one of the following: + **Guild** can be one of the following: ​ ​ ​ ​ Guild ID ​ ​ ​ ​ Exact guild name @@ -4919,7 +5595,8 @@ class Audio(commands.Cog): if new_name.isnumeric(): return await self._embed_msg( ctx, - _( + title=_("Invalid Playlist Name"), + description=_( "Playlist names must be a single word (up to 32 " "characters) and not numbers only." ), @@ -4930,10 +5607,12 @@ class Audio(commands.Cog): ctx, playlist_matches, scope, author, guild, specified_user ) except TooManyMatches as e: - return await self._embed_msg(ctx, str(e)) + return await self._embed_msg(ctx, title=str(e)) if playlist_id is None: return await self._embed_msg( - ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ctx, + title=_("Playlist Not Found"), + description=_("Could not match '{arg}' to a playlist.").format(arg=playlist_arg), ) try: @@ -4941,13 +5620,16 @@ class Audio(commands.Cog): except RuntimeError: return await self._embed_msg( ctx, - _("Playlist {id} does not exist in {scope} scope.").format( - id=playlist_id, scope=humanize_scope(scope, the=True) + title=_("Playlist Not Found"), + description=_("Playlist does not exist in {scope} scope.").format( + scope=humanize_scope(scope, the=True) ), ) except MissingGuild: return await self._embed_msg( - ctx, _("You need to specify the Guild ID for the guild to lookup.") + ctx, + title=_("Missing Arguments"), + description=_("You need to specify the Guild ID for the guild to lookup."), ) if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): @@ -4961,7 +5643,7 @@ class Audio(commands.Cog): msg = _("'{old}' playlist has been renamed to '{new}' (`{id}`) [**{scope}**]").format( old=bold(old_name), new=bold(playlist.name), id=playlist.id, scope=scope_name ) - await self._embed_msg(ctx, msg) + await self._embed_msg(ctx, title=_("Playlist Modified"), description=msg) async def _load_v3_playlist( self, @@ -4973,10 +5655,8 @@ class Audio(commands.Cog): author: Union[discord.User, discord.Member], guild: Union[discord.Guild], ): - embed1 = discord.Embed( - colour=await ctx.embed_colour(), title=_("Please wait, adding tracks...") - ) - playlist_msg = await ctx.send(embed=embed1) + embed1 = discord.Embed(title=_("Please wait, adding tracks...")) + playlist_msg = await self._embed_msg(ctx, embed=embed1) track_count = len(track_list) uploaded_track_count = len(track_list) await asyncio.sleep(1) @@ -5012,11 +5692,11 @@ class Audio(commands.Cog): ) await playlist_msg.edit(embed=embed3) database_entries = [] - time_now = str(datetime.datetime.now(datetime.timezone.utc)) + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) for t in track_list: uri = t.get("info", {}).get("uri") if uri: - t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri} + t = {"loadType": "V2_COMPACT", "tracks": [t], "query": uri} database_entries.append( { "query": uri, @@ -5025,8 +5705,8 @@ class Audio(commands.Cog): "last_fetched": time_now, } ) - if database_entries and HAS_SQL: - await self.music_cache.insert("lavalink", database_entries) + if database_entries: + await self.music_cache.database.insert("lavalink", database_entries) async def _load_v2_playlist( self, @@ -5041,27 +5721,37 @@ class Audio(commands.Cog): ): track_list = [] track_count = 0 - successfull_count = 0 + successful_count = 0 uploaded_track_count = len(uploaded_track_list) - embed1 = discord.Embed( - colour=await ctx.embed_colour(), title=_("Please wait, adding tracks...") - ) - playlist_msg = await ctx.send(embed=embed1) + embed1 = discord.Embed(title=_("Please wait, adding tracks...")) + playlist_msg = await self._embed_msg(ctx, embed=embed1) notifier = Notifier(ctx, playlist_msg, {"playlist": _("Loading track {num}/{total}...")}) for song_url in uploaded_track_list: track_count += 1 try: - result, called_api = await self.music_cache.lavalink_query( - ctx, player, audio_dataclasses.Query.process_input(song_url) - ) + try: + result, called_api = await self.music_cache.lavalink_query( + ctx, player, audio_dataclasses.Query.process_input(song_url) + ) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a few " + "minutes." + ), + ) + track = result.tracks except Exception: continue try: track_obj = track_creator(player, other_track=track[0]) track_list.append(track_obj) - successfull_count += 1 + successful_count += 1 except Exception: continue if (track_count % 2 == 0) or (track_count == len(uploaded_track_list)): @@ -5075,19 +5765,19 @@ class Audio(commands.Cog): scope_name = humanize_scope( scope, ctx=guild if scope == PlaylistScope.GUILD.value else author ) - if not successfull_count: + if not successful_count: msg = _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format( name=playlist.name, id=playlist.id, scope=scope_name ) - elif uploaded_track_count != successfull_count: - bad_tracks = uploaded_track_count - successfull_count + elif uploaded_track_count != successful_count: + bad_tracks = uploaded_track_count - successful_count msg = _( "Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) " "could not be loaded." - ).format(num=successfull_count, playlist_name=playlist.name, num_bad=bad_tracks) + ).format(num=successful_count, playlist_name=playlist.name, num_bad=bad_tracks) else: msg = _("Added {num} tracks from the {playlist_name} playlist.").format( - num=successfull_count, playlist_name=playlist.name + num=successful_count, playlist_name=playlist.name ) embed3 = discord.Embed( colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg @@ -5103,6 +5793,8 @@ class Audio(commands.Cog): updated_tracks = await self._playlist_tracks( ctx, player, audio_dataclasses.Query.process_input(playlist.url) ) + if isinstance(updated_tracks, discord.Message): + return [], [], playlist if not updated_tracks: # No Tracks available on url Lets set it to none to avoid repeated calls here results["url"] = None @@ -5121,10 +5813,11 @@ class Audio(commands.Cog): async def _playlist_check(self, ctx: commands.Context): if not self._player_check(ctx): if self._connection_aborted: - msg = _("Connection to Lavalink has failed.") + msg = _("Connection to Lavalink has failed") + desc = EmptyEmbed if await ctx.bot.is_owner(ctx.author): - msg += " " + _("Please check your console or logs for details.") - await self._embed_msg(ctx, msg) + desc = _("Please check your console or logs for details.") + await self._embed_msg(ctx, title=msg, description=desc) return False try: if ( @@ -5133,7 +5826,9 @@ class Audio(commands.Cog): and userlimit(ctx.author.voice.channel) ): await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + ctx, + title=_("Unable To Get Playlists"), + description=_("I don't have permission to connect to your channel."), ) return False await lavalink.connect(ctx.author.voice.channel) @@ -5141,11 +5836,17 @@ class Audio(commands.Cog): player.store("connect", datetime.datetime.utcnow()) except IndexError: await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Get Playlists"), + description=_("Connection to Lavalink has not yet been established."), ) return False except AttributeError: - await self._embed_msg(ctx, _("Connect to a voice channel first.")) + await self._embed_msg( + ctx, + title=_("Unable To Get Playlists"), + description=_("Connect to a voice channel first."), + ) return False player = lavalink.get_player(ctx.guild.id) @@ -5155,7 +5856,9 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): await self._embed_msg( - ctx, _("You must be in the voice channel to use the playlist command.") + ctx, + title=_("Unable To Get Playlists"), + description=_("You must be in the voice channel to use the playlist command."), ) return False await self._eq_check(ctx, player) @@ -5175,24 +5878,74 @@ class Audio(commands.Cog): try: if self.play_lock[ctx.message.guild.id]: return await self._embed_msg( - ctx, _("Wait until the playlist has finished loading.") + ctx, + title=_("Unable To Get Tracks"), + description=_("Wait until the playlist has finished loading."), ) except KeyError: pass tracks = await self._get_spotify_tracks(ctx, query) + + if isinstance(tracks, discord.Message): + return None + if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) + embed = discord.Embed(title=_("Nothing found.")) + if ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + embed = discord.Embed(title=_("Track is not playable.")) + embed.description = _( + "**{suffix}** is not a fully supported format and some " + "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) for track in tracks: track_obj = track_creator(player, other_track=track) tracklist.append(track_obj) self._play_lock(ctx, False) elif query.is_search: - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + try: + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a few " + "minutes." + ), + ) + tracks = result.tracks if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) + embed = discord.Embed(title=_("Nothing found.")) + if ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + embed = discord.Embed(title=_("Track is not playable.")) + embed.description = _( + "**{suffix}** is not a fully supported format and some " + "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) else: - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + try: + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a few " + "minutes." + ), + ) + tracks = result.tracks if not search and len(tracklist) == 0: @@ -5210,20 +5963,43 @@ class Audio(commands.Cog): async def prev(self, ctx: commands.Context): """Skip to the start of the previously played track.""" if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._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_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 dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg(ctx, _("You need the DJ role to skip tracks.")) - if ( - not ctx.author.voice or ctx.author.voice.channel != player.channel - ) and not 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._embed_msg( - ctx, _("You must be in the voice channel to skip the music.") + 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: + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): + return await self._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: + if not (can_skip or is_requester) and not is_alone: + return await self._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._embed_msg(ctx, _("No previous track.")) + return await self._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) @@ -5233,22 +6009,9 @@ class Audio(commands.Cog): player.queue.insert(0, bump_song) player.queue.pop(queue_len) await player.skip() - query = audio_dataclasses.Query.process_input(player.current.uri) - if query.is_local: - - if player.current.title == "Unknown title": - description = "{}".format(query.track.to_string_hidden()) - else: - song = bold("{} - {}").format(player.current.author, player.current.title) - description = "{}\n{}".format(song, query.track.to_string_hidden()) - else: - description = f"**[{player.current.title}]({player.current.uri})**" - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Replaying Track"), - description=description, - ) - await ctx.send(embed=embed) + description = get_track_description(player.current) + embed = discord.Embed(title=_("Replaying Track"), description=description) + await self._embed_msg(ctx, embed=embed) @commands.group(invoke_without_command=True) @commands.guild_only() @@ -5259,7 +6022,7 @@ class Audio(commands.Cog): async def _queue_menu( ctx: commands.Context, pages: list, - controls: dict, + controls: MutableMapping, message: discord.Message, page: int, timeout: float, @@ -5271,10 +6034,15 @@ class Audio(commands.Cog): await message.delete() return None - queue_controls = {"⬅": prev_page, "❌": close_menu, "➡": next_page, "ℹ": _queue_menu} + queue_controls = { + "\N{LEFTWARDS BLACK ARROW}": prev_page, + "\N{CROSS MARK}": close_menu, + "\N{BLACK RIGHTWARDS ARROW}": next_page, + "\N{INFORMATION SOURCE}": _queue_menu, + } if not self._player_check(ctx): - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) player = lavalink.get_player(ctx.guild.id) if not player.queue: if player.current: @@ -5284,38 +6052,18 @@ class Audio(commands.Cog): dur = "LIVE" else: dur = lavalink.utils.format_time(player.current.length) - - query = audio_dataclasses.Query.process_input(player.current) - - if query.is_local: - if player.current.title != "Unknown title": - song = "**{track.author} - {track.title}**\n{uri}\n" - else: - song = "{uri}\n" - else: - song = "**[{track.title}]({track.uri})**\n" - song += _("Requested by: **{track.requester}**") + song = get_track_description(player.current) + song += _("\n Requested by: **{track.requester}**") song += "\n\n{arrow}`{pos}`/`{dur}`" - song = song.format( - track=player.current, - uri=audio_dataclasses.LocalPath(player.current.uri).to_string_hidden() - if audio_dataclasses.Query.process_input(player.current.uri).is_local - else player.current.uri, - arrow=arrow, - pos=pos, - dur=dur, - ) - - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Now Playing"), description=song - ) + song = song.format(track=player.current, arrow=arrow, pos=pos, dur=dur) + embed = discord.Embed(title=_("Now Playing"), description=song) if await self.config.guild(ctx.guild).thumbnail() and player.current: if player.current.thumbnail: embed.set_thumbnail(url=player.current.thumbnail) shuffle = await self.config.guild(ctx.guild).shuffle() repeat = await self.config.guild(ctx.guild).repeat() - autoplay = await self.config.guild(ctx.guild).auto_play() or self.owns_autoplay + autoplay = await self.config.guild(ctx.guild).auto_play() text = "" text += ( _("Auto-Play") @@ -5335,9 +6083,45 @@ class Audio(commands.Cog): + ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}") ) embed.set_footer(text=text) + message = await self._embed_msg(ctx, embed=embed) + 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: + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( + ctx + ): + return - return await ctx.send(embed=embed) - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + expected = ("⏹", "⏯") + emoji = {"stop": "⏹", "pause": "⏯"} + if player.current: + task = start_adding_reactions(message, expected[:4], ctx.bot.loop) + 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 == "stop": + await self._clear_react(message, emoji) + return await ctx.invoke(self.stop) + elif react == "pause": + await self._clear_react(message, emoji) + return await ctx.invoke(self.pause) + return + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) async with ctx.typing(): len_queue_pages = math.ceil(len(player.queue) / 10) @@ -5354,7 +6138,7 @@ class Audio(commands.Cog): ): shuffle = await self.config.guild(ctx.guild).shuffle() repeat = await self.config.guild(ctx.guild).repeat() - autoplay = await self.config.guild(ctx.guild).auto_play() or self.owns_autoplay + autoplay = await self.config.guild(ctx.guild).auto_play() queue_num_pages = math.ceil(len(player.queue) / 10) queue_idx_start = (page_num - 1) * 10 @@ -5363,7 +6147,7 @@ class Audio(commands.Cog): try: arrow = await draw_time(ctx) except AttributeError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) pos = lavalink.utils.format_time(player.position) if player.current.is_stream: @@ -5385,7 +6169,7 @@ class Audio(commands.Cog): ( _("Playing: ") + "**{current.author} - {current.title}**".format(current=player.current), - audio_dataclasses.LocalPath(player.current.uri).to_string_hidden(), + audio_dataclasses.LocalPath(player.current.uri).to_string_user(), _("Requested by: **{user}**\n").format(user=player.current.requester), f"{arrow}`{pos}`/`{dur}`\n\n", ) @@ -5394,7 +6178,7 @@ class Audio(commands.Cog): queue_list += "\n".join( ( _("Playing: ") - + audio_dataclasses.LocalPath(player.current.uri).to_string_hidden(), + + audio_dataclasses.LocalPath(player.current.uri).to_string_user(), _("Requested by: **{user}**\n").format(user=player.current.requester), f"{arrow}`{pos}`/`{dur}`\n\n", ) @@ -5421,7 +6205,7 @@ class Audio(commands.Cog): if track.title == "Unknown title": queue_list += f"`{track_idx}.` " + ", ".join( ( - bold(audio_dataclasses.LocalPath(track.uri).to_string_hidden()), + bold(audio_dataclasses.LocalPath(track.uri).to_string_user()), _("requested by **{user}**\n").format(user=req_user), ) ) @@ -5435,7 +6219,7 @@ class Audio(commands.Cog): embed = discord.Embed( colour=await ctx.embed_colour(), - title="Queue for " + ctx.guild.name, + title="Queue for __{guild.name}__".format(guild=ctx.guild), description=queue_list, ) if await self.config.guild(ctx.guild).thumbnail() and player.current.thumbnail: @@ -5443,8 +6227,7 @@ class Audio(commands.Cog): queue_dur = await queue_duration(ctx) queue_total_duration = lavalink.utils.format_time(queue_dur) text = _( - "Page {page_num}/{total_pages} | {num_tracks} " - "tracks, {num_remaining} remaining | \n\n" + "Page {page_num}/{total_pages} | {num_tracks} tracks, {num_remaining} remaining\n" ).format( page_num=page_num, total_pages=queue_num_pages, @@ -5480,7 +6263,7 @@ class Audio(commands.Cog): if not match_url(track.uri): query = audio_dataclasses.Query.process_input(track) if track.title == "Unknown title": - track_title = query.track.to_string_hidden() + track_title = query.track.to_string_user() else: track_title = "{} - {}".format(track.author, track.title) else: @@ -5507,7 +6290,7 @@ class Audio(commands.Cog): ): track_idx = i + 1 if type(track) is str: - track_location = audio_dataclasses.LocalPath(track).to_string_hidden() + track_location = audio_dataclasses.LocalPath(track).to_string_user() track_match += "`{}.` **{}**\n".format(track_idx, track_location) else: track_match += "`{}.` **{}**\n".format(track[0], track[1]) @@ -5528,16 +6311,23 @@ class Audio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except KeyError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if not self._player_check(ctx) or not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) - + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg(ctx, _("You need the DJ role to clear the queue.")) + return await self._embed_msg( + ctx, + title=_("Unable To Clear Queue"), + description=_("You need the DJ role to clear the queue."), + ) player.queue.clear() - await self._embed_msg(ctx, _("The queue has been cleared.")) + await self._embed_msg( + ctx, title=_("Queue Modified"), description=_("The queue has been cleared.") + ) @queue.command(name="clean") @commands.guild_only() @@ -5546,13 +6336,19 @@ class Audio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except KeyError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if not self._player_check(ctx) or not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg(ctx, _("You need the DJ role to clean the queue.")) + return await self._embed_msg( + ctx, + title=_("Unable To Clean Queue"), + description=_("You need the DJ role to clean the queue."), + ) clean_tracks = [] removed_tracks = 0 listeners = player.channel.members @@ -5563,13 +6359,14 @@ class Audio(commands.Cog): removed_tracks += 1 player.queue = clean_tracks if removed_tracks == 0: - await self._embed_msg(ctx, _("Removed 0 tracks.")) + await self._embed_msg(ctx, title=_("Removed 0 tracks.")) else: await self._embed_msg( ctx, - _( - "Removed {removed_tracks} tracks queued by members o" - "utside of the voice channel." + title=_("Removed racks from the queue"), + description=_( + "Removed {removed_tracks} tracks queued by members " + "outside of the voice channel." ).format(removed_tracks=removed_tracks), ) @@ -5581,9 +6378,9 @@ class Audio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except KeyError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) if not self._player_check(ctx) or not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) clean_tracks = [] removed_tracks = 0 @@ -5594,13 +6391,14 @@ class Audio(commands.Cog): removed_tracks += 1 player.queue = clean_tracks if removed_tracks == 0: - await self._embed_msg(ctx, _("Removed 0 tracks.")) + await self._embed_msg(ctx, title=_("Removed 0 tracks.")) else: await self._embed_msg( ctx, - _("Removed {removed_tracks} tracks queued by {member.display_name}.").format( - removed_tracks=removed_tracks, member=ctx.author - ), + title=_("Removed tracks from the queue"), + description=_( + "Removed {removed_tracks} tracks queued by {member.display_name}." + ).format(removed_tracks=removed_tracks, member=ctx.author), ) @queue.command(name="search") @@ -5610,13 +6408,13 @@ class Audio(commands.Cog): try: player = lavalink.get_player(ctx.guild.id) except KeyError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) if not self._player_check(ctx) or not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) search_list = await self._build_queue_search_list(player.queue, search_words) if not search_list: - return await self._embed_msg(ctx, _("No matches.")) + return await self._embed_msg(ctx, title=_("No matches.")) len_search_pages = math.ceil(len(search_list) / 10) search_page_list = [] @@ -5627,52 +6425,92 @@ class Audio(commands.Cog): @queue.command(name="shuffle") @commands.guild_only() + @commands.cooldown(1, 30, commands.BucketType.guild) async def _queue_shuffle(self, ctx: commands.Context): """Shuffles the queue.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): - return await self._embed_msg(ctx, _("You need the DJ role to shuffle the queue.")) + ctx.command.reset_cooldown(ctx) + return await self._embed_msg( + ctx, + title=_("Unable To Shuffle Queue"), + description=_("You need the DJ role to shuffle the queue."), + ) if not self._player_check(ctx): - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + ctx.command.reset_cooldown(ctx) + return await self._embed_msg( + ctx, + title=_("Unable To Shuffle Queue"), + description=_("There's nothing in the queue."), + ) try: if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect or not ctx.author.voice.channel.permissions_for(ctx.me).move_members and userlimit(ctx.author.voice.channel) ): + ctx.command.reset_cooldown(ctx) return await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + ctx, + title=_("Unable To Shuffle Queue"), + description=_("I don't have permission to connect to your channel."), ) await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) except AttributeError: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) - except IndexError: + ctx.command.reset_cooldown(ctx) return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Shuffle Queue"), + description=_("Connect to a voice channel first."), + ) + except IndexError: + ctx.command.reset_cooldown(ctx) + return await self._embed_msg( + ctx, + title=_("Unable To Shuffle Queue"), + description=_("Connection to Lavalink has not yet been established."), ) except KeyError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + ctx.command.reset_cooldown(ctx) + return await self._embed_msg( + ctx, + title=_("Unable To Shuffle Queue"), + description=_("There's nothing in the queue."), + ) if not self._player_check(ctx) or not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + ctx.command.reset_cooldown(ctx) + return await self._embed_msg( + ctx, + title=_("Unable To Shuffle Queue"), + description=_("There's nothing in the queue."), + ) player.force_shuffle(0) - return await self._embed_msg(ctx, _("Queue has been shuffled.")) + return await self._embed_msg(ctx, title=_("Queue has been shuffled.")) @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) async def repeat(self, ctx: commands.Context): """Toggle repeat.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._has_dj_role( ctx, ctx.author ): - return await self._embed_msg(ctx, _("You need the DJ role to toggle repeat.")) + return await self._embed_msg( + ctx, + title=_("Unable To Toggle Repeat"), + description=_("You need the DJ role to toggle repeat."), + ) if self._player_check(ctx): await self._data_check(ctx) player = lavalink.get_player(ctx.guild.id) @@ -5680,7 +6518,9 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to toggle repeat.") + 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() @@ -5694,10 +6534,8 @@ class Audio(commands.Cog): msg += _("\nAuto-play has been disabled.") await self.config.guild(ctx.guild).auto_play.set(False) - embed = discord.Embed( - title=_("Repeat settings changed"), description=msg, colour=await ctx.embed_colour() - ) - await ctx.send(embed=embed) + embed = discord.Embed(title=_("Setting Changed"), description=msg) + await self._embed_msg(ctx, embed=embed) if self._player_check(ctx): await self._data_check(ctx) @@ -5706,38 +6544,42 @@ class Audio(commands.Cog): @commands.bot_has_permissions(embed_links=True) async def remove(self, ctx: commands.Context, index: int): """Remove a specific track number from the queue.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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._embed_msg(ctx, _("Nothing playing.")) + return await self._embed_msg(ctx, title=_("Nothing playing.")) player = lavalink.get_player(ctx.guild.id) if not player.queue: - return await self._embed_msg(ctx, _("Nothing queued.")) + return await self._embed_msg(ctx, title=_("Nothing queued.")) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to remove tracks.")) + return await self._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 await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to manage the queue.") + ctx, + title=_("Unable To Modify Queue"), + description=_("You must be in the voice channel to manage the queue."), ) if index > len(player.queue) or index < 1: return await self._embed_msg( - ctx, _("Song number must be greater than 1 and within the queue limit.") + ctx, + title=_("Unable To Modify Queue"), + description=_("Song number must be greater than 1 and within the queue limit."), ) index -= 1 removed = player.queue.pop(index) - query = audio_dataclasses.Query.process_input(removed.uri) - if query.is_local: - local_path = audio_dataclasses.LocalPath(removed.uri).to_string_hidden() - if removed.title == "Unknown title": - removed_title = local_path - else: - removed_title = "{} - {}\n{}".format(removed.author, removed.title, local_path) - else: - removed_title = removed.title + removed_title = get_track_description(removed) await self._embed_msg( - ctx, _("Removed {track} from the queue.").format(track=removed_title) + ctx, + title=_("Removed track from queue"), + description=_("Removed {track} from the queue.").format(track=removed_title), ) @commands.command() @@ -5746,15 +6588,14 @@ class Audio(commands.Cog): async def search(self, ctx: commands.Context, *, query: str): """Pick a track with a search. - Use `[p]search list ` to queue all tracks found - on YouTube. `[p]search sc ` will search SoundCloud - instead of YouTube. + Use `[p]search list ` to queue all tracks found on YouTube. `[p]search sc + ` will search SoundCloud instead of YouTube. """ async def _search_menu( ctx: commands.Context, pages: list, - controls: dict, + controls: MutableMapping, message: discord.Message, page: int, timeout: float, @@ -5767,22 +6608,23 @@ class Audio(commands.Cog): return None search_controls = { - "1⃣": _search_menu, - "2⃣": _search_menu, - "3⃣": _search_menu, - "4⃣": _search_menu, - "5⃣": _search_menu, - "⬅": prev_page, - "❌": close_menu, - "➡": next_page, + "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu, + "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": _search_menu, + "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu, + "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": _search_menu, + "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": _search_menu, + "\N{LEFTWARDS BLACK ARROW}": prev_page, + "\N{CROSS MARK}": close_menu, + "\N{BLACK RIGHTWARDS ARROW}": next_page, } if not self._player_check(ctx): if self._connection_aborted: - msg = _("Connection to Lavalink has failed.") + msg = _("Connection to Lavalink has failed") + desc = EmptyEmbed if await ctx.bot.is_owner(ctx.author): - msg += " " + _("Please check your console or logs for details.") - return await self._embed_msg(ctx, msg) + desc = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=msg, description=desc) try: if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect @@ -5790,16 +6632,24 @@ class Audio(commands.Cog): and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + ctx, + title=_("Unable To Search For Tracks"), + description=_("I don't have permission to connect to your channel."), ) await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) except AttributeError: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) + return await self._embed_msg( + ctx, + title=_("Unable To Search For Tracks"), + description=_("Connect to a voice channel first."), + ) except IndexError: return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Search For Tracks"), + description=_("Connection to Lavalink has not yet been established."), ) player = lavalink.get_player(ctx.guild.id) guild_data = await self.config.guild(ctx.guild).all() @@ -5809,23 +6659,67 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to enqueue tracks.") + ctx, + title=_("Unable To Search For Tracks"), + description=_("You must be in the voice channel to enqueue tracks."), ) await self._eq_check(ctx, player) await self._data_check(ctx) + before_queue_length = len(player.queue) + if not isinstance(query, list): query = audio_dataclasses.Query.process_input(query) + restrict = await self.config.restrict() + if restrict and match_url(query): + valid_url = url_check(query) + if not valid_url: + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("That URL is not allowed."), + ) + if not await is_allowed(ctx.guild, f"{query}", query_obj=query): + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("That track is not allowed."), + ) if query.invoked_from == "search list" or query.invoked_from == "local folder": - if query.invoked_from == "search list": - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + if query.invoked_from == "search list" and not query.is_local: + try: + result, called_api = await self.music_cache.lavalink_query( + ctx, player, query + ) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a " + "few " + "minutes." + ), + ) + tracks = result.tracks else: - tracks = await self._folder_tracks(ctx, player, query) + try: + tracks = await self._folder_tracks(ctx, player, query) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a " + "few " + "minutes." + ), + ) if not tracks: - embed = discord.Embed( - title=_("Nothing found."), colour=await ctx.embed_colour() - ) + embed = discord.Embed(title=_("Nothing found.")) if await self.config.use_external_lavalink() and query.is_local: embed.description = _( "Local tracks will not work " @@ -5833,10 +6727,25 @@ class Audio(commands.Cog): "This may be due to permissions or because Lavalink.jar is being run " "in a different machine than the local tracks." ) - return await ctx.send(embed=embed) + elif ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + embed = discord.Embed(title=_("Track is not playable.")) + embed.description = _( + "**{suffix}** is not a fully supported format and some " + "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) queue_dur = await queue_duration(ctx) queue_total_duration = lavalink.utils.format_time(queue_dur) - + if guild_data["dj_enabled"]: + if not await self._can_instaskip(ctx, ctx.author): + return await self._embed_msg( + ctx, + title=_("Unable To Play Tracks"), + description=_("You need the DJ role to queue tracks."), + ) track_len = 0 empty_queue = not player.queue for track in tracks: @@ -5872,18 +6781,17 @@ class Audio(commands.Cog): else: maxlength_msg = "" songembed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Queued {num} track(s).{maxlength_msg}").format( num=track_len, maxlength_msg=maxlength_msg - ), + ) ) if not guild_data["shuffle"] and queue_dur > 0: songembed.set_footer( text=_( "{time} until start of search playback: starts at #{position} in queue" - ).format(time=queue_total_duration, position=len(player.queue) + 1) + ).format(time=queue_total_duration, position=before_queue_length + 1) ) - return await ctx.send(embed=songembed) + return await self._embed_msg(ctx, embed=songembed) elif query.is_local and query.single_track: tracks = await self._folder_list(ctx, query) elif query.is_local and query.is_album: @@ -5892,10 +6800,20 @@ class Audio(commands.Cog): else: tracks = await self._folder_list(ctx, query) else: - result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + try: + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + except TrackEnqueueError: + self._play_lock(ctx, False) + return await self._embed_msg( + ctx, + title=_("Unable to Get Track"), + description=_( + "I'm unable get a track from Lavalink at the moment, try again in a few minutes." + ), + ) tracks = result.tracks if not tracks: - embed = discord.Embed(title=_("Nothing found."), colour=await ctx.embed_colour()) + embed = discord.Embed(title=_("Nothing found.")) if await self.config.use_external_lavalink() and query.is_local: embed.description = _( "Local tracks will not work " @@ -5903,20 +6821,31 @@ class Audio(commands.Cog): "This may be due to permissions or because Lavalink.jar is being run " "in a different machine than the local tracks." ) - return await ctx.send(embed=embed) + elif ( + query.is_local + and query.suffix in audio_dataclasses._PARTIALLY_SUPPORTED_MUSIC_EXT + ): + embed = discord.Embed(title=_("Track is not playable.")) + embed.description = _( + "**{suffix}** is not a fully supported format and some " + "tracks may not play." + ).format(suffix=query.suffix) + return await self._embed_msg(ctx, embed=embed) else: tracks = query + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) + len_search_pages = math.ceil(len(tracks) / 5) search_page_list = [] for page_num in range(1, len_search_pages + 1): embed = await self._build_search_page(ctx, tracks, page_num) search_page_list.append(embed) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await menu(ctx, search_page_list, DEFAULT_CONTROLS) + if dj_enabled and not await self._can_instaskip(ctx, ctx.author): + return await menu(ctx, search_page_list, DEFAULT_CONTROLS) await menu(ctx, search_page_list, search_controls) @@ -5924,54 +6853,42 @@ class Audio(commands.Cog): if not self._player_check(ctx): if self._connection_aborted: msg = _("Connection to Lavalink has failed.") + description = EmptyEmbed if await ctx.bot.is_owner(ctx.author): - msg += " " + _("Please check your console or logs for details.") - return await self._embed_msg(ctx, msg) + description = _("Please check your console or logs for details.") + return await self._embed_msg(ctx, title=msg, description=description) try: await lavalink.connect(ctx.author.voice.channel) player = lavalink.get_player(ctx.guild.id) player.store("connect", datetime.datetime.utcnow()) except AttributeError: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) + return await self._embed_msg(ctx, title=_("Connect to a voice channel first.")) except IndexError: return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, title=_("Connection to Lavalink has not yet been established.") ) player = lavalink.get_player(ctx.guild.id) guild_data = await self.config.guild(ctx.guild).all() if not await self._currency_check(ctx, guild_data["jukebox_price"]): return try: - if emoji == "1⃣": + if emoji == "\N{DIGIT ONE}\N{COMBINING ENCLOSING KEYCAP}": search_choice = tracks[0 + (page * 5)] - elif emoji == "2⃣": + elif emoji == "\N{DIGIT TWO}\N{COMBINING ENCLOSING KEYCAP}": search_choice = tracks[1 + (page * 5)] - elif emoji == "3⃣": + elif emoji == "\N{DIGIT THREE}\N{COMBINING ENCLOSING KEYCAP}": search_choice = tracks[2 + (page * 5)] - elif emoji == "4⃣": + elif emoji == "\N{DIGIT FOUR}\N{COMBINING ENCLOSING KEYCAP}": search_choice = tracks[3 + (page * 5)] - elif emoji == "5⃣": + elif emoji == "\N{DIGIT FIVE}\N{COMBINING ENCLOSING KEYCAP}": search_choice = tracks[4 + (page * 5)] else: search_choice = tracks[0 + (page * 5)] - # TODO: verify this does not break exit and arrows except IndexError: search_choice = tracks[-1] - try: - query = audio_dataclasses.Query.process_input(search_choice.uri) - if query.is_local: - - localtrack = audio_dataclasses.LocalPath(search_choice.uri) - if search_choice.title != "Unknown title": - description = "**{} - {}**\n{}".format( - search_choice.author, search_choice.title, localtrack.to_string_hidden() - ) - else: - description = localtrack.to_string_hidden() - else: - description = "**[{}]({})**".format(search_choice.title, search_choice.uri) - - except AttributeError: + if getattr(search_choice, "uri", None): + description = get_track_description(search_choice) + else: search_choice = audio_dataclasses.Query.process_input(search_choice) if search_choice.track.exists() and search_choice.track.is_dir(): return await ctx.invoke(self.search, query=search_choice) @@ -5979,11 +6896,11 @@ class Audio(commands.Cog): search_choice.invoked_from = "localtrack" return await ctx.invoke(self.play, query=search_choice) - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Track Enqueued"), description=description - ) + songembed = discord.Embed(title=_("Track Enqueued"), description=description) queue_dur = await queue_duration(ctx) queue_total_duration = lavalink.utils.format_time(queue_dur) + before_queue_length = len(player.queue) + if not await is_allowed( ctx.guild, ( @@ -5993,7 +6910,7 @@ class Audio(commands.Cog): ): log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") self._play_lock(ctx, False) - return await self._embed_msg(ctx, _("This track is not allowed in this server.")) + return await self._embed_msg(ctx, title=_("This track is not allowed in this server.")) elif guild_data["maxlength"] > 0: if track_limit(search_choice.length, guild_data["maxlength"]): @@ -6003,7 +6920,7 @@ class Audio(commands.Cog): "red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author ) else: - return await self._embed_msg(ctx, _("Track exceeds maximum length.")) + return await self._embed_msg(ctx, title=_("Track exceeds maximum length.")) else: player.add(ctx.author, search_choice) player.maybe_shuffle() @@ -6012,15 +6929,21 @@ class Audio(commands.Cog): ) if not guild_data["shuffle"] and queue_dur > 0: - embed.set_footer( + songembed.set_footer( text=_("{time} until track playback: #{position} in queue").format( - time=queue_total_duration, position=len(player.queue) + 1 + time=queue_total_duration, position=before_queue_length + 1 ) ) if not player.current: await player.play() - await ctx.send(embed=embed) + return await self._embed_msg(ctx, embed=songembed) + + @staticmethod + def _format_search_options(search_choice): + query = audio_dataclasses.Query.process_input(search_choice) + description = get_track_description(search_choice) + return description, query @staticmethod async def _build_search_page(ctx: commands.Context, tracks, page_num): @@ -6042,20 +6965,20 @@ class Audio(commands.Cog): search_list += "`{0}.` **{1}**\n[{2}]\n".format( search_track_num, track.title, - audio_dataclasses.LocalPath(track.uri).to_string_hidden(), + audio_dataclasses.LocalPath(track.uri).to_string_user(), ) else: search_list += "`{0}.` **[{1}]({2})**\n".format( search_track_num, track.title, track.uri ) except AttributeError: - # query = Query.process_input(track) track = audio_dataclasses.Query.process_input(track) if track.is_local and command != "search": search_list += "`{}.` **{}**\n".format( search_track_num, track.to_string_user() ) - folder = True + if track.is_album: + folder = True elif command == "search": search_list += "`{}.` **{}**\n".format( search_track_num, track.to_string_user() @@ -6064,7 +6987,7 @@ class Audio(commands.Cog): search_list += "`{}.` **{}**\n".format( search_track_num, track.to_string_user() ) - if hasattr(tracks[0], "uri"): + if hasattr(tracks[0], "uri") and hasattr(tracks[0], "track_identifier"): title = _("Tracks Found:") footer = _("search results") elif folder: @@ -6092,32 +7015,45 @@ class Audio(commands.Cog): async def 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 = await self.config.guild(ctx.guild).dj_enabled() + 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_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._embed_msg(ctx, _("Nothing playing.")) + return await self._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._embed_msg(ctx, _("You must be in the voice channel to use seek.")) + return await self._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._embed_msg( - ctx, _("There are other people listening - vote to skip instead.") + 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._embed_msg( - ctx, _("You need the DJ role or be the track requester to use seek.") + 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._embed_msg(ctx, _("Can't seek on a stream.")) + return await self._embed_msg( + ctx, title=_("Unable To Seek Tracks"), description=_("Can't seek on a stream.") + ) else: try: int(seconds) @@ -6126,18 +7062,25 @@ class Audio(commands.Cog): abs_position = True seconds = time_convert(seconds) if seconds == 0: - return await self._embed_msg(ctx, _("Invalid input for the time to seek.")) + return await self._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._embed_msg( - ctx, _("Moved {num_seconds}s to 00:00:00").format(num_seconds=seconds) + ctx, + title=_("Moved {num_seconds}s to 00:00:00").format( + num_seconds=seconds + ), ) else: await self._embed_msg( ctx, - _("Moved {num_seconds}s to {time}").format( + title=_("Moved {num_seconds}s to {time}").format( num_seconds=seconds, time=lavalink.utils.format_time(seek) ), ) @@ -6145,23 +7088,73 @@ class Audio(commands.Cog): else: await self._embed_msg( ctx, - _("Moved to {time}").format( + title=_("Moved to {time}").format( time=lavalink.utils.format_time(seconds * 1000) ), ) await player.seek(seconds * 1000) else: - await self._embed_msg(ctx, _("Nothing playing.")) + await self._embed_msg(ctx, title=_("Nothing playing.")) - @commands.command() + @commands.group(autohelp=False) @commands.guild_only() @commands.bot_has_permissions(embed_links=True) async def shuffle(self, ctx: commands.Context): """Toggle shuffle.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + if ctx.invoked_subcommand is None: + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) + if dj_enabled: + if not await self._can_instaskip(ctx, ctx.author): + return await self._embed_msg( + ctx, + title=_("Unable To Toggle Shuffle"), + description=_("You need the DJ role to toggle shuffle."), + ) + if self._player_check(ctx): + await self._data_check(ctx) + player = lavalink.get_player(ctx.guild.id) + if ( + not ctx.author.voice or ctx.author.voice.channel != player.channel + ) and not await self._can_instaskip(ctx, ctx.author): + return await self._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._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._data_check(ctx) + + @shuffle.command(name="bumped") + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + async def _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() + ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to toggle shuffle.")) + return await self._embed_msg( + ctx, + title=_("Unable To Toggle Shuffle"), + description=_("You need the DJ role to toggle shuffle."), + ) if self._player_check(ctx): await self._data_check(ctx) player = lavalink.get_player(ctx.guild.id) @@ -6169,15 +7162,18 @@ class Audio(commands.Cog): not ctx.author.voice or ctx.author.voice.channel != player.channel ) and not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to toggle shuffle.") + 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) + bumped = await self.config.guild(ctx.guild).shuffle_bumped() + await self.config.guild(ctx.guild).shuffle_bumped.set(not bumped) await self._embed_msg( ctx, - _("Shuffle tracks: {true_or_false}.").format( - true_or_false=_("Enabled") if not shuffle else _("Disabled") + 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): @@ -6205,17 +7201,21 @@ class Audio(commands.Cog): async def 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._embed_msg(ctx, _("Nothing playing.")) + return await self._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 await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to skip the music.") + 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._embed_msg(ctx, _("Nothing playing.")) - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + return await self._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_alone(ctx) is_requester = await self.is_requester(ctx, ctx.author) @@ -6223,7 +7223,11 @@ class Audio(commands.Cog): if dj_enabled and not vote_enabled: if not (can_skip or is_requester) and not is_alone: return await self._embed_msg( - ctx, _("You need the DJ role or be the track requester to skip tracks.") + ctx, + title=_("Unable To Skip Tracks"), + description=_( + "You need the DJ role or be the track requester to skip tracks." + ), ) if ( is_requester @@ -6231,12 +7235,21 @@ class Audio(commands.Cog): and isinstance(skip_to_track, int) and skip_to_track > 1 ): - return await self._embed_msg(ctx, _("You can only skip the current track.")) + return await self._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._embed_msg( - ctx, _("Can't skip to a specific track in vote mode without the DJ role.") + 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) @@ -6256,7 +7269,7 @@ class Audio(commands.Cog): percent = await self.config.guild(ctx.guild).vote_percent() if vote >= percent: self.skip_votes[ctx.message.guild] = [] - await self._embed_msg(ctx, _("Vote threshold met.")) + await self._embed_msg(ctx, title=_("Vote threshold met.")) return await self._skip_action(ctx) else: reply += _( @@ -6268,7 +7281,7 @@ class Audio(commands.Cog): cur_percent=vote, required_percent=percent, ) - return await self._embed_msg(ctx, reply) + return await self._embed_msg(ctx, title=reply) else: return await self._skip_action(ctx, skip_to_track) else: @@ -6276,7 +7289,9 @@ class Audio(commands.Cog): async def _can_instaskip(self, ctx: commands.Context, member: discord.Member): - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if member.bot: return True @@ -6299,13 +7314,17 @@ class Audio(commands.Cog): return False - async def _is_alone(self, ctx: commands.Context): + @staticmethod + async def _is_alone(ctx: commands.Context): channel_members = rgetattr(ctx, "guild.me.voice.channel.members", []) nonbots = sum(m.id != ctx.author.id for m in channel_members if not m.bot) return nonbots < 1 async def _has_dj_role(self, ctx: commands.Context, member: discord.Member): - dj_role_obj = ctx.guild.get_role(await self.config.guild(ctx.guild).dj_role()) + dj_role = self._dj_role_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_role() + ) + dj_role_obj = ctx.guild.get_role(dj_role) return dj_role_obj in ctx.guild.get_member(member.id).roles @staticmethod @@ -6320,59 +7339,52 @@ class Audio(commands.Cog): async def _skip_action(self, ctx: commands.Context, skip_to_track: int = None): player = lavalink.get_player(ctx.guild.id) - autoplay = await self.config.guild(player.channel.guild).auto_play() or self.owns_autoplay + autoplay = await self.config.guild(player.channel.guild).auto_play() if not player.current or (not player.queue and not autoplay): try: pos, dur = player.position, player.current.length except AttributeError: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) + return await self._embed_msg(ctx, title=_("There's nothing in the queue.")) time_remain = lavalink.utils.format_time(dur - pos) if player.current.is_stream: - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("There's nothing in the queue.") - ) + embed = discord.Embed(title=_("There's nothing in the queue.")) embed.set_footer( text=_("Currently livestreaming {track}").format(track=player.current.title) ) else: - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("There's nothing in the queue.") - ) + embed = discord.Embed(title=_("There's nothing in the queue.")) embed.set_footer( text=_("{time} left on {track}").format( time=time_remain, track=player.current.title ) ) - return await ctx.send(embed=embed) + return await self._embed_msg(ctx, embed=embed) elif autoplay and not player.queue: embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Track Skipped"), - description=await get_description(player.current), + title=_("Track Skipped"), description=get_track_description(player.current) ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, embed=embed) return await player.skip() queue_to_append = [] if skip_to_track is not None and skip_to_track != 1: if skip_to_track < 1: return await self._embed_msg( - ctx, _("Track number must be equal to or greater than 1.") + ctx, title=_("Track number must be equal to or greater than 1.") ) elif skip_to_track > len(player.queue): return await self._embed_msg( ctx, - _( + title=_( "There are only {queuelen} songs currently queued.".format( queuelen=len(player.queue) ) ), ) embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("{skip_to_track} Tracks Skipped".format(skip_to_track=skip_to_track)), + title=_("{skip_to_track} Tracks Skipped".format(skip_to_track=skip_to_track)) ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, embed=embed) if player.repeat: queue_to_append = player.queue[0 : min(skip_to_track - 1, len(player.queue) - 1)] player.queue = player.queue[ @@ -6380,11 +7392,9 @@ class Audio(commands.Cog): ] else: embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Track Skipped"), - description=await get_description(player.current), + title=_("Track Skipped"), description=get_track_description(player.current) ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, embed=embed) self.bot.dispatch("red_audio_skip_track", player.channel.guild, player.current, ctx.author) await player.play() player.queue += queue_to_append @@ -6394,25 +7404,35 @@ class Audio(commands.Cog): @commands.bot_has_permissions(embed_links=True) async def stop(self, ctx: commands.Context): """Stop playback and clear the queue.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + 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._embed_msg(ctx, _("Nothing playing.")) + return await self._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 await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to stop the music.") + 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: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx): return await self._embed_msg( - ctx, _("There are other people listening - vote to skip instead.") + ctx, + title=_("Unable To Stop Player"), + description=_("There are other people listening - vote to skip instead."), ) if dj_enabled and not vote_enabled: if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to stop the music.")) + return await self._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) @@ -6428,7 +7448,7 @@ class Audio(commands.Cog): player.store("prev_song", None) player.store("requester", None) await player.stop() - await self._embed_msg(ctx, _("Stopping...")) + await self._embed_msg(ctx, title=_("Stopping...")) @commands.command() @commands.guild_only() @@ -6436,18 +7456,39 @@ class Audio(commands.Cog): @commands.bot_has_permissions(embed_links=True) async def summon(self, ctx: commands.Context): """Summon the bot to a voice channel.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to summon the bot.")) + 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_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): + if not can_skip and not is_alone: + return await self._embed_msg( + ctx, + title=_("Unable To Join Voice Channel"), + description=_("There are other people listening."), + ) + if dj_enabled and not vote_enabled: + if not (can_skip or is_requester) and not is_alone: + return await self._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 userlimit(ctx.author.voice.channel) ): + ctx.command.reset_cooldown(ctx) return await self._embed_msg( - ctx, _("I don't have permission to connect to your channel.") + 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) @@ -6456,13 +7497,22 @@ class Audio(commands.Cog): 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: - return await self._embed_msg(ctx, _("Connect to a voice channel first.")) - except IndexError: + ctx.command.reset_cooldown(ctx) return await self._embed_msg( - ctx, _("Connection to Lavalink has not yet been established.") + ctx, + title=_("Unable To Join Voice Channel"), + description=_("Connect to a voice channel first."), + ) + except IndexError: + ctx.command.reset_cooldown(ctx) + return await self._embed_msg( + ctx, + title=_("Unable To Join Voice Channel"), + description=_("Connection to Lavalink has not yet been established."), ) @commands.command() @@ -6470,30 +7520,34 @@ class Audio(commands.Cog): @commands.bot_has_permissions(embed_links=True) async def volume(self, ctx: commands.Context, vol: int = None): """Set the volume, 1% - 150%.""" - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + dj_enabled = self._dj_status_cache.setdefault( + ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled() + ) if not vol: vol = await self.config.guild(ctx.guild).volume() - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Current Volume:"), - description=str(vol) + "%", - ) + embed = discord.Embed(title=_("Current Volume:"), description=str(vol) + "%") if not self._player_check(ctx): embed.set_footer(text=_("Nothing playing.")) - return await ctx.send(embed=embed) + return await self._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 await self._can_instaskip(ctx, ctx.author): return await self._embed_msg( - ctx, _("You must be in the voice channel to change the volume.") + ctx, + title=_("Unable To Change Volume"), + description=_("You must be in the voice channel to change the volume."), ) if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._has_dj_role( ctx, ctx.author ): - return await self._embed_msg(ctx, _("You need the DJ role to change the volume.")) + return await self._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: @@ -6505,12 +7559,10 @@ class Audio(commands.Cog): 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( - colour=await ctx.embed_colour(), title=_("Volume:"), description=str(vol) + "%" - ) + embed = discord.Embed(title=_("Volume:"), description=str(vol) + "%") if not self._player_check(ctx): embed.set_footer(text=_("Nothing playing.")) - await ctx.send(embed=embed) + await self._embed_msg(ctx, embed=embed) @commands.group(aliases=["llset"]) @commands.guild_only() @@ -6518,7 +7570,6 @@ class Audio(commands.Cog): @checks.is_owner() async def llsetup(self, ctx: commands.Context): """Lavalink server configuration options.""" - pass @llsetup.command() async def external(self, ctx: commands.Context): @@ -6528,18 +7579,19 @@ class Audio(commands.Cog): if external: embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("External lavalink server: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("External lavalink server: {true_or_false}.").format( true_or_false=_("Enabled") if not external else _("Disabled") ), ) - await ctx.send(embed=embed) + await self._embed_msg(ctx, embed=embed) else: if self._manager is not None: await self._manager.shutdown() await self._embed_msg( ctx, - _("External lavalink server: {true_or_false}.").format( + title=_("Setting Changed"), + description=_("External lavalink server: {true_or_false}.").format( true_or_false=_("Enabled") if not external else _("Disabled") ), ) @@ -6550,32 +7602,30 @@ class Audio(commands.Cog): async def host(self, ctx: commands.Context, host: str): """Set the lavalink server host.""" await self.config.host.set(host) + footer = None if await self._check_external(): - embed = discord.Embed( - colour=await ctx.embed_colour(), title=_("Host set to {host}.").format(host=host) - ) - embed.set_footer(text=_("External lavalink server set to True.")) - await ctx.send(embed=embed) - else: - await self._embed_msg(ctx, _("Host set to {host}.").format(host=host)) - + footer = _("External lavalink server set to True.") + await self._embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Host set to {host}.").format(host=host), + footer=footer, + ) self._restart_connect() @llsetup.command() async def password(self, ctx: commands.Context, password: str): """Set the lavalink server password.""" await self.config.password.set(str(password)) + footer = None if await self._check_external(): - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Server password set to {password}.").format(password=password), - ) - embed.set_footer(text=_("External lavalink server set to True.")) - await ctx.send(embed=embed) - else: - await self._embed_msg( - ctx, _("Server password set to {password}.").format(password=password) - ) + footer = _("External lavalink server set to True.") + await self._embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Server password set to {password}.").format(password=password), + footer=footer, + ) self._restart_connect() @@ -6583,15 +7633,15 @@ class Audio(commands.Cog): async def 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._check_external(): - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("REST port set to {port}.").format(port=rest_port), - ) - embed.set_footer(text=_("External lavalink server set to True.")) - await ctx.send(embed=embed) - else: - await self._embed_msg(ctx, _("REST port set to {port}.").format(port=rest_port)) + footer = _("External lavalink server set to True.") + await self._embed_msg( + ctx, + title=_("Setting Changed"), + description=_("REST port set to {port}.").format(port=rest_port), + footer=footer, + ) self._restart_connect() @@ -6599,15 +7649,15 @@ class Audio(commands.Cog): async def 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._check_external(): - embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Websocket port set to {port}.").format(port=ws_port), - ) - embed.set_footer(text=_("External lavalink server set to True.")) - await ctx.send(embed=embed) - else: - await self._embed_msg(ctx, _("Websocket port set to {port}.").format(port=ws_port)) + footer = _("External lavalink server set to True.") + await self._embed_msg( + ctx, + title=_("Setting Changed"), + description=_("Websocket port set to {port}.").format(port=ws_port), + footer=footer, + ) self._restart_connect() @@ -6685,25 +7735,31 @@ class Audio(commands.Cog): else: return False - async def _clear_react(self, message: discord.Message, emoji: dict = None): - """Non blocking version of clear_react""" + async def _clear_react(self, message: discord.Message, emoji: MutableMapping = None): + """Non blocking version of clear_react.""" return self.bot.loop.create_task(clear_react(self.bot, message, emoji)) async def _currency_check(self, ctx: commands.Context, jukebox_price: int): jukebox = await self.config.guild(ctx.guild).jukebox() if jukebox and not await self._can_instaskip(ctx, ctx.author): - try: + can_spend = await bank.can_spend(ctx.author, jukebox_price) + if can_spend: await bank.withdraw_credits(ctx.author, jukebox_price) - return True - except ValueError: + else: credits_name = await bank.get_currency_name(ctx.guild) + bal = await bank.get_balance(ctx.author) await self._embed_msg( ctx, - _("Not enough {currency} ({required_credits} required).").format( - currency=credits_name, required_credits=humanize_number(jukebox_price) + title=_("Not enough {currency}").format(currency=credits_name), + description=_( + "{required_credits} {currency} required, but you have {bal}." + ).format( + currency=credits_name, + required_credits=humanize_number(jukebox_price), + bal=humanize_number(bal), ), ) - return False + return can_spend else: return True @@ -6712,8 +7768,10 @@ class Audio(commands.Cog): shuffle = await self.config.guild(ctx.guild).shuffle() repeat = await self.config.guild(ctx.guild).repeat() volume = await self.config.guild(ctx.guild).volume() + shuffle_bumped = await self.config.guild(ctx.guild).shuffle_bumped() player.repeat = repeat player.shuffle = shuffle + player.shuffle_bumped = shuffle_bumped if player.volume != volume: await player.set_volume(volume) @@ -6753,7 +7811,6 @@ class Audio(commands.Cog): log.error("Exception raised in Audio's emptydc_timer.", exc_info=True) if "No such player for that guild" in str(err): stop_times.pop(sid, None) - pass elif ( sid in pause_times and await self.config.guild(server_obj).emptypause_enabled() ): @@ -6769,10 +7826,30 @@ class Audio(commands.Cog): ) await asyncio.sleep(5) - @staticmethod - async def _embed_msg(ctx: commands.Context, title: str): - embed = discord.Embed(colour=await ctx.embed_colour(), title=title) - await ctx.send(embed=embed) + async def _embed_msg(self, ctx: commands.Context, **kwargs): + colour = kwargs.get("colour") or kwargs.get("color") or await self.bot.get_embed_color(ctx) + error = kwargs.get("error", False) + success = kwargs.get("success", False) + title = kwargs.get("title", EmptyEmbed) or EmptyEmbed + _type = kwargs.get("type", "rich") or "rich" + url = kwargs.get("url", EmptyEmbed) or EmptyEmbed + description = kwargs.get("description", EmptyEmbed) or EmptyEmbed + timestamp = kwargs.get("timestamp") + footer = kwargs.get("footer") + thumbnail = kwargs.get("thumbnail") + contents = dict(title=title, type=_type, url=url, description=description) + embed = kwargs.get("embed").to_dict() if hasattr(kwargs.get("embed"), "to_dict") else {} + colour = embed.get("color") if embed.get("color") else colour + contents.update(embed) + if timestamp and isinstance(timestamp, datetime.datetime): + contents["timestamp"] = timestamp + embed = discord.Embed.from_dict(contents) + embed.color = colour + if footer: + embed.set_footer(text=footer) + if thumbnail: + embed.set_thumbnail(url=thumbnail) + return await ctx.send(embed=embed) async def _eq_check(self, ctx: commands.Context, player: lavalink.Player): eq = player.fetch("eq", Equalizer()) @@ -6798,16 +7875,16 @@ class Audio(commands.Cog): ): player.store("eq", eq) emoji = { - "far_left": "◀", - "one_left": "⬅", - "max_output": "⏫", - "output_up": "🔼", - "output_down": "🔽", - "min_output": "⏬", - "one_right": "➡", - "far_right": "▶", - "reset": "⏺", - "info": "ℹ", + "far_left": "\N{BLACK LEFT-POINTING TRIANGLE}", + "one_left": "\N{LEFTWARDS BLACK ARROW}", + "max_output": "\N{BLACK UP-POINTING DOUBLE TRIANGLE}", + "output_up": "\N{UP-POINTING SMALL RED TRIANGLE}", + "output_down": "\N{DOWN-POINTING SMALL RED TRIANGLE}", + "min_output": "\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}", + "one_right": "\N{BLACK RIGHTWARDS ARROW}", + "far_right": "\N{BLACK RIGHT-POINTING TRIANGLE}", + "reset": "\N{BLACK CIRCLE FOR RECORD}", + "info": "\N{INFORMATION SOURCE}", } selector = f'{" " * 8}{" " * selected}^^' try: @@ -6815,7 +7892,7 @@ class Audio(commands.Cog): except discord.errors.NotFound: return try: - react_emoji, react_user = await self._get_eq_reaction(ctx, message, emoji) + (react_emoji, react_user) = await self._get_eq_reaction(ctx, message, emoji) except TypeError: return @@ -6823,60 +7900,60 @@ class Audio(commands.Cog): await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands) await self._clear_react(message, emoji) - if react_emoji == "⬅": + if react_emoji == "\N{LEFTWARDS BLACK ARROW}": await remove_react(message, react_emoji, react_user) await self._eq_interact(ctx, player, eq, message, max(selected - 1, 0)) - if react_emoji == "➡": + if react_emoji == "\N{BLACK RIGHTWARDS ARROW}": await remove_react(message, react_emoji, react_user) await self._eq_interact(ctx, player, eq, message, min(selected + 1, 14)) - if react_emoji == "🔼": + if react_emoji == "\N{UP-POINTING SMALL RED TRIANGLE}": await remove_react(message, react_emoji, react_user) _max = "{:.2f}".format(min(eq.get_gain(selected) + 0.1, 1.0)) eq.set_gain(selected, float(_max)) await self._apply_gain(ctx.guild.id, selected, _max) await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "🔽": + if react_emoji == "\N{DOWN-POINTING SMALL RED TRIANGLE}": await remove_react(message, react_emoji, react_user) _min = "{:.2f}".format(max(eq.get_gain(selected) - 0.1, -0.25)) eq.set_gain(selected, float(_min)) await self._apply_gain(ctx.guild.id, selected, _min) await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "⏫": + if react_emoji == "\N{BLACK UP-POINTING DOUBLE TRIANGLE}": await remove_react(message, react_emoji, react_user) _max = 1.0 eq.set_gain(selected, _max) await self._apply_gain(ctx.guild.id, selected, _max) await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "⏬": + if react_emoji == "\N{BLACK DOWN-POINTING DOUBLE TRIANGLE}": await remove_react(message, react_emoji, react_user) _min = -0.25 eq.set_gain(selected, _min) await self._apply_gain(ctx.guild.id, selected, _min) await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "◀": + if react_emoji == "\N{BLACK LEFT-POINTING TRIANGLE}": await remove_react(message, react_emoji, react_user) selected = 0 await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "▶": + if react_emoji == "\N{BLACK RIGHT-POINTING TRIANGLE}": await remove_react(message, react_emoji, react_user) selected = 14 await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "⏺": + if react_emoji == "\N{BLACK CIRCLE FOR RECORD}": await remove_react(message, react_emoji, react_user) for band in range(eq._band_count): eq.set_gain(band, 0.0) await self._apply_gains(ctx.guild.id, eq.bands) await self._eq_interact(ctx, player, eq, message, selected) - if react_emoji == "ℹ": + if react_emoji == "\N{INFORMATION SOURCE}": await remove_react(message, react_emoji, react_user) await ctx.send_help(self.eq) await self._eq_interact(ctx, player, eq, message, selected) @@ -6919,6 +7996,52 @@ class Audio(commands.Cog): except KeyError: return False + @commands.Cog.listener() + async def on_red_audio_track_start( + self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member + ): + scope = PlaylistScope.GUILD.value + today = datetime.date.today() + midnight = datetime.datetime.combine(today, datetime.datetime.min.time()) + name = f"Daily playlist - {today}" + today_id = int(time.mktime(today.timetuple())) + track_identifier = track.track_identifier + track = track_to_json(track) + + try: + playlist = await get_playlist( + playlist_number=today_id, + scope=PlaylistScope.GUILD.value, + bot=self.bot, + guild=guild, + author=self.bot.user, + ) + except RuntimeError: + playlist = None + + if playlist: + tracks = playlist.tracks + tracks.append(track) + await playlist.edit({"tracks": tracks}) + else: + playlist = Playlist( + bot=self.bot, + scope=scope, + author=self.bot.user.id, + playlist_id=today_id, + name=name, + playlist_url=None, + tracks=[track], + guild=guild, + ) + await playlist.save() + with contextlib.suppress(Exception): + too_old = midnight - datetime.timedelta(days=8) + too_old_id = int(time.mktime(too_old.timetuple())) + await delete_playlist( + scope=scope, playlist_id=too_old_id, guild=guild, author=self.bot.user + ) + @commands.Cog.listener() async def on_voice_state_update( self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState @@ -6930,6 +8053,17 @@ class Audio(commands.Cog): except (ValueError, KeyError, AttributeError): pass + @commands.Cog.listener() + async def on_red_audio_queue_end( + self, guild: discord.Guild, track: lavalink.Track, requester: discord.Member + ): + await self.music_cache.database.clean_up_old_entries() + await asyncio.sleep(5) + dat = get_playlist_database() + if dat: + dat.delete_scheduled() + await asyncio.sleep(5) + def cog_unload(self): if not self._cleaned_up: self.bot.dispatch("red_audio_unload", self) @@ -7002,6 +8136,6 @@ class Audio(commands.Cog): async def _close_database(self): await self.music_cache.run_all_pending_tasks() - await self.music_cache.close() + self.music_cache.database.close() __del__ = cog_unload diff --git a/redbot/cogs/audio/audio_dataclasses.py b/redbot/cogs/audio/audio_dataclasses.py index 16dbc27fb..7b1ca769e 100644 --- a/redbot/cogs/audio/audio_dataclasses.py +++ b/redbot/cogs/audio/audio_dataclasses.py @@ -1,7 +1,9 @@ +import ntpath import os +import posixpath import re from pathlib import Path, PosixPath, WindowsPath -from typing import List, Optional, Union +from typing import List, Optional, Union, MutableMapping from urllib.parse import urlparse import lavalink @@ -14,13 +16,57 @@ _config: Optional[Config] = None _bot: Optional[Red] = None _localtrack_folder: Optional[str] = None _ = Translator("Audio", __file__) -_remove_start = re.compile(r"^(sc|list) ") -_re_youtube_timestamp = re.compile(r"&t=(\d+)s?") -_re_youtube_index = re.compile(r"&index=(\d+)") -_re_spotify_url = re.compile(r"(http[s]?://)?(open.spotify.com)/") -_re_spotify_timestamp = re.compile(r"#(\d+):(\d+)") -_re_soundcloud_timestamp = re.compile(r"#t=(\d+):(\d+)s?") -_re_twitch_timestamp = re.compile(r"\?t=(\d+)h(\d+)m(\d+)s") + +_RE_REMOVE_START = re.compile(r"^(sc|list) ") +_RE_YOUTUBE_TIMESTAMP = re.compile(r"&t=(\d+)s?") +_RE_YOUTUBE_INDEX = re.compile(r"&index=(\d+)") +_RE_SPOTIFY_URL = re.compile(r"(http[s]?://)?(open.spotify.com)/") +_RE_SPOTIFY_TIMESTAMP = re.compile(r"#(\d+):(\d+)") +_RE_SOUNDCLOUD_TIMESTAMP = re.compile(r"#t=(\d+):(\d+)s?") +_RE_TWITCH_TIMESTAMP = re.compile(r"\?t=(\d+)h(\d+)m(\d+)s") +_PATH_SEPS = [posixpath.sep, ntpath.sep] + +_FULLY_SUPPORTED_MUSIC_EXT = (".mp3", ".flac", ".ogg") +_PARTIALLY_SUPPORTED_MUSIC_EXT = ( + ".m3u", + ".m4a", + ".aac", + ".ra", + ".wav", + ".opus", + ".wma", + ".ts", + ".au", + # These do not work + # ".mid", + # ".mka", + # ".amr", + # ".aiff", + # ".ac3", + # ".voc", + # ".dsf", +) +_PARTIALLY_SUPPORTED_VIDEO_EXT = ( + ".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 def _pass_config_to_dataclasses(config: Config, bot: Red, folder: str): @@ -32,36 +78,14 @@ def _pass_config_to_dataclasses(config: Config, bot: Red, folder: str): _localtrack_folder = folder -class ChdirClean(object): - def __init__(self, directory): - self.old_dir = os.getcwd() - self.new_dir = directory - self.cwd = None +class LocalPath: + """Local tracks class. - def __enter__(self): - return self - - def __exit__(self, _type, value, traceback): - self.chdir_out() - return isinstance(value, OSError) - - def chdir_in(self): - self.cwd = Path(self.new_dir) - os.chdir(self.new_dir) - - def chdir_out(self): - self.cwd = Path(self.old_dir) - os.chdir(self.old_dir) - - -class LocalPath(ChdirClean): - """ - Local tracks class. - Used to handle system dir trees in a cross system manner. - The only use of this class is for `localtracks`. + Used to handle system dir trees in a cross system manner. The only use of this class is for + `localtracks`. """ - _supported_music_ext = (".mp3", ".flac", ".ogg") + _all_music_ext = _FULLY_SUPPORTED_MUSIC_EXT + _PARTIALLY_SUPPORTED_MUSIC_EXT def __init__(self, path, **kwargs): self._path = path @@ -89,10 +113,11 @@ class LocalPath(ChdirClean): _path.relative_to(self.localtrack_folder) self.path = _path except (ValueError, TypeError): - if path and path.startswith("localtracks//"): - path = path.replace("localtracks//", "", 1) - elif path and path.startswith("localtracks/"): - path = path.replace("localtracks/", "", 1) + 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: @@ -100,18 +125,18 @@ class LocalPath(ChdirClean): parent = self.path.parent else: parent = self.path - super().__init__(str(parent.absolute())) - self.parent = Path(parent) except OSError: self.parent = None - self.cwd = Path.cwd() - @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() @@ -159,11 +184,11 @@ class LocalPath(ChdirClean): def _filtered(self, paths: List[Path]): for p in paths: - if p.suffix in self._supported_music_ext: + if p.suffix in self._all_music_ext: yield p def __str__(self): - return str(self.path.absolute()) + return self.to_string() def to_string(self): try: @@ -171,7 +196,7 @@ class LocalPath(ChdirClean): except OSError: return str(self._path) - def to_string_hidden(self, arg: str = None): + 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, "" ) @@ -186,13 +211,13 @@ class LocalPath(ChdirClean): def tracks_in_tree(self): tracks = [] - for track in self.multirglob(*[f"*{ext}" for ext in self._supported_music_ext]): + for track in self.multirglob(*[f"*{ext}" for ext in self._all_music_ext]): if track.exists() and track.is_file() and track.parent != self.localtrack_folder: tracks.append(Query.process_input(LocalPath(str(track.absolute())))) - return tracks + return sorted(tracks, key=lambda x: x.to_string_user().lower()) def subfolders_in_tree(self): - files = list(self.multirglob(*[f"*{ext}" for ext in self._supported_music_ext])) + files = list(self.multirglob(*[f"*{ext}" for ext in self._all_music_ext])) folders = [] for f in files: if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder: @@ -201,17 +226,17 @@ class LocalPath(ChdirClean): for folder in folders: if folder.exists() and folder.is_dir(): return_folders.append(LocalPath(str(folder.absolute()))) - return return_folders + return sorted(return_folders, key=lambda x: x.to_string_user().lower()) def tracks_in_folder(self): tracks = [] - for track in self.multiglob(*[f"*{ext}" for ext in self._supported_music_ext]): + for track in self.multiglob(*[f"*{ext}" for ext in self._all_music_ext]): if track.exists() and track.is_file() and track.parent != self.localtrack_folder: tracks.append(Query.process_input(LocalPath(str(track.absolute())))) - return tracks + return sorted(tracks, key=lambda x: x.to_string_user().lower()) def subfolders(self): - files = list(self.multiglob(*[f"*{ext}" for ext in self._supported_music_ext])) + files = list(self.multiglob(*[f"*{ext}" for ext in self._all_music_ext])) folders = [] for f in files: if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder: @@ -220,12 +245,44 @@ class LocalPath(ChdirClean): for folder in folders: if folder.exists() and folder.is_dir(): return_folders.append(LocalPath(str(folder.absolute()))) - return return_folders + return sorted(return_folders, key=lambda x: x.to_string_user().lower()) + + def __eq__(self, other): + if not isinstance(other, LocalPath): + return NotImplemented + return self.path._cparts == other.path._cparts + + def __hash__(self): + try: + return self._hash + except AttributeError: + self._hash = hash(tuple(self.path._cparts)) + return self._hash + + def __lt__(self, other): + if not isinstance(other, LocalPath): + return NotImplemented + return self.path._cparts < other.path._cparts + + def __le__(self, other): + if not isinstance(other, LocalPath): + return NotImplemented + return self.path._cparts <= other.path._cparts + + def __gt__(self, other): + if not isinstance(other, LocalPath): + return NotImplemented + return self.path._cparts > other.path._cparts + + def __ge__(self, other): + if not isinstance(other, LocalPath): + return NotImplemented + return self.path._cparts >= other.path._cparts class Query: - """ - Query data class. + """Query data class. + Use: Query.process_input(query) to generate the Query object. """ @@ -259,6 +316,8 @@ class Query: 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) @@ -271,16 +330,38 @@ class 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], **kwargs): - """ - A replacement for :code:`lavalink.Player.load_tracks`. - This will try to get a valid cached entry first if not found or if in valid - it will then call the lavalink API. + """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 ---------- @@ -293,7 +374,7 @@ class Query: """ if not query: query = "InvalidQueryPlaceHolderName" - possible_values = dict() + possible_values = {} if isinstance(query, str): query = query.strip("<>") @@ -311,7 +392,7 @@ class Query: return cls(query, **possible_values) @staticmethod - def _parse(track, **kwargs): + def _parse(track, **kwargs) -> MutableMapping: returning = {} if ( type(track) == type(LocalPath) @@ -338,7 +419,7 @@ class Query: _id = _id.split("?")[0] returning["id"] = _id if "#" in _id: - match = re.search(_re_spotify_timestamp, track) + match = re.search(_RE_SPOTIFY_TIMESTAMP, track) if match: returning["start_time"] = (int(match.group(1)) * 60) + int(match.group(2)) returning["uri"] = track @@ -349,7 +430,7 @@ class Query: returning["soundcloud"] = True elif track.startswith("list "): returning["invoked_from"] = "search list" - track = _remove_start.sub("", track, 1) + track = _RE_REMOVE_START.sub("", track, 1) returning["queryforced"] = track _localtrack = LocalPath(track) @@ -367,6 +448,8 @@ class Query: 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:]) @@ -374,11 +457,11 @@ class Query: returning["youtube"] = True _has_index = "&index=" in track if "&t=" in track: - match = re.search(_re_youtube_timestamp, 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) + 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?"]): @@ -402,7 +485,7 @@ class Query: returning["album"] = True elif "/track/" in track: returning["single"] = True - val = re.sub(_re_spotify_url, "", track).replace("/", ":") + val = re.sub(_RE_SPOTIFY_URL, "", track).replace("/", ":") if "user:" in val: val = val.split(":", 2)[-1] _id = val.split(":", 1)[-1] @@ -410,7 +493,7 @@ class Query: if "#" in _id: _id = _id.split("#")[0] - match = re.search(_re_spotify_timestamp, track) + match = re.search(_RE_SPOTIFY_TIMESTAMP, track) if match: returning["start_time"] = (int(match.group(1)) * 60) + int( match.group(2) @@ -421,7 +504,7 @@ class Query: elif url_domain == "soundcloud.com": returning["soundcloud"] = True if "#t=" in track: - match = re.search(_re_soundcloud_timestamp, track) + match = re.search(_RE_SOUNDCLOUD_TIMESTAMP, track) if match: returning["start_time"] = (int(match.group(1)) * 60) + int( match.group(2) @@ -446,7 +529,7 @@ class Query: elif url_domain == "twitch.tv": returning["twitch"] = True if "?t=" in track: - match = re.search(_re_twitch_timestamp, track) + match = re.search(_RE_TWITCH_TIMESTAMP, track) if match: returning["start_time"] = ( (int(match.group(1)) * 60 * 60) @@ -485,5 +568,66 @@ class Query: def to_string_user(self): if self.is_local: - return str(self.track.to_string_hidden()) + return str(self.track.to_string_user()) return str(self._raw) + + @property + def suffix(self): + if self.is_local: + return self.track.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() diff --git a/redbot/cogs/audio/checks.py b/redbot/cogs/audio/checks.py index d6f9e6315..8289ca3e3 100644 --- a/redbot/cogs/audio/checks.py +++ b/redbot/cogs/audio/checks.py @@ -1,8 +1,11 @@ +from typing import TYPE_CHECKING + from redbot.core import Config, commands -from .apis import HAS_SQL - -_config = None +if TYPE_CHECKING: + _config: Config +else: + _config = None def _pass_config_to_checks(config: Config): @@ -26,12 +29,3 @@ def roomlocked(): return False return commands.check(predicate) - - -def can_have_caching(): - """Check to disable Caching commands if SQLite is not avaliable.""" - - async def predicate(ctx: commands.Context): - return HAS_SQL - - return commands.check(predicate) diff --git a/redbot/cogs/audio/config.py b/redbot/cogs/audio/config.py new file mode 100644 index 000000000..6737a1eac --- /dev/null +++ b/redbot/cogs/audio/config.py @@ -0,0 +1,18 @@ +from redbot.core import Config +from redbot.core.bot import Red + +from .apis import _pass_config_to_apis +from .audio_dataclasses import _pass_config_to_dataclasses +from .converters import _pass_config_to_converters +from .databases import _pass_config_to_databases +from .playlists import _pass_config_to_playlist +from .utils import _pass_config_to_utils + + +def pass_config_to_dependencies(config: Config, bot: Red, localtracks_folder: str): + _pass_config_to_databases(config, bot) + _pass_config_to_utils(config, bot) + _pass_config_to_dataclasses(config, bot, localtracks_folder) + _pass_config_to_apis(config, bot) + _pass_config_to_playlist(config, bot) + _pass_config_to_converters(config, bot) diff --git a/redbot/cogs/audio/converters.py b/redbot/cogs/audio/converters.py index 4842b8bf0..8a928a323 100644 --- a/redbot/cogs/audio/converters.py +++ b/redbot/cogs/audio/converters.py @@ -1,16 +1,17 @@ import argparse import functools import re -from typing import Optional, Tuple, Union +from typing import Optional, Tuple, Union, MutableMapping, TYPE_CHECKING import discord -from redbot.cogs.audio.errors import TooManyMatches, NoMatchesFound from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.i18n import Translator -from .playlists import PlaylistScope, standardize_scope +from .errors import NoMatchesFound, TooManyMatches +from .playlists import get_all_playlist_converter, standardize_scope +from .utils import PlaylistScope _ = Translator("Audio", __file__) @@ -24,8 +25,12 @@ __all__ = [ "get_playlist_converter", ] -_config = None -_bot = None +if TYPE_CHECKING: + _bot: Red + _config: Config +else: + _bot = None + _config = None _SCOPE_HELP = """ Scope must be a valid version of one of the following: @@ -42,7 +47,7 @@ Author must be a valid version of one of the following: _GUILD_HELP = """ Guild must be a valid version of one of the following: ​ ​ ​ ​ Guild ID -​ ​ ​ ​ Exact guild name +​ ​ ​ ​ Exact guild name """ MENTION_RE = re.compile(r"^?$") @@ -137,30 +142,18 @@ async def global_unique_user_finder( class PlaylistConverter(commands.Converter): - async def convert(self, ctx: commands.Context, arg: str) -> dict: - global_scope = await _config.custom(PlaylistScope.GLOBAL.value).all() - guild_scope = await _config.custom(PlaylistScope.GUILD.value).all() - user_scope = await _config.custom(PlaylistScope.USER.value).all() - user_matches = [ - (uid, pid, pdata) - for uid, data in user_scope.items() - for pid, pdata in data.items() - if arg == pid or arg.lower() in pdata.get("name", "").lower() - ] - guild_matches = [ - (gid, pid, pdata) - for gid, data in guild_scope.items() - for pid, pdata in data.items() - if arg == pid or arg.lower() in pdata.get("name", "").lower() - ] - global_matches = [ - (None, pid, pdata) - for pid, pdata in global_scope.items() - if arg == pid or arg.lower() in pdata.get("name", "").lower() - ] + async def convert(self, ctx: commands.Context, arg: str) -> MutableMapping: + global_matches = await get_all_playlist_converter( + PlaylistScope.GLOBAL.value, _bot, arg, guild=ctx.guild, author=ctx.author + ) + guild_matches = await get_all_playlist_converter( + PlaylistScope.GUILD.value, _bot, arg, guild=ctx.guild, author=ctx.author + ) + user_matches = await get_all_playlist_converter( + PlaylistScope.USER.value, _bot, arg, guild=ctx.guild, author=ctx.author + ) 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, @@ -498,9 +491,7 @@ class LazyGreedyConverter(commands.Converter): def get_lazy_converter(splitter: str) -> type: - """ - Returns a typechecking safe `LazyGreedyConverter` suitable for use with discord.py. - """ + """Returns a typechecking safe `LazyGreedyConverter` suitable for use with discord.py.""" class PartialMeta(type(LazyGreedyConverter)): __call__ = functools.partialmethod(type(LazyGreedyConverter).__call__, splitter) @@ -512,9 +503,7 @@ def get_lazy_converter(splitter: str) -> type: def get_playlist_converter() -> type: - """ - Returns a typechecking safe `PlaylistConverter` suitable for use with discord.py. - """ + """Returns a typechecking safe `PlaylistConverter` suitable for use with discord.py.""" class PartialMeta(type(PlaylistConverter)): __call__ = functools.partialmethod(type(PlaylistConverter).__call__) diff --git a/redbot/cogs/audio/databases.py b/redbot/cogs/audio/databases.py new file mode 100644 index 000000000..6db74da75 --- /dev/null +++ b/redbot/cogs/audio/databases.py @@ -0,0 +1,372 @@ +import asyncio +import concurrent.futures +import contextlib +import datetime +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Union, MutableMapping, Mapping + +import apsw + +from redbot.core import Config +from redbot.core.bot import Red +from redbot.core.data_manager import cog_data_path + +from .errors import InvalidTableError +from .sql_statements import * +from .utils import PlaylistScope + +log = logging.getLogger("red.audio.database") + +if TYPE_CHECKING: + database_connection: apsw.Connection + _bot: Red + _config: Config +else: + _config = None + _bot = None + database_connection = None + + +SCHEMA_VERSION = 3 +SQLError = apsw.ExecutionCompleteError + + +_PARSER: Mapping = { + "youtube": { + "insert": YOUTUBE_UPSERT, + "youtube_url": {"query": YOUTUBE_QUERY}, + "update": YOUTUBE_UPDATE, + }, + "spotify": { + "insert": SPOTIFY_UPSERT, + "track_info": {"query": SPOTIFY_QUERY}, + "update": SPOTIFY_UPDATE, + }, + "lavalink": { + "insert": LAVALINK_UPSERT, + "data": {"query": LAVALINK_QUERY, "played": LAVALINK_QUERY_LAST_FETCHED_RANDOM}, + "update": LAVALINK_UPDATE, + }, +} + + +def _pass_config_to_databases(config: Config, bot: Red): + global _config, _bot, database_connection + if _config is None: + _config = config + if _bot is None: + _bot = bot + if database_connection is None: + database_connection = apsw.Connection( + str(cog_data_path(_bot.get_cog("Audio")) / "Audio.db") + ) + + +@dataclass +class PlaylistFetchResult: + playlist_id: int + playlist_name: str + scope_id: int + author_id: int + playlist_url: Optional[str] = None + tracks: List[MutableMapping] = field(default_factory=lambda: []) + + def __post_init__(self): + if isinstance(self.tracks, str): + self.tracks = json.loads(self.tracks) + + +@dataclass +class CacheFetchResult: + query: Optional[Union[str, MutableMapping]] + last_updated: int + + def __post_init__(self): + if isinstance(self.last_updated, int): + self.updated_on: datetime.datetime = datetime.datetime.fromtimestamp(self.last_updated) + if isinstance(self.query, str) and all( + k in self.query for k in ["loadType", "playlistInfo", "isSeekable", "isStream"] + ): + self.query = json.loads(self.query) + + +@dataclass +class CacheLastFetchResult: + tracks: List[MutableMapping] = field(default_factory=lambda: []) + + def __post_init__(self): + if isinstance(self.tracks, str): + self.tracks = json.loads(self.tracks) + + +@dataclass +class CacheGetAllLavalink: + query: str + data: List[MutableMapping] = field(default_factory=lambda: []) + + def __post_init__(self): + if isinstance(self.data, str): + self.data = json.loads(self.data) + + +class CacheInterface: + def __init__(self): + self.database = database_connection.cursor() + + @staticmethod + def close(): + with contextlib.suppress(Exception): + database_connection.close() + + async def init(self): + self.database.execute(PRAGMA_SET_temp_store) + self.database.execute(PRAGMA_SET_journal_mode) + self.database.execute(PRAGMA_SET_read_uncommitted) + self.maybe_migrate() + + self.database.execute(LAVALINK_CREATE_TABLE) + self.database.execute(LAVALINK_CREATE_INDEX) + self.database.execute(YOUTUBE_CREATE_TABLE) + self.database.execute(YOUTUBE_CREATE_INDEX) + self.database.execute(SPOTIFY_CREATE_TABLE) + self.database.execute(SPOTIFY_CREATE_INDEX) + + await self.clean_up_old_entries() + + async def clean_up_old_entries(self): + max_age = await _config.cache_age() + maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age) + maxage_int = int(time.mktime(maxage.timetuple())) + values = {"maxage": maxage_int} + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + executor.submit(self.database.execute, LAVALINK_DELETE_OLD_ENTRIES, values) + executor.submit(self.database.execute, YOUTUBE_DELETE_OLD_ENTRIES, values) + executor.submit(self.database.execute, SPOTIFY_DELETE_OLD_ENTRIES, values) + + def maybe_migrate(self): + current_version = self.database.execute(PRAGMA_FETCH_user_version).fetchone() + if isinstance(current_version, tuple): + current_version = current_version[0] + if current_version == SCHEMA_VERSION: + return + self.database.execute(PRAGMA_SET_user_version, {"version": SCHEMA_VERSION}) + + async def insert(self, table: str, values: List[MutableMapping]): + try: + query = _PARSER.get(table, {}).get("insert") + if query is None: + raise InvalidTableError(f"{table} is not a valid table in the database.") + self.database.execute("BEGIN;") + self.database.executemany(query, values) + self.database.execute("COMMIT;") + except Exception as err: + log.debug("Error during audio db insert", exc_info=err) + + async def update(self, table: str, values: Dict[str, Union[str, int]]): + try: + table = _PARSER.get(table, {}) + sql_query = table.get("update") + time_now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + values["last_fetched"] = time_now + if not table: + raise InvalidTableError(f"{table} is not a valid table in the database.") + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(self.database.execute, sql_query, values) + except Exception as err: + log.debug("Error during audio db update", exc_info=err) + + async def fetch_one( + self, table: str, query: str, values: Dict[str, Union[str, int]] + ) -> Tuple[Optional[str], bool]: + table = _PARSER.get(table, {}) + sql_query = table.get(query, {}).get("query") + if not table: + raise InvalidTableError(f"{table} is not a valid table in the database.") + max_age = await _config.cache_age() + maxage = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=max_age) + maxage_int = int(time.mktime(maxage.timetuple())) + values.update({"maxage": maxage_int}) + output = self.database.execute(sql_query, values).fetchone() or (None, 0) + result = CacheFetchResult(*output) + return result.query, False + + async def fetch_all( + self, table: str, query: str, values: Dict[str, Union[str, int]] + ) -> List[CacheLastFetchResult]: + table = _PARSER.get(table, {}) + sql_query = table.get(query, {}).get("played") + if not table: + raise InvalidTableError(f"{table} is not a valid table in the database.") + + output = [] + for index, row in enumerate(self.database.execute(sql_query, values), start=1): + if index % 50 == 0: + await asyncio.sleep(0.01) + output.append(CacheLastFetchResult(*row)) + return output + + async def fetch_random( + self, table: str, query: str, values: Dict[str, Union[str, int]] + ) -> CacheLastFetchResult: + table = _PARSER.get(table, {}) + sql_query = table.get(query, {}).get("played") + if not table: + raise InvalidTableError(f"{table} is not a valid table in the database.") + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + for future in concurrent.futures.as_completed( + [executor.submit(self.database.execute, sql_query, values)] + ): + try: + row = future.result() + row = row.fetchone() + except Exception as exc: + log.debug(f"Failed to completed random fetch from database", exc_info=exc) + return CacheLastFetchResult(*row) + + +class PlaylistInterface: + def __init__(self): + self.cursor = database_connection.cursor() + self.cursor.execute(PRAGMA_SET_temp_store) + self.cursor.execute(PRAGMA_SET_journal_mode) + self.cursor.execute(PRAGMA_SET_read_uncommitted) + self.cursor.execute(PLAYLIST_CREATE_TABLE) + self.cursor.execute(PLAYLIST_CREATE_INDEX) + + @staticmethod + def close(): + with contextlib.suppress(Exception): + database_connection.close() + + @staticmethod + def get_scope_type(scope: str) -> int: + if scope == PlaylistScope.GLOBAL.value: + table = 1 + elif scope == PlaylistScope.USER.value: + table = 3 + else: + table = 2 + return table + + def fetch(self, scope: str, playlist_id: int, scope_id: int) -> PlaylistFetchResult: + scope_type = self.get_scope_type(scope) + row = ( + self.cursor.execute( + PLAYLIST_FETCH, + ({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}), + ).fetchone() + or [] + ) + + return PlaylistFetchResult(*row) if row else None + + async def fetch_all( + self, scope: str, scope_id: int, author_id=None + ) -> List[PlaylistFetchResult]: + scope_type = self.get_scope_type(scope) + if author_id is not None: + output = [] + for index, row in enumerate( + self.cursor.execute( + PLAYLIST_FETCH_ALL_WITH_FILTER, + ({"scope_type": scope_type, "scope_id": scope_id, "author_id": author_id}), + ), + start=1, + ): + if index % 50 == 0: + await asyncio.sleep(0.01) + output.append(row) + else: + output = [] + for index, row in enumerate( + self.cursor.execute( + PLAYLIST_FETCH_ALL, ({"scope_type": scope_type, "scope_id": scope_id}) + ), + start=1, + ): + if index % 50 == 0: + await asyncio.sleep(0.01) + output.append(row) + return [PlaylistFetchResult(*row) for row in output] if output else [] + + async def fetch_all_converter( + self, scope: str, playlist_name, playlist_id + ) -> List[PlaylistFetchResult]: + scope_type = self.get_scope_type(scope) + try: + playlist_id = int(playlist_id) + except Exception: + playlist_id = -1 + + output = [] + for index, row in enumerate( + self.cursor.execute( + PLAYLIST_FETCH_ALL_CONVERTER, + ( + { + "scope_type": scope_type, + "playlist_name": playlist_name, + "playlist_id": playlist_id, + } + ), + ), + start=1, + ): + if index % 50 == 0: + await asyncio.sleep(0.01) + output.append(row) + return [PlaylistFetchResult(*row) for row in output] if output else [] + + def delete(self, scope: str, playlist_id: int, scope_id: int): + scope_type = self.get_scope_type(scope) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit( + self.cursor.execute, + PLAYLIST_DELETE, + ({"playlist_id": playlist_id, "scope_id": scope_id, "scope_type": scope_type}), + ) + + def delete_scheduled(self): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(self.cursor.execute, PLAYLIST_DELETE_SCHEDULED) + + def drop(self, scope: str): + scope_type = self.get_scope_type(scope) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit( + self.cursor.execute, PLAYLIST_DELETE_SCOPE, ({"scope_type": scope_type}) + ) + + def create_table(self, scope: str): + scope_type = self.get_scope_type(scope) + return self.cursor.execute(PLAYLIST_CREATE_TABLE, ({"scope_type": scope_type})) + + def upsert( + self, + scope: str, + playlist_id: int, + playlist_name: str, + scope_id: int, + author_id: int, + playlist_url: Optional[str], + tracks: List[MutableMapping], + ): + scope_type = self.get_scope_type(scope) + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + executor.submit( + self.cursor.execute, + PLAYLIST_UPSERT, + { + "scope_type": str(scope_type), + "playlist_id": int(playlist_id), + "playlist_name": str(playlist_name), + "scope_id": int(scope_id), + "author_id": int(author_id), + "playlist_url": playlist_url, + "tracks": json.dumps(tracks), + }, + ) diff --git a/redbot/cogs/audio/equalizer.py b/redbot/cogs/audio/equalizer.py index ee3cb62fb..3aea24f1c 100644 --- a/redbot/cogs/audio/equalizer.py +++ b/redbot/cogs/audio/equalizer.py @@ -5,7 +5,7 @@ class Equalizer: def __init__(self): self._band_count = 15 - self.bands = [0.0 for _ in range(self._band_count)] + self.bands = [0.0 for _loop_counter in range(self._band_count)] def set_gain(self, band: int, gain: float): if band < 0 or band >= self._band_count: diff --git a/redbot/cogs/audio/errors.py b/redbot/cogs/audio/errors.py index 41374a9a8..5a3d3ecfd 100644 --- a/redbot/cogs/audio/errors.py +++ b/redbot/cogs/audio/errors.py @@ -14,7 +14,6 @@ class LavalinkDownloadFailed(AudioError, RuntimeError): The response from the server to the failed GET request. should_retry : bool Whether or not the Audio cog should retry downloading the jar. - """ def __init__(self, *args, response: aiohttp.ClientResponse, should_retry: bool = False): @@ -33,6 +32,18 @@ class LavalinkDownloadFailed(AudioError, RuntimeError): return f"[{self.response.status} {self.response.reason}]" +class QueryUnauthorized(AudioError): + """Provided an unauthorized query to audio.""" + + def __init__(self, message, *args): + self.message = message + super().__init__(*args) + + +class TrackEnqueueError(AudioError): + """Unable to play track.""" + + class PlayListError(AudioError): """Base exception for errors related to playlists.""" diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index 14175275d..ab8f8e811 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -15,24 +15,28 @@ import aiohttp from tqdm import tqdm from redbot.core import data_manager + from .errors import LavalinkDownloadFailed +log = logging.getLogger("red.audio.manager") JAR_VERSION = "3.2.1" JAR_BUILD = 846 LAVALINK_DOWNLOAD_URL = ( f"https://github.com/Cog-Creators/Lavalink-Jars/releases/download/{JAR_VERSION}_{JAR_BUILD}/" - f"Lavalink.jar" + "Lavalink.jar" ) LAVALINK_DOWNLOAD_DIR = data_manager.cog_data_path(raw_name="Audio") LAVALINK_JAR_FILE = LAVALINK_DOWNLOAD_DIR / "Lavalink.jar" - BUNDLED_APP_YML = pathlib.Path(__file__).parent / "data" / "application.yml" LAVALINK_APP_YML = LAVALINK_DOWNLOAD_DIR / "application.yml" -READY_LINE_RE = re.compile(rb"Started Launcher in \S+ seconds") -BUILD_LINE_RE = re.compile(rb"Build:\s+(?P\d+)") - -log = logging.getLogger("red.audio.manager") +_RE_READY_LINE = re.compile(rb"Started Launcher in \S+ seconds") +_FAILED_TO_START = re.compile(rb"Web server failed to start. (.*)") +_RE_BUILD_LINE = re.compile(rb"Build:\s+(?P\d+)") +_RE_JAVA_VERSION_LINE = re.compile( + r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"' +) +_RE_JAVA_SHORT_VERSION = re.compile(r'version "(?P\d+)"') class ServerManager: @@ -40,10 +44,10 @@ class ServerManager: _java_available: ClassVar[Optional[bool]] = None _java_version: ClassVar[Optional[Tuple[int, int]]] = None _up_to_date: ClassVar[Optional[bool]] = None - _blacklisted_archs = [] + _blacklisted_archs: List[str] = [] def __init__(self) -> None: - self.ready = asyncio.Event() + self.ready: asyncio.Event = asyncio.Event() self._proc: Optional[asyncio.subprocess.Process] = None # pylint:disable=no-member self._monitor_task: Optional[asyncio.Task] = None @@ -88,7 +92,7 @@ class ServerManager: @classmethod async def _get_jar_args(cls) -> List[str]: - java_available, java_version = await cls._has_java() + (java_available, java_version) = await cls._has_java() if not java_available: raise RuntimeError("You must install Java 1.8+ for Lavalink to run.") @@ -117,9 +121,7 @@ class ServerManager: @staticmethod async def _get_java_version() -> Tuple[int, int]: - """ - This assumes we've already checked that java exists. - """ + """This assumes we've already checked that java exists.""" _proc: asyncio.subprocess.Process = await asyncio.create_subprocess_exec( # pylint:disable=no-member "java", "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) @@ -133,15 +135,11 @@ class ServerManager: # ... version "MAJOR.MINOR.PATCH[_BUILD]" ... # ... # We only care about the major and minor parts though. - version_line_re = re.compile( - r'version "(?P\d+).(?P\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"' - ) - short_version_re = re.compile(r'version "(?P\d+)"') lines = version_info.splitlines() for line in lines: - match = version_line_re.search(line) - short_match = short_version_re.search(line) + match = _RE_JAVA_VERSION_LINE.search(line) + short_match = _RE_JAVA_SHORT_VERSION.search(line) if match: return int(match["major"]), int(match["minor"]) elif short_match: @@ -157,9 +155,11 @@ class ServerManager: lastmessage = 0 for i in itertools.cycle(range(50)): line = await self._proc.stdout.readline() - if READY_LINE_RE.search(line): + if _RE_READY_LINE.search(line): self.ready.set() break + if _FAILED_TO_START.search(line): + raise RuntimeError(f"Lavalink failed to start: {line.decode().strip()}") if self._proc.returncode is not None and lastmessage + 2 < time.time(): # Avoid Console spam only print once every 2 seconds lastmessage = time.time() @@ -259,7 +259,7 @@ class ServerManager: stderr=asyncio.subprocess.STDOUT, ) stdout = (await _proc.communicate())[0] - match = BUILD_LINE_RE.search(stdout) + match = _RE_BUILD_LINE.search(stdout) if not match: # Output is unexpected, suspect corrupted jarfile return False diff --git a/redbot/cogs/audio/playlists.py b/redbot/cogs/audio/playlists.py index 1ac62b7fd..7b33107d4 100644 --- a/redbot/cogs/audio/playlists.py +++ b/redbot/cogs/audio/playlists.py @@ -1,6 +1,5 @@ from collections import namedtuple -from enum import Enum, unique -from typing import List, Optional, Union +from typing import List, MutableMapping, Optional, Union, TYPE_CHECKING import discord import lavalink @@ -9,22 +8,33 @@ from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.i18n import Translator from redbot.core.utils.chat_formatting import humanize_list -from .errors import InvalidPlaylistScope, MissingAuthor, MissingGuild, NotAllowed -_config = None -_bot = None +from .databases import PlaylistFetchResult, PlaylistInterface +from .errors import InvalidPlaylistScope, MissingAuthor, MissingGuild, NotAllowed +from .utils import PlaylistScope + +if TYPE_CHECKING: + database: PlaylistInterface + _bot: Red + _config: Config +else: + database = None + _bot = None + _config = None __all__ = [ "Playlist", - "PlaylistScope", "get_playlist", "get_all_playlist", "create_playlist", "reset_playlist", "delete_playlist", - "humanize_scope", "standardize_scope", "FakePlaylist", + "get_all_playlist_for_migration23", + "database", + "get_all_playlist_converter", + "get_playlist_database", ] FakePlaylist = namedtuple("Playlist", "author scope") @@ -32,29 +42,22 @@ FakePlaylist = namedtuple("Playlist", "author scope") _ = Translator("Audio", __file__) -@unique -class PlaylistScope(Enum): - GLOBAL = "GLOBALPLAYLIST" - GUILD = "GUILDPLAYLIST" - USER = "USERPLAYLIST" - - def __str__(self): - return "{0}".format(self.value) - - @staticmethod - def list(): - return list(map(lambda c: c.value, PlaylistScope)) - - def _pass_config_to_playlist(config: Config, bot: Red): - global _config, _bot + global _config, _bot, database if _config is None: _config = config if _bot is None: _bot = bot + if database is None: + database = PlaylistInterface() -def standardize_scope(scope) -> str: +def get_playlist_database() -> Optional[PlaylistInterface]: + global database + return database + + +def standardize_scope(scope: str) -> str: scope = scope.upper() valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"] @@ -76,17 +79,25 @@ def standardize_scope(scope) -> str: return scope -def humanize_scope(scope, ctx=None, the=None): +def _prepare_config_scope( + scope, author: Union[discord.abc.User, int] = None, guild: Union[discord.Guild, int] = None +): + scope = standardize_scope(scope) if scope == PlaylistScope.GLOBAL.value: - return ctx or _("the ") if the else "" + "Global" - elif scope == PlaylistScope.GUILD.value: - return ctx.name if ctx else _("the ") if the else "" + "Server" + config_scope = [PlaylistScope.GLOBAL.value, _bot.user.id] elif scope == PlaylistScope.USER.value: - return str(ctx) if ctx else _("the ") if the else "" + "User" + 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( +def _prepare_config_scope_for_migration23( # TODO: remove me in a future version ? scope, author: Union[discord.abc.User, int] = None, guild: discord.Guild = None ): scope = standardize_scope(scope) @@ -104,6 +115,146 @@ def _prepare_config_scope( return config_scope +class PlaylistMigration23: # TODO: remove me in a future version ? + """A single playlist.""" + + def __init__( + self, + scope: str, + author: int, + playlist_id: int, + name: str, + playlist_url: Optional[str] = None, + tracks: Optional[List[MutableMapping]] = None, + guild: Union[discord.Guild, int, None] = None, + ): + self.guild = guild + self.scope = standardize_scope(scope) + self.author = author + self.id = playlist_id + self.name = name + self.url = playlist_url + self.tracks = tracks or [] + + @classmethod + async def from_json( + cls, scope: str, playlist_number: int, data: MutableMapping, **kwargs + ) -> "PlaylistMigration23": + """Get a Playlist object from the provided information. + Parameters + ---------- + scope:str + The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'. + playlist_number: int + The playlist's number. + data: dict + The JSON representation of the playlist to be gotten. + **kwargs + Extra attributes for the Playlist instance which override values + in the data dict. These should be complete objects and not + IDs, where possible. + Returns + ------- + Playlist + The playlist object for the requested playlist. + Raises + ------ + `InvalidPlaylistScope` + Passing a scope that is not supported. + `MissingGuild` + Trying to access the Guild scope without a guild. + `MissingAuthor` + Trying to access the User scope without an user id. + """ + guild = data.get("guild") or kwargs.get("guild") + author: int = data.get("author") or 0 + playlist_id = data.get("id") or playlist_number + name = data.get("name", "Unnamed") + playlist_url = data.get("playlist_url", None) + tracks = data.get("tracks", []) + + return cls( + guild=guild, + scope=scope, + author=author, + playlist_id=playlist_id, + name=name, + playlist_url=playlist_url, + tracks=tracks, + ) + + async def save(self): + """Saves a Playlist to SQL.""" + scope, scope_id = _prepare_config_scope(self.scope, self.author, self.guild) + database.upsert( + scope, + playlist_id=int(self.id), + playlist_name=self.name, + scope_id=scope_id, + author_id=self.author, + playlist_url=self.url, + tracks=self.tracks, + ) + + +async def get_all_playlist_for_migration23( # TODO: remove me in a future version ? + scope: str, guild: Union[discord.Guild, int] = None +) -> List[PlaylistMigration23]: + """ + Gets all playlist for the specified scope. + Parameters + ---------- + scope: str + The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'. + guild: discord.Guild + The guild to get the playlist from if scope is GUILDPLAYLIST. + Returns + ------- + list + A list of all playlists for the specified scope + Raises + ------ + `InvalidPlaylistScope` + Passing a scope that is not supported. + `MissingGuild` + Trying to access the Guild scope without a guild. + `MissingAuthor` + Trying to access the User scope without an user id. + """ + playlists = await _config.custom(scope).all() + if scope == PlaylistScope.GLOBAL.value: + return [ + await PlaylistMigration23.from_json( + scope, + playlist_number, + playlist_data, + guild=guild, + author=int(playlist_data.get("author", 0)), + ) + for playlist_number, playlist_data in playlists.items() + ] + elif scope == PlaylistScope.USER.value: + return [ + await PlaylistMigration23.from_json( + scope, playlist_number, playlist_data, guild=guild, author=int(user_id) + ) + for user_id, scopedata in playlists.items() + for playlist_number, playlist_data in scopedata.items() + ] + else: + return [ + await PlaylistMigration23.from_json( + scope, + playlist_number, + playlist_data, + guild=int(guild_id), + author=int(playlist_data.get("author", 0)), + ) + for guild_id, scopedata in playlists.items() + for playlist_number, playlist_data in scopedata.items() + ] + + class Playlist: """A single playlist.""" @@ -115,14 +266,16 @@ class Playlist: playlist_id: int, name: str, playlist_url: Optional[str] = None, - tracks: Optional[List[dict]] = 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.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 ) @@ -132,7 +285,14 @@ class Playlist: self.tracks = tracks or [] self.tracks_obj = [lavalink.Track(data=track) for track in self.tracks] - async def edit(self, data: dict): + 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 @@ -146,10 +306,23 @@ class Playlist: for item in list(data.keys()): setattr(self, item, data[item]) + await self.save() + return self - await _config.custom(*self.config_scope, str(self.id)).set(self.to_json()) + async def save(self): + """Saves a Playlist.""" + scope, scope_id = self.config_scope + database.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) -> dict: + def to_json(self) -> MutableMapping: """Transform the object to a dict. Returns ------- @@ -158,7 +331,7 @@ class Playlist: """ data = dict( id=self.id, - author=self.author, + author=self.author_id, guild=self.guild_id, name=self.name, playlist_url=self.url, @@ -168,7 +341,9 @@ class Playlist: return data @classmethod - async def from_json(cls, bot: Red, scope: str, playlist_number: int, data: dict, **kwargs): + async def from_json( + cls, bot: Red, scope: str, playlist_number: int, data: PlaylistFetchResult, **kwargs + ) -> "Playlist": """Get a Playlist object from the provided information. Parameters ---------- @@ -197,12 +372,12 @@ class Playlist: `MissingAuthor` Trying to access the User scope without an user id. """ - guild = data.get("guild") or kwargs.get("guild") - author = data.get("author") - playlist_id = data.get("id") or playlist_number - name = data.get("name", "Unnamed") - playlist_url = data.get("playlist_url", None) - tracks = data.get("tracks", []) + 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, @@ -252,13 +427,13 @@ async def get_playlist( `MissingAuthor` Trying to access the User scope without an user id. """ - playlist_data = await _config.custom( - *_prepare_config_scope(scope, author, guild), str(playlist_number) - ).all() - if not playlist_data["id"]: + scope_standard, scope_id = _prepare_config_scope(scope, author, guild) + playlist_data = database.fetch(scope_standard, playlist_number, scope_id) + + if not (playlist_data and playlist_data.playlist_id): raise RuntimeError(f"That playlist does not exist for the following scope: {scope}") return await Playlist.from_json( - bot, scope, playlist_number, playlist_data, guild=guild, author=author + bot, scope_standard, playlist_number, playlist_data, guild=guild, author=author ) @@ -296,23 +471,65 @@ async def get_all_playlist( `MissingAuthor` Trying to access the User scope without an user id. """ - playlists = await _config.custom(*_prepare_config_scope(scope, author, guild)).all() + scope_standard, scope_id = _prepare_config_scope(scope, author, guild) + if specified_user: user_id = getattr(author, "id", author) - return [ - await Playlist.from_json( - bot, scope, playlist_number, playlist_data, guild=guild, author=author - ) - for playlist_number, playlist_data in playlists.items() - if user_id == playlist_data.get("author") - ] + playlists = await database.fetch_all(scope_standard, scope_id, author_id=user_id) else: - return [ - await Playlist.from_json( - bot, scope, playlist_number, playlist_data, guild=guild, author=author - ) - for playlist_number, playlist_data in playlists.items() - ] + playlists = await database.fetch_all(scope_standard, scope_id) + return [ + await Playlist.from_json( + bot, scope, playlist.playlist_id, playlist, guild=guild, author=author + ) + for playlist in playlists + ] + + +async def get_all_playlist_converter( + scope: str, + bot: Red, + 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. + 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(scope, author, guild) + playlists = await database.fetch_all_converter( + scope_standard, playlist_name=arg, playlist_id=arg + ) + return [ + await Playlist.from_json( + bot, scope, playlist.playlist_id, playlist, guild=guild, author=author + ) + for playlist in playlists + ] async def create_playlist( @@ -320,12 +537,11 @@ async def create_playlist( scope: str, playlist_name: str, playlist_url: Optional[str] = None, - tracks: Optional[List[dict]] = None, + tracks: Optional[List[MutableMapping]] = None, author: Optional[discord.User] = None, guild: Optional[discord.Guild] = None, ) -> Optional[Playlist]: - """ - Creates a new Playlist. + """Creates a new Playlist. Parameters ---------- @@ -337,7 +553,7 @@ async def create_playlist( The name of the new playlist. playlist_url:str the url of the new playlist. - tracks: List[dict] + tracks: List[MutableMapping] A list of tracks to add to the playlist. author: discord.User The Author of the playlist. @@ -358,12 +574,16 @@ async def create_playlist( """ playlist = Playlist( - ctx.bot, scope, author.id, ctx.message.id, playlist_name, playlist_url, tracks, ctx.guild - ) - - await _config.custom(*_prepare_config_scope(scope, author, guild), str(ctx.message.id)).set( - playlist.to_json() + ctx.bot, + scope, + author.id if author else None, + ctx.message.id, + playlist_name, + playlist_url, + tracks, + guild or ctx.guild, ) + await playlist.save() return playlist @@ -372,8 +592,7 @@ async def reset_playlist( guild: Union[discord.Guild, int] = None, author: Union[discord.abc.User, int] = None, ) -> None: - """ - Wipes all playlists for the specified scope. + """Wipes all playlists for the specified scope. Parameters ---------- @@ -393,7 +612,9 @@ async def reset_playlist( `MissingAuthor` Trying to access the User scope without an user id. """ - await _config.custom(*_prepare_config_scope(scope, author, guild)).clear() + scope, scope_id = _prepare_config_scope(scope, author, guild) + database.drop(scope) + database.create_table(scope) async def delete_playlist( @@ -402,27 +623,27 @@ async def delete_playlist( guild: discord.Guild, author: Union[discord.abc.User, int] = None, ) -> None: + """Deletes the specified playlist. + + Parameters + ---------- + 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. + + 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. """ - Deletes the specified playlist. - - Parameters - ---------- - 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. - - 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. - """ - await _config.custom(*_prepare_config_scope(scope, author, guild), str(playlist_id)).clear() + scope, scope_id = _prepare_config_scope(scope, author, guild) + database.delete(scope, int(playlist_id), scope_id) diff --git a/redbot/cogs/audio/sql_statements.py b/redbot/cogs/audio/sql_statements.py new file mode 100644 index 000000000..c9cd43f1b --- /dev/null +++ b/redbot/cogs/audio/sql_statements.py @@ -0,0 +1,397 @@ +# TODO: https://github.com/Cog-Creators/Red-DiscordBot/pull/3195#issuecomment-567821701 +# Thanks a lot Sinbad! + +__all__ = [ + # PRAGMA Statements + "PRAGMA_SET_temp_store", + "PRAGMA_SET_journal_mode", + "PRAGMA_SET_read_uncommitted", + "PRAGMA_FETCH_user_version", + "PRAGMA_SET_user_version", + # Playlist table statements + "PLAYLIST_CREATE_TABLE", + "PLAYLIST_DELETE", + "PLAYLIST_DELETE_SCOPE", + "PLAYLIST_DELETE_SCHEDULED", + "PLAYLIST_FETCH_ALL", + "PLAYLIST_FETCH_ALL_WITH_FILTER", + "PLAYLIST_FETCH_ALL_CONVERTER", + "PLAYLIST_FETCH", + "PLAYLIST_UPSERT", + "PLAYLIST_CREATE_INDEX", + # YouTube table statements + "YOUTUBE_DROP_TABLE", + "YOUTUBE_CREATE_TABLE", + "YOUTUBE_CREATE_INDEX", + "YOUTUBE_UPSERT", + "YOUTUBE_UPDATE", + "YOUTUBE_QUERY", + "YOUTUBE_DELETE_OLD_ENTRIES", + # Spotify table statements + "SPOTIFY_DROP_TABLE", + "SPOTIFY_CREATE_INDEX", + "SPOTIFY_CREATE_TABLE", + "SPOTIFY_UPSERT", + "SPOTIFY_QUERY", + "SPOTIFY_UPDATE", + "SPOTIFY_DELETE_OLD_ENTRIES", + # Lavalink table statements + "LAVALINK_DROP_TABLE", + "LAVALINK_CREATE_TABLE", + "LAVALINK_CREATE_INDEX", + "LAVALINK_UPSERT", + "LAVALINK_UPDATE", + "LAVALINK_QUERY", + "LAVALINK_QUERY_LAST_FETCHED_RANDOM", + "LAVALINK_DELETE_OLD_ENTRIES", + "LAVALINK_FETCH_ALL_ENTRIES_GLOBAL", +] + +# PRAGMA Statements +PRAGMA_SET_temp_store = """ +PRAGMA temp_store = 2; +""" +PRAGMA_SET_journal_mode = """ +PRAGMA journal_mode = wal; +""" +PRAGMA_SET_read_uncommitted = """ +PRAGMA read_uncommitted = 1; +""" +PRAGMA_FETCH_user_version = """ +pragma user_version; +""" +PRAGMA_SET_user_version = """ +pragma user_version=3; +""" + +# Playlist table statements +PLAYLIST_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS playlists ( + scope_type INTEGER NOT NULL, + playlist_id INTEGER NOT NULL, + playlist_name TEXT NOT NULL, + scope_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + deleted BOOLEAN DEFAULT false, + playlist_url TEXT, + tracks JSON, + PRIMARY KEY (playlist_id, scope_id, scope_type) +); +""" +PLAYLIST_DELETE = """ +UPDATE playlists + SET + deleted = true +WHERE + ( + scope_type = :scope_type + AND playlist_id = :playlist_id + AND scope_id = :scope_id + ) +; +""" +PLAYLIST_DELETE_SCOPE = """ +DELETE +FROM + playlists +WHERE + scope_type = :scope_type ; +""" +PLAYLIST_DELETE_SCHEDULED = """ +DELETE +FROM + playlists +WHERE + deleted = true; +""" +PLAYLIST_FETCH_ALL = """ +SELECT + playlist_id, + playlist_name, + scope_id, + author_id, + playlist_url, + tracks +FROM + playlists +WHERE + scope_type = :scope_type + AND scope_id = :scope_id + AND deleted = false + ; +""" +PLAYLIST_FETCH_ALL_WITH_FILTER = """ +SELECT + playlist_id, + playlist_name, + scope_id, + author_id, + playlist_url, + tracks +FROM + playlists +WHERE + ( + scope_type = :scope_type + AND scope_id = :scope_id + AND author_id = :author_id + AND deleted = false + ) +; +""" +PLAYLIST_FETCH_ALL_CONVERTER = """ +SELECT + playlist_id, + playlist_name, + scope_id, + author_id, + playlist_url, + tracks +FROM + playlists +WHERE + ( + scope_type = :scope_type + AND + ( + playlist_id = :playlist_id + OR + LOWER(playlist_name) LIKE "%" || COALESCE(LOWER(:playlist_name), "") || "%" + ) + AND deleted = false + ) +; +""" +PLAYLIST_FETCH = """ +SELECT + playlist_id, + playlist_name, + scope_id, + author_id, + playlist_url, + tracks +FROM + playlists +WHERE + ( + scope_type = :scope_type + AND playlist_id = :playlist_id + AND scope_id = :scope_id + AND deleted = false + ) +""" +PLAYLIST_UPSERT = """ +INSERT INTO + playlists ( scope_type, playlist_id, playlist_name, scope_id, author_id, playlist_url, tracks ) +VALUES + ( + :scope_type, :playlist_id, :playlist_name, :scope_id, :author_id, :playlist_url, :tracks + ) + ON CONFLICT (scope_type, playlist_id, scope_id) DO + UPDATE + SET + playlist_name = excluded.playlist_name, + playlist_url = excluded.playlist_url, + tracks = excluded.tracks; +""" +PLAYLIST_CREATE_INDEX = """ +CREATE INDEX IF NOT EXISTS name_index ON playlists (scope_type, playlist_id, playlist_name, scope_id); +""" + +# YouTube table statements +YOUTUBE_DROP_TABLE = """ +DROP TABLE IF EXISTS youtube; +""" +YOUTUBE_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS youtube( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_info TEXT, + youtube_url TEXT, + last_updated INTEGER, + last_fetched INTEGER +); +""" +YOUTUBE_CREATE_INDEX = """ +CREATE UNIQUE INDEX IF NOT EXISTS idx_youtube_url +ON youtube (track_info, youtube_url); +""" +YOUTUBE_UPSERT = """INSERT INTO +youtube + ( + track_info, + youtube_url, + last_updated, + last_fetched + ) +VALUES + ( + :track_info, + :track_url, + :last_updated, + :last_fetched + ) +ON CONFLICT + ( + track_info, + youtube_url + ) +DO UPDATE + SET + track_info = excluded.track_info, + last_updated = excluded.last_updated +""" +YOUTUBE_UPDATE = """ +UPDATE youtube +SET last_fetched=:last_fetched +WHERE track_info=:track; +""" +YOUTUBE_QUERY = """ +SELECT youtube_url, last_updated +FROM youtube +WHERE + track_info=:track + AND last_updated > :maxage +; +""" +YOUTUBE_DELETE_OLD_ENTRIES = """ +DELETE FROM youtube +WHERE + last_updated < :maxage; +""" + +# Spotify table statements +SPOTIFY_DROP_TABLE = """ +DROP TABLE IF EXISTS spotify; +""" +SPOTIFY_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS spotify( + id TEXT, + type TEXT, + uri TEXT, + track_name TEXT, + artist_name TEXT, + song_url TEXT, + track_info TEXT, + last_updated INTEGER, + last_fetched INTEGER +); +""" +SPOTIFY_CREATE_INDEX = """ +CREATE UNIQUE INDEX IF NOT EXISTS idx_spotify_uri +ON spotify (id, type, uri); +""" +SPOTIFY_UPSERT = """INSERT INTO +spotify + ( + id, type, uri, track_name, artist_name, + song_url, track_info, last_updated, last_fetched + ) +VALUES + ( + :id, :type, :uri, :track_name, :artist_name, + :song_url, :track_info, :last_updated, :last_fetched + ) +ON CONFLICT + ( + id, + type, + uri + ) +DO UPDATE + SET + track_name = excluded.track_name, + artist_name = excluded.artist_name, + song_url = excluded.song_url, + track_info = excluded.track_info, + last_updated = excluded.last_updated; +""" +SPOTIFY_UPDATE = """ +UPDATE spotify +SET last_fetched=:last_fetched +WHERE uri=:uri; +""" +SPOTIFY_QUERY = """ +SELECT track_info, last_updated +FROM spotify +WHERE + uri=:uri + AND last_updated > :maxage; +""" +SPOTIFY_DELETE_OLD_ENTRIES = """ +DELETE FROM spotify +WHERE + last_updated < :maxage; +""" + +# Lavalink table statements +LAVALINK_DROP_TABLE = """ +DROP TABLE IF EXISTS lavalink ; +""" +LAVALINK_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS lavalink( + query TEXT, + data JSON, + last_updated INTEGER, + last_fetched INTEGER + +); +""" +LAVALINK_CREATE_INDEX = """ +CREATE UNIQUE INDEX IF NOT EXISTS idx_lavalink_query +ON lavalink (query); +""" +LAVALINK_UPSERT = """INSERT INTO +lavalink + ( + query, + data, + last_updated, + last_fetched + ) +VALUES + ( + :query, + :data, + :last_updated, + :last_fetched + ) +ON CONFLICT + ( + query + ) +DO UPDATE + SET + data = excluded.data, + last_updated = excluded.last_updated; +""" +LAVALINK_UPDATE = """ +UPDATE lavalink +SET last_fetched=:last_fetched +WHERE query=:query; +""" +LAVALINK_QUERY = """ +SELECT data, last_updated +FROM lavalink +WHERE + query=:query + AND last_updated > :maxage; +""" +LAVALINK_QUERY_LAST_FETCHED_RANDOM = """ +SELECT data +FROM lavalink +WHERE + last_fetched > :day + AND last_updated > :maxage +ORDER BY RANDOM() +LIMIT 10 +; +""" +LAVALINK_DELETE_OLD_ENTRIES = """ +DELETE FROM lavalink +WHERE + last_updated < :maxage; +""" +LAVALINK_FETCH_ALL_ENTRIES_GLOBAL = """ +SELECT query, data +FROM lavalink +""" diff --git a/redbot/cogs/audio/utils.py b/redbot/cogs/audio/utils.py index b25ec5fe2..fdb2b152a 100644 --- a/redbot/cogs/audio/utils.py +++ b/redbot/cogs/audio/utils.py @@ -1,9 +1,10 @@ import asyncio import contextlib import functools -import os import re import time +from enum import Enum, unique +from typing import MutableMapping, Optional, TYPE_CHECKING from urllib.parse import urlparse import discord @@ -11,13 +12,14 @@ import lavalink from redbot.core import Config, commands from redbot.core.bot import Red +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import bold, box +from discord.utils import escape_markdown as escape -from . import audio_dataclasses -from .converters import _pass_config_to_converters -from .playlists import _pass_config_to_playlist +from .audio_dataclasses import Query __all__ = [ - "pass_config_to_dependencies", + "_pass_config_to_utils", "track_limit", "queue_duration", "draw_time", @@ -26,35 +28,45 @@ __all__ = [ "clear_react", "match_yt_playlist", "remove_react", - "get_description", + "get_track_description", "track_creator", "time_convert", "url_check", "userlimit", "is_allowed", + "track_to_json", "rgetattr", + "humanize_scope", "CacheLevel", + "format_playlist_picker_data", + "get_track_description_unformatted", "Notifier", + "PlaylistScope", ] -_re_time_converter = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])") -re_yt_list_playlist = re.compile( +_RE_TIME_CONVERTER = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])") +_RE_YT_LIST_PLAYLIST = re.compile( r"^(https?://)?(www\.)?(youtube\.com|youtu\.?be)(/playlist\?).*(list=)(.*)(&|$)" ) -_config = None -_bot = None +if TYPE_CHECKING: + _config: Config + _bot: Red +else: + _config = None + _bot = None + +_ = Translator("Audio", __file__) -def pass_config_to_dependencies(config: Config, bot: Red, localtracks_folder: str): - global _bot, _config - _bot = bot - _config = config - _pass_config_to_playlist(config, bot) - _pass_config_to_converters(config, bot) - audio_dataclasses._pass_config_to_dataclasses(config, bot, localtracks_folder) +def _pass_config_to_utils(config: Config, bot: Red) -> None: + global _config, _bot + if _config is None: + _config = config + if _bot is None: + _bot = bot -def track_limit(track, maxlength): +def track_limit(track, maxlength) -> bool: try: length = round(track.length / 1000) except AttributeError: @@ -65,16 +77,33 @@ def track_limit(track, maxlength): return True -async def is_allowed(guild: discord.Guild, query: str): +async def is_allowed(guild: discord.Guild, query: str, query_obj: Query = None) -> bool: + query = query.lower().strip() - whitelist = set(await _config.guild(guild).url_keyword_whitelist()) - if whitelist: - return any(i in query for i in whitelist) - blacklist = set(await _config.guild(guild).url_keyword_blacklist()) - return not any(i in query for i in blacklist) + if query_obj is not None: + query = query_obj.lavalink_query.replace("ytsearch:", "youtubesearch").replace( + "scsearch:", "soundcloudsearch" + ) + global_whitelist = set(await _config.url_keyword_whitelist()) + global_whitelist = [i.lower() for i in global_whitelist] + if global_whitelist: + return any(i in query for i in global_whitelist) + global_blacklist = set(await _config.url_keyword_blacklist()) + global_blacklist = [i.lower() for i in global_blacklist] + if any(i in query for i in global_blacklist): + return False + if guild is not None: + whitelist = set(await _config.guild(guild).url_keyword_whitelist()) + whitelist = [i.lower() for i in whitelist] + if whitelist: + return any(i in query for i in whitelist) + blacklist = set(await _config.guild(guild).url_keyword_blacklist()) + blacklist = [i.lower() for i in blacklist] + return not any(i in query for i in blacklist) + return True -async def queue_duration(ctx): +async def queue_duration(ctx) -> int: player = lavalink.get_player(ctx.guild.id) duration = [] for i in range(len(player.queue)): @@ -94,7 +123,7 @@ async def queue_duration(ctx): return queue_total_duration -async def draw_time(ctx): +async def draw_time(ctx) -> str: player = lavalink.get_player(ctx.guild.id) paused = player.paused pos = player.position @@ -115,7 +144,7 @@ async def draw_time(ctx): return msg -def dynamic_time(seconds): +def dynamic_time(seconds) -> str: m, s = divmod(seconds, 60) h, m = divmod(m, 60) d, h = divmod(h, 24) @@ -133,7 +162,19 @@ def dynamic_time(seconds): return msg.format(d, h, m, s) -def match_url(url): +def format_playlist_picker_data(pid, pname, ptracks, pauthor, scope) -> str: + author = _bot.get_user(pauthor) or pauthor or _("Unknown") + line = _( + " - Name: <{pname}>\n" + " - Scope: < {scope} >\n" + " - ID: < {pid} >\n" + " - Tracks: < {ptracks} >\n" + " - Author: < {author} >\n\n" + ).format(pname=pname, scope=humanize_scope(scope), pid=pid, ptracks=ptracks, author=author) + return box(line, lang="md") + + +def match_url(url) -> bool: try: query_url = urlparse(url) return all([query_url.scheme, query_url.netloc, query_url.path]) @@ -141,18 +182,18 @@ def match_url(url): return False -def match_yt_playlist(url): - if re_yt_list_playlist.match(url): +def match_yt_playlist(url) -> bool: + if _RE_YT_LIST_PLAYLIST.match(url): return True return False -async def remove_react(message, react_emoji, react_user): +async def remove_react(message, react_emoji, react_user) -> None: with contextlib.suppress(discord.HTTPException): await message.remove_reaction(react_emoji, react_user) -async def clear_react(bot: Red, message: discord.Message, emoji: dict = None): +async def clear_react(bot: Red, message: discord.Message, emoji: MutableMapping = None) -> None: try: await message.clear_reactions() except discord.Forbidden: @@ -166,29 +207,50 @@ async def clear_react(bot: Red, message: discord.Message, emoji: dict = None): return -async def get_description(track): - if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): - local_track = audio_dataclasses.LocalPath(track.uri) - if track.title != "Unknown title": - return "**{} - {}**\n{}".format( - track.author, track.title, local_track.to_string_hidden() - ) +def get_track_description(track) -> Optional[str]: + if track and getattr(track, "uri", None): + query = Query.process_input(track.uri) + if query.is_local: + if track.title != "Unknown title": + return f'**{escape(f"{track.author} - {track.title}")}**' + escape( + f"\n{query.to_string_user()} " + ) + else: + return escape(query.to_string_user()) else: - return local_track.to_string_hidden() - else: - return "**[{}]({})**".format(track.title, track.uri) + return f'**{escape(f"[{track.title}]({track.uri}) ")}**' + elif hasattr(track, "to_string_user") and track.is_local: + return escape(track.to_string_user() + " ") -def track_creator(player, position=None, other_track=None): +def get_track_description_unformatted(track) -> Optional[str]: + if track and hasattr(track, "uri"): + query = Query.process_input(track.uri) + if query.is_local: + if track.title != "Unknown title": + return escape(f"{track.author} - {track.title}") + else: + return escape(query.to_string_user()) + else: + return escape(f"{track.title}") + elif hasattr(track, "to_string_user") and track.is_local: + return escape(track.to_string_user() + " ") + + +def track_creator(player, position=None, other_track=None) -> MutableMapping: if position == "np": queued_track = player.current elif position is None: queued_track = other_track else: queued_track = player.queue[position] - track_keys = queued_track._info.keys() - track_values = queued_track._info.values() - track_id = queued_track.track_identifier + return track_to_json(queued_track) + + +def track_to_json(track: lavalink.Track) -> MutableMapping: + track_keys = track._info.keys() + track_values = track._info.values() + track_id = track.track_identifier track_info = {} for k, v in zip(track_keys, track_values): track_info[k] = v @@ -200,8 +262,8 @@ def track_creator(player, position=None, other_track=None): return track_obj -def time_convert(length): - match = re.compile(_re_time_converter).match(length) +def time_convert(length) -> int: + match = _RE_TIME_CONVERTER.match(length) if match is not None: hr = int(match.group(1)) if match.group(1) else 0 mn = int(match.group(2)) if match.group(2) else 0 @@ -215,7 +277,7 @@ def time_convert(length): return 0 -def url_check(url): +def url_check(url) -> bool: valid_tld = [ "youtube.com", "youtu.be", @@ -235,7 +297,7 @@ def url_check(url): return True if url_domain in valid_tld else False -def userlimit(channel): +def userlimit(channel) -> bool: if channel.user_limit == 0 or channel.user_limit > len(channel.members) + 1: return False return True @@ -386,7 +448,9 @@ class CacheLevel: class Notifier: - def __init__(self, ctx: commands.Context, message: discord.Message, updates: dict, **kwargs): + def __init__( + self, ctx: commands.Context, message: discord.Message, updates: MutableMapping, **kwargs + ): self.context = ctx self.message = message self.updates = updates @@ -402,8 +466,8 @@ class Notifier: seconds_key: str = None, seconds: str = None, ): - """ - This updates an existing message. + """This updates an existing message. + Based on the message found in :variable:`Notifier.updates` as per the `key` param """ if self.last_msg_time + self.cooldown > time.time() and not current == total: @@ -435,3 +499,27 @@ class Notifier: self.last_msg_time = time.time() except discord.errors.NotFound: pass + + +@unique +class PlaylistScope(Enum): + GLOBAL = "GLOBALPLAYLIST" + GUILD = "GUILDPLAYLIST" + USER = "USERPLAYLIST" + + def __str__(self): + return "{0}".format(self.value) + + @staticmethod + def list(): + return list(map(lambda c: c.value, PlaylistScope)) + + +def humanize_scope(scope, ctx=None, the=None): + + if scope == PlaylistScope.GLOBAL.value: + return _("the ") if the else "" + _("Global") + elif scope == PlaylistScope.GUILD.value: + return ctx.name if ctx else _("the ") if the else "" + _("Server") + elif scope == PlaylistScope.USER.value: + return str(ctx) if ctx else _("the ") if the else "" + _("User") diff --git a/redbot/cogs/permissions/permissions.py b/redbot/cogs/permissions/permissions.py index c36c179af..c1633684b 100644 --- a/redbot/cogs/permissions/permissions.py +++ b/redbot/cogs/permissions/permissions.py @@ -678,7 +678,7 @@ class Permissions(commands.Cog): @staticmethod def _get_updated_schema( - old_config: _OldConfigSchema + old_config: _OldConfigSchema, ) -> Tuple[_NewConfigSchema, _NewConfigSchema]: # Prior to 1.0.0, the schema was in this form for both global # and guild-based rules: diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 893723500..81b0abb24 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -319,7 +319,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d ---------- location : `discord.abc.Messageable` Location to check embed color for. - + Returns ------- discord.Color diff --git a/redbot/core/drivers/postgres/postgres.py b/redbot/core/drivers/postgres/postgres.py index f46b64298..46af7b12f 100644 --- a/redbot/core/drivers/postgres/postgres.py +++ b/redbot/core/drivers/postgres/postgres.py @@ -22,7 +22,7 @@ DROP_DDL_SCRIPT_PATH = _PKG_PATH / "drop_ddl.sql" def encode_identifier_data( - id_data: IdentifierData + id_data: IdentifierData, ) -> Tuple[str, str, str, List[str], List[str], int, bool]: return ( id_data.cog_name, diff --git a/setup.cfg b/setup.cfg index 2ecf6d75a..5c39e5738 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,6 @@ install_requires = Click==7.0 colorama==0.4.1 contextlib2==0.5.5 - databases[sqlite]==0.2.5 discord.py==1.2.5 distro==1.4.0; sys_platform == "linux" fuzzywuzzy==0.17.0 diff --git a/tools/primary_deps.ini b/tools/primary_deps.ini index bcdd9c126..7ef06bba4 100644 --- a/tools/primary_deps.ini +++ b/tools/primary_deps.ini @@ -14,7 +14,6 @@ install_requires = babel click colorama - databases[sqlite] discord.py distro; sys_platform == "linux" fuzzywuzzy