From 36f494ba635c8db483f2d0a8dfdeed75ecd483a1 Mon Sep 17 00:00:00 2001 From: Draper <27962761+Drapersniper@users.noreply.github.com> Date: Fri, 11 Oct 2019 03:09:01 +0100 Subject: [PATCH] [Audio] One PR to rule them all, One PR to find them, One PR to bring them all, and in the darkness bind them (all-in-one pr) (#2904) * More changes Signed-off-by: Guy * Fixed auto play defaulting to playlist Signed-off-by: Guy * Localtrack fix Signed-off-by: Guy * Updated deps .. since for some reason aiosqlite is not being auto installed for everyone Signed-off-by: Guy * Yupo Signed-off-by: Guy * Fixed a crash in [p]now Signed-off-by: Guy * Fixed crash on playlist save Signed-off-by: Guy * Debugging Commit Signed-off-by: Guy * Yet more prints Signed-off-by: Guy * Even more spammy debug Signed-off-by: Guy * Debugging commit + NEw Dispatches Signed-off-by: Guy * Debugging commit + NEw Dispatches Signed-off-by: Guy * Fixed localpath checks Signed-off-by: Guy * more fixes for Localpaths Signed-off-by: Guy * Spelling mistake on method Signed-off-by: Guy * Fixed Crash on event handler Signed-off-by: Guy * Fixed Crash on local search Signed-off-by: Guy * Reduced fuzzy match percentage threshold for local tracks to account for nested folders Signed-off-by: Guy * Fixed a crash on queue end Signed-off-by: Guy * Sigh ... Removed a duplicate dispatch Signed-off-by: Guy * Sigh i removed this before ... Signed-off-by: Guy * Reorder dispatch signatures so all 3 new dispatch have matching signature Signed-off-by: Guy * Formatting Signed-off-by: Guy * Edited Error Event to support localtracks Signed-off-by: Guy * Fix a Crash on track crash :awesome: Signed-off-by: Guy * Yikes soo much spam Signed-off-by: Guy * Remove spam and improve existance check Signed-off-by: Guy * Repeat and Auto-play are mutually exclusive now Signed-off-by: Guy * DEBUGS for Preda Signed-off-by: Guy * Vimeo tracks can be from both these domains "vimeo.com", "beam.pro" Signed-off-by: Guy * I mean Mixer can be from those 2 domains .... Signed-off-by: Guy * Fixed `search sc` command Signed-off-by: Guy * Run everything though lints. rename localtracks module to dataclasses Clear lock on errors Signed-off-by: Draper * Try to speed up long playlist loading Signed-off-by: Draper * Im an idiot Signed-off-by: Draper * Im an idiot Signed-off-by: Draper * Added logging for writes Signed-off-by: Draper * Fix crash on cog reload Signed-off-by: Draper * Fix for runtimewarning ? Signed-off-by: Draper * Fix for Local Track cache Signed-off-by: Draper * Remove broken tracks from queue on exception Theoretically do not auto play if track stop reason is Stopped or cleanup Signed-off-by: Draper * Previous commit was a fluke ... ignore it Signed-off-by: Draper * Change from cleanup to Replaced Signed-off-by: Draper * Fixed AttributeError: 'Track' object has no attribute 'info'. [p]skip will only work for autoplay is there a track being played. Fixed Console spam if query saving failed in the background while reloading bot. Autoplay now respect [p]stop command Signed-off-by: Guy * Black formatting Fix Issue with auto play working when there is songs in the queue Stop notifying queue ended if autoplay is on Signed-off-by: Guy * Fixed a crash on track load timeout Signed-off-by: Guy * [p]playlist start will now show the playlist name in embed body Improved Logic for handling broken tracks when repeat is on. Signed-off-by: Draper * Enqueue tracks as soon as we have the youtube URL .... This basically changes how spotify urls are handled Need to test saving spotify playlist Need to test loading a spotify playlist from file Need to test enqueuing a spotify playlist Signed-off-by: Draper * Updated a track whrn enqueuing spotify playlist Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Debug Signed-off-by: Draper * Revert spotify_enqueue changes Signed-off-by: Draper * Revert spotify_enqueue changes Signed-off-by: Draper * Allow to set Lavalink jar version from Environment vars Signed-off-by: Draper * Allow to set Lavalink jar version from Environment vars Signed-off-by: Draper * Fix for a crash on Equalizer, Merge Spotify_enqueue changes and revert manager changes Signed-off-by: Draper * Break playlist enqueue after 10 consecutive failures Signed-off-by: Draper * Auto DC, is not compatible with Auto Play Signed-off-by: Draper * Make notifier aware of guild its being called for Signed-off-by: Draper * Type checking Signed-off-by: Draper * Remove lock from 2 exits that i didn't before Signed-off-by: Draper * Fixed TypeError: spotify_enqueue() got an unexpected keyword argument 'notify' Signed-off-by: Guy * Reorder toggles to alphabetical order Signed-off-by: Guy * Update Query to handle spotify URIs Signed-off-by: Guy * update database Signed-off-by: Guy * Dont say tracks enqued on invalid link Make autop lay a mod only setting Signed-off-by: Draper * Dont say tracks enqued on invalid spotify link Signed-off-by: Draper * Set default age to 365 days Signed-off-by: Draper * Allow Audio mods to set auto play playlists. Save playlists songs to cache when migrating Signed-off-by: Guy * Black formatting Signed-off-by: Guy * [p]eq cooldown is not triggered is player check fails (i.e if nothing is currently playing) Adding and removing reaction is no longer a blocking action Signed-off-by: Guy * changelog for non blocking reaction handles Signed-off-by: Guy * Show auto dc and auto play settings by default Signed-off-by: Guy * lint is being a bitch Signed-off-by: Guy * lint changes Signed-off-by: Draper * stop caching local tracks Signed-off-by: Draper * List of Lavalink.Tracks natively added to Playlist Objects Signed-off-by: Draper * Fix UX changes and should fix autoplay Signed-off-by: Draper * Fixed Skip x number of tracks Signed-off-by: Draper * Lint changes Signed-off-by: Draper * Remvoe dead code Signed-off-by: Draper * Update playlist embed formatting to reflect Preda's suggestions Signed-off-by: Draper * Update change logs Signed-off-by: Draper * Add `async with ctx.typing():` to queue and to local folder Signed-off-by: Draper * Stop queuing now when queue is empty with [p]queue Signed-off-by: Draper * fix ctx.typing() Signed-off-by: Draper * fix ctx.typing() Signed-off-by: Draper * Part 1 Signed-off-by: Draper * Dont check local track author and name if title is Unknown Signed-off-by: Guy * Makes auto play more random Signed-off-by: Guy * Fixes local play Fixed missing format Signed-off-by: Guy * Query.process_input accept lavalink.Track objects Signed-off-by: Draper * docstrings Signed-off-by: Draper * Add TODO for timestamp support Signed-off-by: Draper * Improve autoplay from cache logic (possibly slightly slower but more efficient overall) Signed-off-by: Draper * Add My Lavalink PR as a dependency Remember to remove this .... The PR will bump it to 0.3.2 Signed-off-by: Draper * Add My Lavalink PR as a dependency Remember to remove this .... The PR will bump it to 0.3.2 Signed-off-by: Draper * Add My Lavalink PR as a dependency Remember to remove this .... The PR will bump it to 0.3.2 Signed-off-by: Draper * Compile all regex at runtime Signed-off-by: Draper * Fixes local play Fixed missing format Signed-off-by: Guy * Revert Dep error Signed-off-by: Guy * black Signed-off-by: Guy * Fixed attribute error Signed-off-by: Guy * add `self.bot.dispatch("audio_disconnect", ctx.guild)` dispatch when the player is disconnected Signed-off-by: Guy * Removed shuffle lock on skip Signed-off-by: Guy * Better logic for auto seek (timestamps) Signed-off-by: Guy * Better logic for auto seek (timestamps) Signed-off-by: Guy * Fixes timestamps on spotify tracks Signed-off-by: Guy * Add ctx typing to playlist enqueue Signed-off-by: Guy * Fix Deps Signed-off-by: Guy * Black formatting + Using new lavalink methods for shuffling Signed-off-by: Guy * remove ctx.typing from playlist start Signed-off-by: Guy * Fixes typerror when enqueuing spotify playlists Signed-off-by: Guy * Fix keyerror Signed-off-by: Guy * black formatting, + embed for [p]audioset cache as I forgot it before Signed-off-by: Guy * Fix Error on playlist upload Signed-off-by: Guy * Fix Text help for bump Signed-off-by: Guy * Allow track bumping while shuffle is on Signed-off-by: Guy * Edit bump embed to be consistent with other embed Hyperlink tracks and removed dynamic title Signed-off-by: Guy * Black Signed-off-by: Guy * Errors not printing fix? Signed-off-by: Guy * Errors not printing fix? Signed-off-by: Guy * Track enqueued footer now shows correct track position when shuffle is on Signed-off-by: Guy * Update changelogs Signed-off-by: Guy * Fix is_owner check in audioset settings Signed-off-by: Guy * Changelogs Signed-off-by: Guy * Dont store searches with no results in cache, fix malformated playlist to cache upon settings migration Signed-off-by: Guy * _clear_lock_on_error > Needs to be reviewed to see if it has been done correctly Signed-off-by: Guy * _clear_lock_on_error > Needs to be reviewed to see if it has been done correctly Signed-off-by: Guy * Fix Query search so that it works with absolute paths for localtracks Signed-off-by: Guy * Extra error if lavalink is set to external and the query is a localtrack and nothing is found Signed-off-by: Guy * Black Signed-off-by: Guy * More detailed error message Signed-off-by: Guy * [p]seek and [p]skip can be used by user if they are the song requester while DJ mode is enabled, if votes are disabled. , [p]queue shuffle can be used to shuffle the queue manually. and [p]queue clean self can be used to remove all songs you requested from the queue. Signed-off-by: Guy * black Signed-off-by: Guy * All the fixes + a `should_auto_play` dispatch for the tech savy peeps Signed-off-by: Guy * Spellchecker + Pythonic changes Signed-off-by: Guy * NO spam for logs Signed-off-by: Guy * Pass Current voice channel to `red_audio_should_auto_play` dispatch Signed-off-by: Guy * Black Signed-off-by: Guy * playlist upload also updates cache in the background Signed-off-by: Guy * playlist upload also updates cache in the background Signed-off-by: Guy * Add scope to playlist picker Signed-off-by: Guy * Delete Playlist picker message once something is selected Signed-off-by: Guy * OCD Fix Signed-off-by: Guy * Facepalm Signed-off-by: Guy * Fix a Potential crash Signed-off-by: Guy * Update my stupidity Signed-off-by: Guy * Auto Pause + Skip tracks already in playlist upon playlist append + a command to remove duplicated tracks from playlist Signed-off-by: Guy * Fix DJ mode when Role is deleted - Credits go to Neuro Assassin#4779 Fix an issue where auto play MAY not trigger Signed-off-by: Guy * Change log to Neuro Assassin#4779 fix Signed-off-by: Guy * Black Signed-off-by: Guy * Dont auto pause manual pauses Signed-off-by: Guy * Adds `[p]autoplay` that can be run by mods or higher Signed-off-by: Guy * :facepalm: Signed-off-by: Guy * 2x :facepalm: Signed-off-by: Guy * Fixed wrong import Signed-off-by: Guy * Added Autoplay notify Signed-off-by: Guy * Added Autoplay notify Signed-off-by: Guy * Black Signed-off-by: Guy * Store Track object as prev song instead of URI Signed-off-by: Guy * Black why do u hate me Signed-off-by: Guy * Fix command name Signed-off-by: Guy * Fix Autoplay notify Signed-off-by: Guy * Fix missing await and TypeError, Thanks Flame Signed-off-by: Guy * Add a list of tracks to show as a menu Signed-off-by: Guy * adds the `[p]genre` command which uses the Spotify and Youtube API Signed-off-by: Guy * Enqueue Playlists from genre command Signed-off-by: Guy * Pretify `[p]genre` Signed-off-by: Guy * Fix a Typo and correct jukebox charge order Signed-off-by: Guy * Add genre command to error handling Signed-off-by: Guy * Type checking Signed-off-by: Guy * Update naming scheme for `[p]genre` Signed-off-by: Guy * Black why do you hate me Signed-off-by: Guy * Fixed `[p]local start` Playlist picker auto selects if theres just 1 playlist found `[p]queue cleanself` added Signed-off-by: Guy * *sigh* back compatibility with old localtrack paths Signed-off-by: Guy * *sigh* back compatibility with old localtrack paths, even more Signed-off-by: Guy * *sigh* back compatibility with old localtrack paths Even more Signed-off-by: Guy * Fixes localtracks in playlist info command Signed-off-by: Guy * Debug Local Strings Signed-off-by: Guy * Debug Local Strings Signed-off-by: Guy * Fixes `[p]playlist info` for local tracks + fixed error in `[p]remove` Signed-off-by: Guy * Black Signed-off-by: Guy * Fixes formatting in `[p]playlist info` Signed-off-by: Guy * Fix an issue with User Scope playlists were not being deleted Signed-off-by: Guy * Typechecking Signed-off-by: Guy * Black Signed-off-by: Guy * Fix the logic of `delegate_autoplay` Signed-off-by: Guy * Fix a Crash on Load due to type hinting Signed-off-by: Guy * Fix a Crash on Load due to type hintingBlack + fix order of `red_audio_should_auto_play` Signed-off-by: Guy * Add `red_audio_initialized` dispatch so that ownership of auto play can be maintained after a reload Signed-off-by: Guy * Check if the current owner is loaded before raising an error Signed-off-by: Guy * Fixes the Existence Check in `delegate_autoplay` Signed-off-by: Guy * Turns `own_autoplay` in a property of Audio and improves `delegate_autoplay` Thanks Sinbad! Signed-off-by: Guy * Fix for Localtracks playlists Signed-off-by: Guy * When disconnecting send `Disconnecting...` Fix Stop after a skip Fix UX discrepancy on Playlist IDs Fixed Exception when theres a track error Signed-off-by: Guy * add `on_red_audio_unload` dispatch Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fix a crash on track start where `player.current` can be none? Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Missing new line Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Allow `--author` for playlist to be used to filter playlist for an specific author. Plus a few bugfixes for UX Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Rename `remdupe` to `dedupe` Make global scope always be referenced as Global add missing backwards quotes around the Playlist ID for 1 string Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Towncrier entries for dep changes Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Remove track index when shuffle is on Fix Progress bar for livestreams Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Trigger autoplay on `QUEUE_END` event instead of `TRACK_END` Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Can't reproduce Ians bug but here a safeguard agaisnt it just in case Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fixes 2 Messages that had the wrong formatting Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * standerdize playlist naming scheme Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fix `[p]autoplay` message when Notify is enabled Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * y u h8 me black Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fix an issue with `[p]audioset localpath` where the localtracks folder was incorrect Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Pythonic formatting Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Ugh Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fix a typo Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Silently try to delete messages + fixes error Ian found with `[p]genre` Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * sigh black Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Add humanize_number usage correctly Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Bump RLL to 0.4.0 Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Update changelog entries Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Change `bot.db` to new API's added by #2967 Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Additional reformatting Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Remove PyCharm noise + Fixes a few Pycharm warnings Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Rework `index` parsing for youtube urls Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Addess Aika's review Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fix a potential crash, saves guild ID to playlists to avoid an scheme change in the future Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Add handling for Python installs without sqlite3. Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Address Flame's review Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Fix ma stupidity Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Address Aika's latest review. 1. Update docstring for `[p]playlist rename`. 2. Fix punctuation for playlist matching. 3. `[p]playlist update` now respect playlist management perms 4. Playlist management errors now shows playlist name, id and scope where possible 5. Remove duplicated code and dead code. Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> * Pluralize string Signed-off-by: guyre <27962761+drapersniper@users.noreply.github.com> --- changelog.d/audio/270.feature.rst | 1 + changelog.d/audio/2861.bugfix.1.rst | 1 + changelog.d/audio/2861.bugfix.2.rst | 1 + changelog.d/audio/2861.enhance.1.rst | 1 + changelog.d/audio/2861.enhance.2.rst | 1 + changelog.d/audio/2861.feature.1.rst | 1 + changelog.d/audio/2861.feature.2.rst | 1 + changelog.d/audio/2861.feature.3.rst | 1 + changelog.d/audio/2861.feature.4.rst | 1 + changelog.d/audio/2861.feature.5.rst | 16 + changelog.d/audio/2861.misc.1.rst | 1 + changelog.d/audio/2861.misc.2.rst | 1 + changelog.d/audio/2861.misc.3.rst | 1 + changelog.d/audio/2861.misc.4.rst | 1 + changelog.d/audio/2861.misc.5.rst | 1 + changelog.d/audio/2861.misc.6.rst | 1 + changelog.d/audio/2890.enhance.1.rst | 1 + changelog.d/audio/2890.enhance.2.rst | 1 + changelog.d/audio/2890.misc.1.rst | 1 + changelog.d/audio/2890.misc.2.rst | 1 + changelog.d/audio/2890.misc.3.rst | 1 + changelog.d/audio/2904.bugfix.1.rst | 1 + changelog.d/audio/2904.bugfix.2.rst | 1 + changelog.d/audio/2904.bugfix.3.rst | 1 + changelog.d/audio/2904.dep.1.rst | 1 + changelog.d/audio/2904.dep.2.rst | 1 + changelog.d/audio/2904.enhance.1.rst | 2 + changelog.d/audio/2904.enhance.2.rst | 1 + changelog.d/audio/2904.enhance.3.rst | 1 + changelog.d/audio/2904.enhance.4.rst | 1 + changelog.d/audio/2904.enhance.5.rst | 1 + changelog.d/audio/2904.enhance.6.rst | 1 + changelog.d/audio/2904.enhance.7.rst | 1 + changelog.d/audio/2904.enhance.8.rst | 1 + changelog.d/audio/2904.feature.1.rst | 1 + changelog.d/audio/2904.feature.10.rst | 1 + changelog.d/audio/2904.feature.2.rst | 1 + changelog.d/audio/2904.feature.3.rst | 1 + changelog.d/audio/2904.feature.4.rst | 12 + changelog.d/audio/2904.feature.5.rst | 1 + changelog.d/audio/2904.feature.6.rst | 1 + changelog.d/audio/2904.feature.7.rst | 1 + changelog.d/audio/2904.feature.8.rst | 1 + changelog.d/audio/2904.feature.9.rst | 1 + changelog.d/audio/2904.misc.1.rst | 1 + changelog.d/audio/721.feature.rst | 1 + redbot/cogs/audio/apis.py | 1131 +++++ redbot/cogs/audio/audio.py | 5600 ++++++++++++++++++------- redbot/cogs/audio/checks.py | 37 + redbot/cogs/audio/converters.py | 495 +++ redbot/cogs/audio/dataclasses.py | 486 +++ redbot/cogs/audio/equalizer.py | 7 +- redbot/cogs/audio/errors.py | 64 + redbot/cogs/audio/manager.py | 17 +- redbot/cogs/audio/playlists.py | 428 ++ redbot/cogs/audio/utils.py | 425 ++ redbot/core/commands/errors.py | 20 +- redbot/core/events.py | 7 + setup.cfg | 4 +- tools/primary_deps.ini | 2 + 60 files changed, 7297 insertions(+), 1499 deletions(-) create mode 100644 changelog.d/audio/270.feature.rst create mode 100644 changelog.d/audio/2861.bugfix.1.rst create mode 100644 changelog.d/audio/2861.bugfix.2.rst create mode 100644 changelog.d/audio/2861.enhance.1.rst create mode 100644 changelog.d/audio/2861.enhance.2.rst create mode 100644 changelog.d/audio/2861.feature.1.rst create mode 100644 changelog.d/audio/2861.feature.2.rst create mode 100644 changelog.d/audio/2861.feature.3.rst create mode 100644 changelog.d/audio/2861.feature.4.rst create mode 100644 changelog.d/audio/2861.feature.5.rst create mode 100644 changelog.d/audio/2861.misc.1.rst create mode 100644 changelog.d/audio/2861.misc.2.rst create mode 100644 changelog.d/audio/2861.misc.3.rst create mode 100644 changelog.d/audio/2861.misc.4.rst create mode 100644 changelog.d/audio/2861.misc.5.rst create mode 100644 changelog.d/audio/2861.misc.6.rst create mode 100644 changelog.d/audio/2890.enhance.1.rst create mode 100644 changelog.d/audio/2890.enhance.2.rst create mode 100644 changelog.d/audio/2890.misc.1.rst create mode 100644 changelog.d/audio/2890.misc.2.rst create mode 100644 changelog.d/audio/2890.misc.3.rst create mode 100644 changelog.d/audio/2904.bugfix.1.rst create mode 100644 changelog.d/audio/2904.bugfix.2.rst create mode 100644 changelog.d/audio/2904.bugfix.3.rst create mode 100644 changelog.d/audio/2904.dep.1.rst create mode 100644 changelog.d/audio/2904.dep.2.rst create mode 100644 changelog.d/audio/2904.enhance.1.rst create mode 100644 changelog.d/audio/2904.enhance.2.rst create mode 100644 changelog.d/audio/2904.enhance.3.rst create mode 100644 changelog.d/audio/2904.enhance.4.rst create mode 100644 changelog.d/audio/2904.enhance.5.rst create mode 100644 changelog.d/audio/2904.enhance.6.rst create mode 100644 changelog.d/audio/2904.enhance.7.rst create mode 100644 changelog.d/audio/2904.enhance.8.rst create mode 100644 changelog.d/audio/2904.feature.1.rst create mode 100644 changelog.d/audio/2904.feature.10.rst create mode 100644 changelog.d/audio/2904.feature.2.rst create mode 100644 changelog.d/audio/2904.feature.3.rst create mode 100644 changelog.d/audio/2904.feature.4.rst create mode 100644 changelog.d/audio/2904.feature.5.rst create mode 100644 changelog.d/audio/2904.feature.6.rst create mode 100644 changelog.d/audio/2904.feature.7.rst create mode 100644 changelog.d/audio/2904.feature.8.rst create mode 100644 changelog.d/audio/2904.feature.9.rst create mode 100644 changelog.d/audio/2904.misc.1.rst create mode 100644 changelog.d/audio/721.feature.rst create mode 100644 redbot/cogs/audio/apis.py create mode 100644 redbot/cogs/audio/checks.py create mode 100644 redbot/cogs/audio/converters.py create mode 100644 redbot/cogs/audio/dataclasses.py create mode 100644 redbot/cogs/audio/playlists.py create mode 100644 redbot/cogs/audio/utils.py diff --git a/changelog.d/audio/270.feature.rst b/changelog.d/audio/270.feature.rst new file mode 100644 index 000000000..8ab87d40f --- /dev/null +++ b/changelog.d/audio/270.feature.rst @@ -0,0 +1 @@ +Added support for nested folders in the localtrack folder. \ No newline at end of file diff --git a/changelog.d/audio/2861.bugfix.1.rst b/changelog.d/audio/2861.bugfix.1.rst new file mode 100644 index 000000000..4fe209e39 --- /dev/null +++ b/changelog.d/audio/2861.bugfix.1.rst @@ -0,0 +1 @@ +``[p]playlist remove`` now removes the playlist url if the playlist was created through ``[p]playlist save``. diff --git a/changelog.d/audio/2861.bugfix.2.rst b/changelog.d/audio/2861.bugfix.2.rst new file mode 100644 index 000000000..5a686e790 --- /dev/null +++ b/changelog.d/audio/2861.bugfix.2.rst @@ -0,0 +1 @@ +Users are no longer able to accidentally overwrite existing playlist if a new one with the same name is created/rename. diff --git a/changelog.d/audio/2861.enhance.1.rst b/changelog.d/audio/2861.enhance.1.rst new file mode 100644 index 000000000..22e392132 --- /dev/null +++ b/changelog.d/audio/2861.enhance.1.rst @@ -0,0 +1 @@ +``[p]playlist upload`` will now load playlists generated via ``[p]playlist download`` much faster if the playlist use the new scheme. diff --git a/changelog.d/audio/2861.enhance.2.rst b/changelog.d/audio/2861.enhance.2.rst new file mode 100644 index 000000000..bd0810d96 --- /dev/null +++ b/changelog.d/audio/2861.enhance.2.rst @@ -0,0 +1 @@ +``[p]playlist`` commands now can be used by everyone regardless of DJ settings, however it will respect DJ settings when creating/modifying playlist in the server scope. diff --git a/changelog.d/audio/2861.feature.1.rst b/changelog.d/audio/2861.feature.1.rst new file mode 100644 index 000000000..b26f4049e --- /dev/null +++ b/changelog.d/audio/2861.feature.1.rst @@ -0,0 +1 @@ +Playlist are now stored in a dataclass and new APIs were added to interact with them see :module:`redbot.cogs.audio.playlist` for more details. diff --git a/changelog.d/audio/2861.feature.2.rst b/changelog.d/audio/2861.feature.2.rst new file mode 100644 index 000000000..768f22b13 --- /dev/null +++ b/changelog.d/audio/2861.feature.2.rst @@ -0,0 +1 @@ +All Playlist commands now accept optional arguments, use ``[p]help playlist `` for more details. diff --git a/changelog.d/audio/2861.feature.3.rst b/changelog.d/audio/2861.feature.3.rst new file mode 100644 index 000000000..d0089f1f2 --- /dev/null +++ b/changelog.d/audio/2861.feature.3.rst @@ -0,0 +1 @@ +``[p]playlist rename`` will now allow users to rename existing playlists. diff --git a/changelog.d/audio/2861.feature.4.rst b/changelog.d/audio/2861.feature.4.rst new file mode 100644 index 000000000..1e79bb342 --- /dev/null +++ b/changelog.d/audio/2861.feature.4.rst @@ -0,0 +1 @@ +``[p]playlist update`` will allow users to update non custom Playlists to the latest available tracks. diff --git a/changelog.d/audio/2861.feature.5.rst b/changelog.d/audio/2861.feature.5.rst new file mode 100644 index 000000000..0ef6b9d1b --- /dev/null +++ b/changelog.d/audio/2861.feature.5.rst @@ -0,0 +1,16 @@ +There are 3 different scopes of playlist now, to define them use the ``--scope`` argument. + + ``Global Playlist`` + + - These playlists will be available in all servers the bot is in. + - These can be managed by the Bot Owner only. + + ``Server Playlist`` + + - These playlists will only be available in the server they were created in. + - These can be managed by the Bot Owner, Guild Owner, Mods, Admins, DJs and creator (if DJ role is disabled). + + ``User Playlist`` + + - These playlists will be available in all servers both the bot and the creator are in. + - These can be managed by the Bot Owner and Creator only. \ No newline at end of file diff --git a/changelog.d/audio/2861.misc.1.rst b/changelog.d/audio/2861.misc.1.rst new file mode 100644 index 000000000..f0956ddb6 --- /dev/null +++ b/changelog.d/audio/2861.misc.1.rst @@ -0,0 +1 @@ +:class:`ArgParserFailure` was added to :class:`redbot.core.commands.errors` to allow user friendly errors from ArgParser Converters. diff --git a/changelog.d/audio/2861.misc.2.rst b/changelog.d/audio/2861.misc.2.rst new file mode 100644 index 000000000..f06aadea7 --- /dev/null +++ b/changelog.d/audio/2861.misc.2.rst @@ -0,0 +1 @@ +Automatic handling of :class:`redbot.core.commands.errors.ArgParserFailure` on :meth:`Cog.on_command_error`. \ No newline at end of file diff --git a/changelog.d/audio/2861.misc.3.rst b/changelog.d/audio/2861.misc.3.rst new file mode 100644 index 000000000..fe1653d55 --- /dev/null +++ b/changelog.d/audio/2861.misc.3.rst @@ -0,0 +1 @@ +Playlists are now stored in 3 different scopes ``GLOBALPLAYLIST``, ``GUILDPLAYLIST``, ``USERPLAYLIST``. diff --git a/changelog.d/audio/2861.misc.4.rst b/changelog.d/audio/2861.misc.4.rst new file mode 100644 index 000000000..563d7245b --- /dev/null +++ b/changelog.d/audio/2861.misc.4.rst @@ -0,0 +1 @@ +:class:`ScopeParser` is used to parse optional arguments for all playlist commands. \ No newline at end of file diff --git a/changelog.d/audio/2861.misc.5.rst b/changelog.d/audio/2861.misc.5.rst new file mode 100644 index 000000000..73ff90b55 --- /dev/null +++ b/changelog.d/audio/2861.misc.5.rst @@ -0,0 +1 @@ +:method:`Audio.can_manage_playlist` is now used to check users permissions when managing playlists. diff --git a/changelog.d/audio/2861.misc.6.rst b/changelog.d/audio/2861.misc.6.rst new file mode 100644 index 000000000..5469346e1 --- /dev/null +++ b/changelog.d/audio/2861.misc.6.rst @@ -0,0 +1 @@ +:meth:`Audio._migrate_config` will automatically migrate old schema playlist to the new schema. diff --git a/changelog.d/audio/2890.enhance.1.rst b/changelog.d/audio/2890.enhance.1.rst new file mode 100644 index 000000000..55fa674a1 --- /dev/null +++ b/changelog.d/audio/2890.enhance.1.rst @@ -0,0 +1 @@ +Spotify, Youtube Data and Lavalink API calls can be cached to avoid repeated calls in the future, see ``[p]audioset cache``. \ No newline at end of file diff --git a/changelog.d/audio/2890.enhance.2.rst b/changelog.d/audio/2890.enhance.2.rst new file mode 100644 index 000000000..0af2598a9 --- /dev/null +++ b/changelog.d/audio/2890.enhance.2.rst @@ -0,0 +1 @@ +Playlist will now start playing as soon as first track is loaded. diff --git a/changelog.d/audio/2890.misc.1.rst b/changelog.d/audio/2890.misc.1.rst new file mode 100644 index 000000000..2a31df96c --- /dev/null +++ b/changelog.d/audio/2890.misc.1.rst @@ -0,0 +1 @@ +Spotify and Youtube API functions have been moved to :module:`redbot.cogs.audio.api` under :class:`SpotifyAPI` and :class:`YouTubeAPI`. \ No newline at end of file diff --git a/changelog.d/audio/2890.misc.2.rst b/changelog.d/audio/2890.misc.2.rst new file mode 100644 index 000000000..326a1328b --- /dev/null +++ b/changelog.d/audio/2890.misc.2.rst @@ -0,0 +1 @@ +:class:`MusicCache` now handles the Spotify, Youtube Data and Lavalink API calls, this queries the cache first before making API calls. diff --git a/changelog.d/audio/2890.misc.3.rst b/changelog.d/audio/2890.misc.3.rst new file mode 100644 index 000000000..2d45531fd --- /dev/null +++ b/changelog.d/audio/2890.misc.3.rst @@ -0,0 +1 @@ +Due to playlist loading order changes users are unable to load tracks while a playlist is currently loading. \ No newline at end of file diff --git a/changelog.d/audio/2904.bugfix.1.rst b/changelog.d/audio/2904.bugfix.1.rst new file mode 100644 index 000000000..74ec8775f --- /dev/null +++ b/changelog.d/audio/2904.bugfix.1.rst @@ -0,0 +1 @@ +``[p]audioset settings`` no longer shows lavalink JAR version. diff --git a/changelog.d/audio/2904.bugfix.2.rst b/changelog.d/audio/2904.bugfix.2.rst new file mode 100644 index 000000000..20ac7f463 --- /dev/null +++ b/changelog.d/audio/2904.bugfix.2.rst @@ -0,0 +1 @@ +:code:`KeyError: loadType` when trying to play tracks has been fixed. diff --git a/changelog.d/audio/2904.bugfix.3.rst b/changelog.d/audio/2904.bugfix.3.rst new file mode 100644 index 000000000..bbea88e92 --- /dev/null +++ b/changelog.d/audio/2904.bugfix.3.rst @@ -0,0 +1 @@ +``[p]audioset settings`` now uses :code:`ctx.is_owner()` to check if context author is the bot owner. \ No newline at end of file diff --git a/changelog.d/audio/2904.dep.1.rst b/changelog.d/audio/2904.dep.1.rst new file mode 100644 index 000000000..a8b0491e2 --- /dev/null +++ b/changelog.d/audio/2904.dep.1.rst @@ -0,0 +1 @@ +New dependency: ``databases[sqlite]`` . \ No newline at end of file diff --git a/changelog.d/audio/2904.dep.2.rst b/changelog.d/audio/2904.dep.2.rst new file mode 100644 index 000000000..dbf929184 --- /dev/null +++ b/changelog.d/audio/2904.dep.2.rst @@ -0,0 +1 @@ +``Red-Lavalink`` bumped to version 0.4.0. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.1.rst b/changelog.d/audio/2904.enhance.1.rst new file mode 100644 index 000000000..484991fa5 --- /dev/null +++ b/changelog.d/audio/2904.enhance.1.rst @@ -0,0 +1,2 @@ +``[p]audioset localpath`` can set a path anywhere in your machine now. + - Note: This path needs to be visible by :code:`Lavalink.jar`. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.2.rst b/changelog.d/audio/2904.enhance.2.rst new file mode 100644 index 000000000..377bc911f --- /dev/null +++ b/changelog.d/audio/2904.enhance.2.rst @@ -0,0 +1 @@ +``[p]queue`` now works where there are no tracks in the queue (it shows the current track playing). \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.3.rst b/changelog.d/audio/2904.enhance.3.rst new file mode 100644 index 000000000..25e24feef --- /dev/null +++ b/changelog.d/audio/2904.enhance.3.rst @@ -0,0 +1 @@ +``[p]audioset settings`` now reports lavalink lib version. diff --git a/changelog.d/audio/2904.enhance.4.rst b/changelog.d/audio/2904.enhance.4.rst new file mode 100644 index 000000000..05478b075 --- /dev/null +++ b/changelog.d/audio/2904.enhance.4.rst @@ -0,0 +1 @@ +Adding and removing reactions in Audio is no longer a blocking action. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.5.rst b/changelog.d/audio/2904.enhance.5.rst new file mode 100644 index 000000000..03362ff9e --- /dev/null +++ b/changelog.d/audio/2904.enhance.5.rst @@ -0,0 +1 @@ +When shuffle is on queue now shows correct play order. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.6.rst b/changelog.d/audio/2904.enhance.6.rst new file mode 100644 index 000000000..d0d48557c --- /dev/null +++ b/changelog.d/audio/2904.enhance.6.rst @@ -0,0 +1 @@ +``[p]seek`` and ``[p]skip`` can be used by user if they are the song requester while DJ mode is enabled, if votes are disabled. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.7.rst b/changelog.d/audio/2904.enhance.7.rst new file mode 100644 index 000000000..ac882211d --- /dev/null +++ b/changelog.d/audio/2904.enhance.7.rst @@ -0,0 +1 @@ +Adding a playlist and album to a saved playlist skips tracks already in the playlist. \ No newline at end of file diff --git a/changelog.d/audio/2904.enhance.8.rst b/changelog.d/audio/2904.enhance.8.rst new file mode 100644 index 000000000..a24058949 --- /dev/null +++ b/changelog.d/audio/2904.enhance.8.rst @@ -0,0 +1 @@ +Turn off DJ mode if the DJ role is deleted. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.1.rst b/changelog.d/audio/2904.feature.1.rst new file mode 100644 index 000000000..fe7641b1b --- /dev/null +++ b/changelog.d/audio/2904.feature.1.rst @@ -0,0 +1 @@ +``[p]audioset cache`` can be used to set the cache level. **It's off by default**. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.10.rst b/changelog.d/audio/2904.feature.10.rst new file mode 100644 index 000000000..b68931a33 --- /dev/null +++ b/changelog.d/audio/2904.feature.10.rst @@ -0,0 +1 @@ +``[p]genre`` command can be used to play spotify playlist. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.2.rst b/changelog.d/audio/2904.feature.2.rst new file mode 100644 index 000000000..c7a40961d --- /dev/null +++ b/changelog.d/audio/2904.feature.2.rst @@ -0,0 +1 @@ +``[p]audioset cacheage`` can be used to set maximum age of an entry in the cache. **Default is 365 days**. diff --git a/changelog.d/audio/2904.feature.3.rst b/changelog.d/audio/2904.feature.3.rst new file mode 100644 index 000000000..126f416c0 --- /dev/null +++ b/changelog.d/audio/2904.feature.3.rst @@ -0,0 +1 @@ +``[p]audioset autoplay`` can be used to enable auto play once the queue runs out. diff --git a/changelog.d/audio/2904.feature.4.rst b/changelog.d/audio/2904.feature.4.rst new file mode 100644 index 000000000..7a7a20080 --- /dev/null +++ b/changelog.d/audio/2904.feature.4.rst @@ -0,0 +1,12 @@ +New events dispatched by Audio. + + - :code:`on_red_audio_track_start(guild: discord.Guild, track: lavalink.Track, requester: discord.Member)` + - :code:`on_red_audio_track_end(guild: discord.Guild, track: lavalink.Track, requester: discord.Member)` + - :code:`on_red_audio_track_enqueue(guild: discord.Guild, track: lavalink.Track, requester: discord.Member)` + - :code:`on_red_audio_track_auto_play(guild: discord.Guild, track: lavalink.Track, requester: discord.Member)` + - :code:`on_red_audio_queue_end(guild: discord.Guild, track: lavalink.Track, requester: discord.Member)` + - :code:`on_red_audio_audio_disconnect(guild: discord.Guild)` + - :code:`on_red_audio_should_auto_play(guild: discord.Guild, channel: discord.VoiceChannel, play: Callable)` + - :code:`on_red_audio_initialized(audio:Cog)` + - :code:`on_red_audio_skip_track(guild: discord.Guild, track: lavalink.Track, requester: discord.Member)` + - :code:`on_red_audio_unload(audio:Cog)` \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.5.rst b/changelog.d/audio/2904.feature.5.rst new file mode 100644 index 000000000..51209d660 --- /dev/null +++ b/changelog.d/audio/2904.feature.5.rst @@ -0,0 +1 @@ +``[p]queue shuffle`` can be used to shuffle the queue manually. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.6.rst b/changelog.d/audio/2904.feature.6.rst new file mode 100644 index 000000000..c37957ae4 --- /dev/null +++ b/changelog.d/audio/2904.feature.6.rst @@ -0,0 +1 @@ +``[p]queue clean self`` can be used to remove all songs you requested from the queue. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.7.rst b/changelog.d/audio/2904.feature.7.rst new file mode 100644 index 000000000..ab1b46e0c --- /dev/null +++ b/changelog.d/audio/2904.feature.7.rst @@ -0,0 +1 @@ +``[p]audioset restrictions`` can be used to add or remove keywords which songs must have or are not allowed to have. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.8.rst b/changelog.d/audio/2904.feature.8.rst new file mode 100644 index 000000000..97202c775 --- /dev/null +++ b/changelog.d/audio/2904.feature.8.rst @@ -0,0 +1 @@ +``[p]playlist dedupe`` can be used to remove duplicated tracks from a playlist. \ No newline at end of file diff --git a/changelog.d/audio/2904.feature.9.rst b/changelog.d/audio/2904.feature.9.rst new file mode 100644 index 000000000..9ab73f454 --- /dev/null +++ b/changelog.d/audio/2904.feature.9.rst @@ -0,0 +1 @@ +``[p]autoplay`` can be used to play a song. \ No newline at end of file diff --git a/changelog.d/audio/2904.misc.1.rst b/changelog.d/audio/2904.misc.1.rst new file mode 100644 index 000000000..1676ffd84 --- /dev/null +++ b/changelog.d/audio/2904.misc.1.rst @@ -0,0 +1 @@ +:class:`red.cogs.audio.localpaths.Query` and :class:`red.cogs.audio.localpaths.LocalPath` have been implemented to handle localtracks and queries. \ No newline at end of file diff --git a/changelog.d/audio/721.feature.rst b/changelog.d/audio/721.feature.rst new file mode 100644 index 000000000..ec8ae42a8 --- /dev/null +++ b/changelog.d/audio/721.feature.rst @@ -0,0 +1 @@ +Auto pause queue when room is empty. \ No newline at end of file diff --git a/redbot/cogs/audio/apis.py b/redbot/cogs/audio/apis.py new file mode 100644 index 000000000..bd9f548d7 --- /dev/null +++ b/redbot/cogs/audio/apis.py @@ -0,0 +1,1131 @@ +import asyncio +import base64 +import contextlib +import datetime +import json +import logging +import os +import random +import time +from collections import namedtuple +from typing import Callable, Dict, List, Mapping, NoReturn, Optional, Tuple, Union + +try: + from sqlite3 import Error as SQLError + from databases import Database + + HAS_SQL = True +except ModuleNotFoundError: + HAS_SQL = False + SQLError = ModuleNotFoundError + Database = None + +import aiohttp +import discord +import lavalink +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 dataclasses +from .errors import InvalidTableError, SpotifyFetchError, YouTubeApiError +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" + + +class SpotifyAPI: + """Wrapper for the Spotify API.""" + + def __init__(self, bot: Red, session: aiohttp.ClientSession): + self.bot = bot + self.session = session + self.spotify_token = None + self.client_id = None + self.client_secret = None + + @staticmethod + async def _check_token(token: dict): + now = int(time.time()) + return token["expires_at"] - now < 60 + + @staticmethod + def _make_token_auth(client_id: Optional[str], client_secret: Optional[str]) -> dict: + if client_id is None: + client_id = "" + if client_secret is None: + client_secret = "" + + 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: + if params is None: + params = {} + async with self.session.request("GET", url, params=params, headers=headers) as r: + if r.status != 200: + log.debug( + "Issue making GET request to {0}: [{1.status}] {2}".format( + url, r, await r.json() + ) + ) + return await r.json() + + async def _get_auth(self) -> NoReturn: + 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 _request_token(self) -> dict: + await self._get_auth() + + payload = {"grant_type": "client_credentials"} + headers = self._make_token_auth(self.client_id, self.client_secret) + r = await self.post_call( + "https://accounts.spotify.com/api/token", payload=payload, headers=headers + ) + return r + + async def _get_spotify_token(self) -> Optional[str]: + if self.spotify_token and not await self._check_token(self.spotify_token): + return self.spotify_token["access_token"] + token = await self._request_token() + if token is None: + log.debug("Requested a token from Spotify, did not end up getting one.") + try: + token["expires_at"] = int(time.time()) + token["expires_in"] + except KeyError: + return + self.spotify_token = token + 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 with self.session.post(url, data=payload, headers=headers) as r: + if r.status != 200: + log.debug( + "Issue making POST request to {0}: [{1.status}] {2}".format( + url, r, await r.json() + ) + ) + return await r.json() + + async def get_call(self, url: str, params: dict) -> dict: + 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]]: + url = "https://api.spotify.com/v1/browse/categories" + params = {} + result = await self.get_call(url, params=params) + with contextlib.suppress(KeyError): + if result["error"]["status"] == 401: + raise SpotifyFetchError( + message=( + "The Spotify API key or client secret has not been set properly. " + "\nUse `{prefix}audioset spotifyapi` for instructions." + ) + ) + categories = result.get("categories", {}).get("items", []) + return [{c["name"]: c["id"]} for c in categories] + + async def get_playlist_from_category(self, category: str): + url = f"https://api.spotify.com/v1/browse/categories/{category}/playlists" + params = {} + result = await self.get_call(url, params=params) + playlists = result.get("playlists", {}).get("items", []) + return [ + { + "name": c["name"], + "uri": c["uri"], + "url": c.get("external_urls", {}).get("spotify"), + "tracks": c.get("tracks", {}).get("total", "Unknown"), + } + for c in playlists + ] + + +class YouTubeAPI: + """Wrapper for the YouTube Data API.""" + + def __init__(self, bot: Red, session: aiohttp.ClientSession): + self.bot = bot + 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", "") + return self.api_key + + async def get_call(self, query: str) -> Optional[str]: + params = { + "q": query, + "part": "id", + "key": await self._get_api_key(), + "maxResults": 1, + "type": "video", + } + yt_url = "https://www.googleapis.com/youtube/v3/search" + async with self.session.request("GET", yt_url, params=params) as r: + if r.status in [400, 404]: + return None + elif r.status in [403, 429]: + if r.reason == "quotaExceeded": + raise YouTubeApiError("Your YouTube Data API quota has been reached.") + + return None + else: + search_response = await r.json() + for search_result in search_response.get("items", []): + if search_result["id"]["kind"] == "youtube#video": + return f"https://www.youtube.com/watch?v={search_result['id']['videoId']}" + + +@cog_i18n(_) +class MusicCache: + """ + 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): + 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._tasks: dict = {} + self._lock: asyncio.Lock = asyncio.Lock() + self.config: Optional[Config] = None + + async def initialize(self, config: Config) -> NoReturn: + 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) -> NoReturn: + if HAS_SQL: + await self.database.execute(query="PRAGMA optimize;") + await self.database.disconnect() + + async def insert(self, table: str, values: List[dict]) -> NoReturn: + # 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]) -> NoReturn: + # 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 [] + + @staticmethod + def _spotify_format_call(qtype: str, key: str) -> Tuple[str, dict]: + params = {} + if qtype == "album": + query = "https://api.spotify.com/v1/albums/{0}/tracks".format(key) + elif qtype == "track": + query = "https://api.spotify.com/v1/tracks/{0}".format(key) + else: + query = "https://api.spotify.com/v1/playlists/{0}/tracks".format(key) + return query, params + + @staticmethod + def _get_spotify_track_info(track_data: dict) -> Tuple[str, ...]: + artist_name = track_data["artists"][0]["name"] + track_name = track_data["name"] + track_info = f"{track_name} {artist_name}" + song_url = track_data.get("external_urls", {}).get("spotify") + uri = track_data["uri"] + _id = track_data["id"] + _type = track_data["type"] + + return song_url, track_info, uri, artist_name, track_name, _id, _type + + async def _spotify_first_time_query( + self, + ctx: commands.Context, + query_type: str, + uri: str, + notifier: Notifier, + skip_youtube: bool = False, + current_cache_level: CacheLevel = CacheLevel.none(), + ) -> List[str]: + youtube_urls = [] + + tracks = await self._spotify_fetch_tracks(query_type, uri, params=None, notifier=notifier) + total_tracks = len(tracks) + database_entries = [] + track_count = 0 + time_now = str(datetime.datetime.now(datetime.timezone.utc)) + youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level) + for track in tracks: + if track.get("error", {}).get("message") == "invalid id": + continue + ( + song_url, + track_info, + uri, + artist_name, + track_name, + _id, + _type, + ) = self._get_spotify_track_info(track) + + database_entries.append( + { + "id": _id, + "type": _type, + "uri": uri, + "track_name": track_name, + "artist_name": artist_name, + "song_url": song_url, + "track_info": track_info, + "last_updated": time_now, + "last_fetched": time_now, + } + ) + if skip_youtube is False: + val = None + if youtube_cache: + update = True + with contextlib.suppress(SQLError): + val, update = await self.fetch_one( + "youtube", "youtube_url", {"track": track_info} + ) + if update: + val = None + if val is None: + val = await self._youtube_first_time_query( + ctx, track_info, current_cache_level=current_cache_level + ) + if youtube_cache and val: + task = ("update", ("youtube", {"track": track_info})) + self.append_task(ctx, *task) + + if val: + youtube_urls.append(val) + else: + youtube_urls.append(track_info) + track_count += 1 + if notifier and ((track_count % 2 == 0) or (track_count == total_tracks)): + await notifier.notify_user(current=track_count, total=total_tracks, key="youtube") + if CacheLevel.set_spotify().is_subset(current_cache_level): + task = ("insert", ("spotify", database_entries)) + self.append_task(ctx, *task) + return youtube_urls + + async def _youtube_first_time_query( + self, + ctx: commands.Context, + track_info: str, + current_cache_level: CacheLevel = CacheLevel.none(), + ) -> 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)) + task = ( + "insert", + ( + "youtube", + [ + { + "track_info": track_info, + "track_url": track_url, + "last_updated": time_now, + "last_fetched": time_now, + } + ], + ), + ) + self.append_task(ctx, *task) + return track_url + + async def _spotify_fetch_tracks( + self, + query_type: str, + uri: str, + recursive: Union[str, bool] = False, + params=None, + notifier: Optional[Notifier] = None, + ) -> Union[dict, List[str]]: + + if recursive is False: + 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) + try: + if results["error"]["status"] == 401 and not recursive: + raise SpotifyFetchError( + ( + "The Spotify API key or client secret has not been set properly. " + "\nUse `{prefix}audioset spotifyapi` for instructions." + ) + ) + elif recursive: + return {"next": None} + except KeyError: + pass + if recursive: + return results + tracks = [] + track_count = 0 + total_tracks = results.get("tracks", results).get("total", 1) + while True: + new_tracks = [] + if query_type == "track": + new_tracks = results + tracks.append(new_tracks) + elif query_type == "album": + tracks_raw = results.get("tracks", results).get("items", []) + if tracks_raw: + new_tracks = tracks_raw + tracks.extend(new_tracks) + else: + tracks_raw = results.get("tracks", results).get("items", []) + if tracks_raw: + new_tracks = [k["track"] for k in tracks_raw if k.get("track")] + tracks.extend(new_tracks) + track_count += len(new_tracks) + if notifier: + await notifier.notify_user(current=track_count, total=total_tracks, key="spotify") + + try: + if results.get("next") is not None: + results = await self._spotify_fetch_tracks( + query_type, uri, results["next"], params, notifier=notifier + ) + continue + else: + break + except KeyError: + raise SpotifyFetchError( + "This doesn't seem to be a valid Spotify playlist/album URL or code." + ) + + return tracks + + async def spotify_query( + self, + ctx: commands.Context, + query_type: str, + uri: str, + skip_youtube: bool = False, + notifier: Optional[Notifier] = None, + ) -> List[str]: + """ + Queries the Database then falls back to Spotify and YouTube APIs. + + Parameters + ---------- + ctx: commands.Context + The context this method is being called under. + query_type : str + Type of query to perform (Pl + uri: str + Spotify URL ID . + skip_youtube:bool + Whether or not to skip YouTube API Calls. + notifier: Notifier + A Notifier object to handle the user UI notifications while tracks are loaded. + Returns + ------- + List[str] + List of Youtube URLs. + """ + current_cache_level = ( + CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() + ) + 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( + "spotify", "track_info", {"uri": f"spotify:track:{uri}"} + ) + if update: + val = None + else: + val = None + youtube_urls = [] + if val is None: + urls = await self._spotify_first_time_query( + ctx, + query_type, + uri, + notifier, + skip_youtube, + current_cache_level=current_cache_level, + ) + youtube_urls.extend(urls) + else: + if query_type == "track" and cache_enabled: + task = ("update", ("spotify", {"uri": f"spotify:track:{uri}"})) + self.append_task(ctx, *task) + youtube_urls.append(val) + return youtube_urls + + async def spotify_enqueue( + self, + ctx: commands.Context, + query_type: str, + uri: str, + enqueue: bool, + player: lavalink.Player, + lock: Callable, + notifier: Optional[Notifier] = None, + ) -> List[lavalink.Track]: + track_list = [] + has_not_allowed = False + try: + current_cache_level = ( + CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() + ) + guild_data = await self.config.guild(ctx.guild).all() + + # now = int(time.time()) + enqueued_tracks = 0 + consecutive_fails = 0 + queue_dur = await queue_duration(ctx) + queue_total_duration = lavalink.utils.format_time(queue_dur) + before_queue_length = len(player.queue) + tracks_from_spotify = await self._spotify_fetch_tracks( + query_type, uri, params=None, notifier=notifier + ) + total_tracks = len(tracks_from_spotify) + if total_tracks < 1: + lock(ctx, False) + embed3 = discord.Embed( + colour=await ctx.embed_colour(), + title=_("This doesn't seem to be a supported Spotify URL or code."), + ) + await notifier.update_embed(embed3) + + return track_list + database_entries = [] + time_now = str(datetime.datetime.now(datetime.timezone.utc)) + + youtube_cache = CacheLevel.set_youtube().is_subset(current_cache_level) + spotify_cache = CacheLevel.set_spotify().is_subset(current_cache_level) + for track_count, track in enumerate(tracks_from_spotify): + ( + song_url, + track_info, + uri, + artist_name, + track_name, + _id, + _type, + ) = self._get_spotify_track_info(track) + + database_entries.append( + { + "id": _id, + "type": _type, + "uri": uri, + "track_name": track_name, + "artist_name": artist_name, + "song_url": song_url, + "track_info": track_info, + "last_updated": time_now, + "last_fetched": time_now, + } + ) + val = None + if youtube_cache: + update = True + with contextlib.suppress(SQLError): + val, update = await self.fetch_one( + "youtube", "youtube_url", {"track": track_info} + ) + if update: + val = None + if val is None: + val = await self._youtube_first_time_query( + ctx, track_info, current_cache_level=current_cache_level + ) + if youtube_cache and val: + task = ("update", ("youtube", {"track": track_info})) + self.append_task(ctx, *task) + + if val: + try: + result, called_api = await self.lavalink_query( + ctx, player, dataclasses.Query.process_input(val) + ) + except (RuntimeError, aiohttp.ServerDisconnectedError): + lock(ctx, False) + error_embed = discord.Embed( + colour=await ctx.embed_colour(), + title=_("The connection was reset while loading the playlist."), + ) + await notifier.update_embed(error_embed) + break + except asyncio.TimeoutError: + lock(ctx, False) + error_embed = discord.Embed( + colour=await ctx.embed_colour(), + title=_("Player timedout, skipping remaning tracks."), + ) + await notifier.update_embed(error_embed) + break + track_object = result.tracks + else: + track_object = [] + if (track_count % 2 == 0) or (track_count == total_tracks): + key = "lavalink" + seconds = "???" + second_key = None + # if 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, + key=key, + seconds_key=second_key, + seconds=seconds, + ) + + if consecutive_fails >= 10: + error_embed = discord.Embed( + colour=await ctx.embed_colour(), + title=_("Failing to get tracks, skipping remaining."), + ) + await notifier.update_embed(error_embed) + break + if not track_object: + consecutive_fails += 1 + continue + consecutive_fails = 0 + single_track = track_object[0] + if not await is_allowed( + ctx.guild, + ( + f"{single_track.title} {single_track.author} {single_track.uri} " + f"{str(dataclasses.Query.process_input(single_track))}" + ), + ): + has_not_allowed = True + log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") + continue + track_list.append(single_track) + if enqueue: + if guild_data["maxlength"] > 0: + if track_limit(single_track, guild_data["maxlength"]): + enqueued_tracks += 1 + player.add(ctx.author, single_track) + self.bot.dispatch( + "red_audio_track_enqueue", + player.channel.guild, + single_track, + ctx.author, + ) + else: + enqueued_tracks += 1 + player.add(ctx.author, single_track) + self.bot.dispatch( + "red_audio_track_enqueue", + player.channel.guild, + single_track, + ctx.author, + ) + + if not player.current: + await player.play() + if len(track_list) == 0: + if not has_not_allowed: + embed3 = discord.Embed( + colour=await ctx.embed_colour(), + title=_( + "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), + ) + await ctx.send(embed=embed3) + player.maybe_shuffle() + if enqueue and tracks_from_spotify: + if total_tracks > enqueued_tracks: + maxlength_msg = " {bad_tracks} tracks cannot be queued.".format( + bad_tracks=(total_tracks - enqueued_tracks) + ) + else: + maxlength_msg = "" + + embed = discord.Embed( + colour=await ctx.embed_colour(), + title=_("Playlist Enqueued"), + description=_("Added {num} tracks to the queue.{maxlength_msg}").format( + num=enqueued_tracks, maxlength_msg=maxlength_msg + ), + ) + if not guild_data["shuffle"] and queue_dur > 0: + embed.set_footer( + text=_( + "{time} until start of playlist" + " playback: starts at #{position} in queue" + ).format(time=queue_total_duration, position=before_queue_length + 1) + ) + + await notifier.update_embed(embed) + lock(ctx, False) + + if spotify_cache: + task = ("insert", ("spotify", database_entries)) + self.append_task(ctx, *task) + except Exception as e: + lock(ctx, False) + raise e + finally: + lock(ctx, False) + 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() + ) + 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}) + if update: + val = None + if val is None: + youtube_url = await self._youtube_first_time_query( + ctx, track_info, current_cache_level=current_cache_level + ) + else: + if cache_enabled: + task = ("update", ("youtube", {"track": track_info})) + self.append_task(ctx, *task) + youtube_url = val + return youtube_url + + async def lavalink_query( + self, + ctx: commands.Context, + player: lavalink.Player, + query: 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. + + Parameters + ---------- + ctx: commands.Context + The context this method is being called under. + player : lavalink.Player + The player who's requesting the query. + query: dataclasses.Query + The Query object for the query in question. + forced:bool + Whether or not to skip cache and call API first.. + Returns + ------- + Tuple[lavalink.LoadResult, bool] + Tuple with the Load result and whether or not the API was called. + """ + current_cache_level = ( + CacheLevel(await self.config.cache_level()) if HAS_SQL else CacheLevel.none() + ) + cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) + val = None + _raw_query = dataclasses.Query.process_input(query) + query = str(_raw_query) + 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}) + if update: + val = None + if val: + task = ("update", ("lavalink", {"query": query})) + self.append_task(ctx, *task) + if val and not forced: + data = json.loads(val) + data["query"] = query + results = LoadResult(data) + called_api = False + if results.has_error: + # If cached value has an invalid entry make a new call so that it gets updated + return await self.lavalink_query(ctx, player, _raw_query, forced=True) + else: + called_api = True + results = None + try: + results = await player.load_tracks(query) + except KeyError: + results = None + if results is None: + results = LoadResult({"loadType": "LOAD_FAILED", "playlistInfo": {}, "tracks": []}) + if ( + cache_enabled + and results.load_type + and not results.has_error + and not _raw_query.is_local + and results.tracks + ): + with contextlib.suppress(SQLError): + time_now = str(datetime.datetime.now(datetime.timezone.utc)) + task = ( + "insert", + ( + "lavalink", + [ + { + "query": query, + "data": json.dumps(results._raw), + "last_updated": time_now, + "last_fetched": time_now, + } + ], + ), + ) + self.append_task(ctx, *task) + return results, called_api + + async def run_tasks(self, ctx: Optional[commands.Context] = None, _id=None): + lock_id = _id or ctx.message.id + lock_author = ctx.author if ctx else None + async with self._lock: + if lock_id in self._tasks: + log.debug(f"Running database writes for {lock_id} ({lock_author})") + with contextlib.suppress(Exception): + tasks = self._tasks[ctx.message.id] + del self._tasks[ctx.message.id] + await asyncio.gather( + *[asyncio.ensure_future(self.insert(*a)) for a in tasks["insert"]], + loop=self.bot.loop, + return_exceptions=True, + ) + await asyncio.gather( + *[asyncio.ensure_future(self.update(*a)) for a in tasks["update"]], + loop=self.bot.loop, + return_exceptions=True, + ) + log.debug(f"Completed database writes for {lock_id} " f"({lock_author})") + + async def run_all_pending_tasks(self): + async with self._lock: + log.debug("Running pending writes to database") + with contextlib.suppress(Exception): + tasks = {"update": [], "insert": []} + for k, task in self._tasks.items(): + for t, args in task.items(): + tasks[t].append(args) + self._tasks = {} + + await asyncio.gather( + *[asyncio.ensure_future(self.insert(*a)) for a in tasks["insert"]], + loop=self.bot.loop, + return_exceptions=True, + ) + await asyncio.gather( + *[asyncio.ensure_future(self.update(*a)) for a in tasks["update"]], + loop=self.bot.loop, + return_exceptions=True, + ) + log.debug("Completed pending writes to database have finished") + + def append_task(self, ctx: commands.Context, event: str, task: tuple, _id=None): + lock_id = _id or ctx.message.id + if lock_id not in self._tasks: + self._tasks[lock_id] = {"update": [], "insert": []} + self._tasks[lock_id][event].append(task) + + async def play_random(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 + + vals = await self.fetch_all("lavalink", "data", query_data) + recently_played = [r.data for r in vals if r] + + if recently_played: + track = random.choice(recently_played) + results = LoadResult(json.loads(track)) + tracks = list(results.tracks) + except Exception: + tracks = [] + + return tracks + + 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() + ) + cache_enabled = CacheLevel.set_lavalink().is_subset(current_cache_level) + playlist = None + tracks = None + if autoplaylist["enabled"]: + with contextlib.suppress(Exception): + playlist = await get_playlist( + autoplaylist["id"], + autoplaylist["scope"], + self.bot, + player.channel.guild, + player.channel.guild.me, + ) + tracks = playlist.tracks_obj + + if not tracks or not getattr(playlist, "tracks", None): + if cache_enabled: + tracks = await self.play_random() + if not tracks: + ctx = namedtuple("Context", "message") + results, called_api = await self.lavalink_query( + ctx(player.channel.guild), player, dataclasses.Query.process_input(_TOP_100_US) + ) + tracks = list(results.tracks) + if tracks: + multiple = len(tracks) > 1 + track = tracks[0] + + valid = not multiple + + while valid is False and multiple: + track = random.choice(tracks) + query = dataclasses.Query.process_input(track) + if not query.valid: + continue + if query.is_local and not query.track.exists(): + continue + if not await is_allowed( + player.channel.guild, + ( + f"{track.title} {track.author} {track.uri} " + f"{str(dataclasses.Query.process_input(track))}" + ), + ): + log.debug( + "Query is not allowed in " + f"{player.channel.guild} ({player.channel.guild.id})" + ) + continue + valid = True + + track.extras = {"autoplay": True} + player.add(player.channel.guild.me, track) + self.bot.dispatch( + "red_audio_track_auto_play", player.channel.guild, track, player.channel.guild.me + ) + if not player.current: + await player.play() diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index 637bced48..7f1f141e2 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -1,46 +1,70 @@ -import aiohttp +# -*- coding: utf-8 -*- import asyncio -import base64 +import contextlib import datetime -import discord -from fuzzywuzzy import process import heapq -from io import StringIO import json -import lavalink import logging -import math import os import random import re import time -from typing import Optional +import traceback +from collections import namedtuple +from io import StringIO +from typing import List, Optional, Tuple, Union, cast + +import aiohttp +import discord +import lavalink +import math +from fuzzywuzzy import process + import redbot.core -from redbot.core import Config, commands, checks, bank +from redbot.core import Config, bank, checks, commands 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, pagify, humanize_number +from redbot.core.utils.chat_formatting import bold, box, humanize_number, inline, pagify from redbot.core.utils.menus import ( - menu, DEFAULT_CONTROLS, - prev_page, - next_page, close_menu, + menu, + next_page, + prev_page, start_adding_reactions, ) from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate -from urllib.parse import urlparse +from . import dataclasses +from .apis import MusicCache, HAS_SQL +from .checks import can_have_caching +from .converters import ComplexScopeParser, ScopeParser, get_lazy_converter, get_playlist_converter from .equalizer import Equalizer +from .errors import LavalinkDownloadFailed, MissingGuild, SpotifyFetchError, TooManyMatches from .manager import ServerManager -from .errors import LavalinkDownloadFailed +from .playlists import ( + FakePlaylist, + Playlist, + PlaylistScope, + create_playlist, + delete_playlist, + get_all_playlist, + get_playlist, + humanize_scope, +) +from .utils import * + _ = Translator("Audio", __file__) -__version__ = "0.0.10" -__author__ = ["aikaterna"] +__version__ = "1.0.0" +__author__ = ["aikaterna", "Draper"] log = logging.getLogger("red.audio") +_SCHEMA_VERSION = 2 +LazyGreedyConverter = get_lazy_converter("--") +PlaylistConverter = get_playlist_converter() + @cog_i18n(_) class Audio(commands.Cog): @@ -57,8 +81,20 @@ class Audio(commands.Cog): 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._manager: Optional[ServerManager] = None + self._cog_name = None + self._cog_id = None default_global = dict( + schema_version=1, + cache_level=0, + cache_age=365, status=False, use_external_lavalink=False, restrict=True, @@ -68,15 +104,18 @@ class Audio(commands.Cog): ) default_guild = dict( + auto_play=False, + autoplaylist=dict(enabled=False, id=None, name=None, scope=None), disconnect=False, dj_enabled=False, dj_role=None, emptydc_enabled=False, emptydc_timer=0, + emptypause_enabled=False, + emptypause_timer=0, jukebox=False, jukebox_price=0, maxlength=0, - playlists={}, notify=False, repeat=False, shuffle=False, @@ -84,26 +123,49 @@ class Audio(commands.Cog): volume=100, vote_enabled=False, vote_percent=0, + room_lock=None, + url_keyword_blacklist=[], + url_keyword_whitelist=[], ) - + _playlist = dict(id=None, author=None, name=None, playlist_url=None, tracks=[]) self.config.init_custom("EQUALIZER", 1) self.config.register_custom("EQUALIZER", eq_bands=[], eq_presets={}) - + self.config.init_custom(PlaylistScope.GLOBAL.value, 1) + self.config.register_custom(PlaylistScope.GLOBAL.value, **_playlist) + self.config.init_custom(PlaylistScope.GUILD.value, 2) + self.config.register_custom(PlaylistScope.GUILD.value, **_playlist) + self.config.init_custom(PlaylistScope.USER.value, 2) + self.config.register_custom(PlaylistScope.USER.value, **_playlist) self.config.register_guild(**default_guild) self.config.register_global(**default_global) - self.skip_votes = {} - self.session = aiohttp.ClientSession() - self._connect_task = None - self._disconnect_task = None - self._cleaned_up = False - self._connection_aborted = False - - self.spotify_token = None + self.music_cache = MusicCache(bot, self.session, path=str(cog_data_path(raw_name="Audio"))) self.play_lock = {} self._manager: Optional[ServerManager] = None + self.bot.dispatch("red_audio_initialized", self) - async def cog_before_invoke(self, ctx): + @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 + + async def cog_before_invoke(self, ctx: commands.Context): if self.llsetup in [ctx.command, ctx.command.root_parent]: pass elif self._connect_task.cancelled(): @@ -114,11 +176,84 @@ class Audio(commands.Cog): raise RuntimeError( "Not running audio command due to invalid machine architecture for Lavalink." ) + dj_enabled = 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()) + if not dj_role_obj: + await self.config.guild(ctx.guild).dj_enabled.set(None) + await self.config.guild(ctx.guild).dj_role.set(None) + await self._embed_msg(ctx, _("No DJ role found. Disabling DJ mode.")) async def initialize(self): + pass_config_to_dependencies(self.config, self.bot, await self.config.localpath()) + await self.music_cache.initialize(self.config) + asyncio.ensure_future( + self._migrate_config( + from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION + ) + ) 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 SQL to " + "access the caching features, " + "your Python install is missing the module sqlite3.\n\n" + "For instructions on how to fix it Google " + "`ModuleNotFoundError: No module named '_sqlite3'`\n" + "You will need to reinstall " + "Python with SQL dependencies installed.\n\n" + ).format(version=__version__) + with contextlib.suppress(discord.HTTPException): + await self.bot.send_to_owners(error_message) + log.critical(error_message) + + async def _migrate_config(self, from_version: int, to_version: int): + database_entries = [] + time_now = str(datetime.datetime.now(datetime.timezone.utc)) + if from_version == to_version: + return + elif from_version < to_version: + all_guild_data = await self.config.all_guilds() + all_playlist = {} + 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): + if not data or not name: + continue + playlist = {"id": count, "name": name, "guild": int(guild_id)} + playlist.update(data) + guild_playlist[str(count)] = playlist + + tracks_in_playlist = data.get("tracks", []) or [] + for t in tracks_in_playlist: + uri = t.get("info", {}).get("uri") + if uri: + t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri} + database_entries.append( + { + "query": uri, + "data": json.dumps(t), + "last_updated": time_now, + "last_fetched": time_now, + } + ) + if guild_playlist: + all_playlist[str(guild_id)] = guild_playlist + await self.config.custom(PlaylistScope.GUILD.value).set(all_playlist) + # new schema is now in place + await self.config.schema_version.set(_SCHEMA_VERSION) + + # migration done, now let's delete all the old stuff + for guild_id in all_guild_data: + await self.config.guild( + cast(discord.Guild, discord.Object(id=guild_id)) + ).clear_raw("playlists") + if database_entries and HAS_SQL: + asyncio.ensure_future(self.music_cache.insert("lavalink", database_entries)) def _restart_connect(self): if self._connect_task: @@ -219,10 +354,14 @@ class Audio(commands.Cog): "tracebacks for details." ) - async def event_handler(self, player, event_type, extra): + 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() status = await self.config.status() + repeat = await self.config.guild(player.channel.guild).repeat() async def _players_check(): try: @@ -231,7 +370,10 @@ class Audio(commands.Cog): 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 "localtracks/" in lavalink.active_players()[0].current.uri: + elif any( + x in lavalink.active_players()[0].current.uri + for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): get_single_title = "{} - {}".format( lavalink.active_players()[0].current.author, lavalink.active_players()[0].current.title, @@ -263,32 +405,84 @@ class Audio(commands.Cog): ) if event_type == lavalink.LavalinkEvents.TRACK_START: + self.skip_votes[player.channel.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.uri) - player.store("requester", player.current.requester) - self.skip_votes[player.channel.guild] = [] + 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, + ) + 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 + ) + + 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 + ) + if autoplay and not player.queue and player.fetch("playing_song") is not None: + if self.owns_autoplay is None: + await self.music_cache.autoplay(player) + else: + self.bot.dispatch( + "red_audio_should_auto_play", + player, + player.channel.guild, + player.channel, + self.play_query, + ) if event_type == lavalink.LavalinkEvents.TRACK_START and notify: notify_channel = player.fetch("channel") + prev_song = player.fetch("prev_song") if notify_channel: notify_channel = self.bot.get_channel(notify_channel) if player.fetch("notify_message") is not None: - try: + with contextlib.suppress(discord.HTTPException): await player.fetch("notify_message").delete() - except discord.errors.NotFound: - pass - if "localtracks/" in player.current.uri: - if not player.current.title == "Unknown title": + + 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."), + ) + await notify_channel.send(embed=embed) + + if ( + any( + x in player.current.uri + for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ) + if player.current + else False + ): + if player.current.title != "Unknown title": description = "**{} - {}**\n{}".format( player.current.author, player.current.title, - player.current.uri.replace("localtracks/", ""), + dataclasses.LocalPath(player.current.uri).to_string_hidden(), ) else: - description = "{}".format(player.current.uri.replace("localtracks/", "")) + description = "{}".format( + dataclasses.LocalPath(player.current.uri).to_string_hidden() + ) else: description = "**[{}]({})**".format(player.current.title, player.current.uri) if player.current.is_stream: @@ -323,7 +517,7 @@ class Audio(commands.Cog): player_check = await _players_check() await _status_check(player_check[1]) - if event_type == lavalink.LavalinkEvents.QUEUE_END and notify: + if event_type == lavalink.LavalinkEvents.QUEUE_END and notify and not autoplay: notify_channel = player.fetch("channel") if notify_channel: notify_channel = self.bot.get_channel(notify_channel) @@ -333,7 +527,8 @@ class Audio(commands.Cog): ) await notify_channel.send(embed=embed) - if event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect: + elif event_type == lavalink.LavalinkEvents.QUEUE_END and disconnect and not autoplay: + self.bot.dispatch("red_audio_audio_disconnect", player.channel.guild) await player.disconnect() if event_type == lavalink.LavalinkEvents.QUEUE_END and status: @@ -341,54 +536,441 @@ class Audio(commands.Cog): await _status_check(player_check[1]) if event_type == lavalink.LavalinkEvents.TRACK_EXCEPTION: - if "localtracks/" in player.current.uri: - return message_channel = player.fetch("channel") if message_channel: message_channel = self.bot.get_channel(message_channel) + if player.current and any( + x in player.current.uri + for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): + query = 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, player.current.title, player.current.uri - ), + description="{}\n{}".format(extra, description), ) embed.set_footer(text=_("Skipping...")) await message_channel.send(embed=embed) - await player.skip() + while True: + if player.current in player.queue: + player.queue.remove(player.current) + else: + break + if repeat: + player.current = None + await player.skip() + + async def play_query( + self, + query: str, + guild: discord.Guild, + channel: discord.VoiceChannel, + is_autoplay: bool = True, + ): + if not self._player_check(guild.me): + try: + if ( + not channel.permissions_for(guild.me).connect + or not channel.permissions_for(guild.me).move_members + and userlimit(channel) + ): + log.error(f"I don't have permission to connect to {channel} in {guild}.") + + await lavalink.connect(channel) + player = lavalink.get_player(guild.id) + player.store("connect", datetime.datetime.utcnow()) + except IndexError: + log.debug( + f"Connection to Lavalink has not yet been established" + f" while trying to connect to to {channel} in {guild}." + ) + return + + player = lavalink.get_player(guild.id) + + player.store("channel", channel.id) + player.store("guild", guild.id) + await self._data_check(guild.me) + query = dataclasses.Query.process_input(query) + ctx = namedtuple("Context", "message") + results, called_api = await self.music_cache.lavalink_query(ctx(guild), player, query) + + if not results.tracks: + log.debug(f"Query returned no tracks.") + return + track = results.tracks[0] + + if not await is_allowed( + guild, f"{track.title} {track.author} {track.uri} {str(query._raw)}" + ): + log.debug(f"Query is not allowed in {guild} ({guild.id})") + return + 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 + ) + 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): + async def audioset(self, ctx: commands.Context): """Music configuration options.""" pass @audioset.command() @checks.mod_or_permissions(manage_messages=True) - async def dc(self, ctx): + async def dc(self, ctx: commands.Context): """Toggle the bot auto-disconnecting when done playing. This setting takes precedence over [p]audioset emptydisconnect. """ + disconnect = await self.config.guild(ctx.guild).disconnect() - await self.config.guild(ctx.guild).disconnect.set(not disconnect) - await self._embed_msg( - ctx, - _("Auto-disconnection at queue end: {true_or_false}.").format( - true_or_false=not disconnect - ), + autoplay = await self.config.guild(ctx.guild).auto_play() + msg = "" + msg += _("Auto-disconnection at queue end: {true_or_false}.").format( + true_or_false=_("Enabled") if not disconnect else _("Disabled") ) + await self.config.guild(ctx.guild).repeat.set(not disconnect) + if disconnect is not True and autoplay is True: + msg += _("\nAuto-play has been disabled.") + await self.config.guild(ctx.guild).auto_play.set(False) + + 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) + + @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 + + @_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): + """Adds a keyword to the blacklist.""" + keyword = keyword.lower().strip() + if not keyword: + return await ctx.send_help() + exists = False + async with self.config.guild(ctx.guild).url_keyword_blacklist() as blacklist: + if keyword in blacklist: + exists = True + else: + blacklist.append(keyword) + if exists: + return await self._embed_msg(ctx, _("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 + ) + await ctx.send(embed=embed) + + @_perms_whitelist.command(name="add") + async def _perms_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.guild(ctx.guild).url_keyword_whitelist() as whitelist: + if keyword in whitelist: + exists = True + else: + whitelist.append(keyword) + if exists: + return await self._embed_msg(ctx, _("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 + ) + await ctx.send(embed=embed) + + @_perms_blacklist.command(name="delete", aliases=["del", "remove"]) + async def _perms_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.guild(ctx.guild).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, _("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 + ) + await ctx.send(embed=embed) + + @_perms_whitelist.command(name="delete", aliases=["del", "remove"]) + async def _perms_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.guild(ctx.guild).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, _("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 + ) + 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.")) + 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="Whitelist", description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @_perms_blacklist.command(name="list") + async def _perms_blacklist_list(self, ctx: commands.Context): + """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.")) + 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="Whitelist", description=page, colour=embed_colour) + for page in pages + ) + await menu(ctx, pages, DEFAULT_CONTROLS) + + @_perms_whitelist.command(name="clear") + async def _perms_whitelist_clear(self, ctx: commands.Context): + """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.")) + await self.config.guild(ctx.guild).url_keyword_whitelist.clear() + return await self._embed_msg(ctx, _("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.")) + await self.config.guild(ctx.guild).url_keyword_blacklist.clear() + return await self._embed_msg(ctx, _("All entries have been removed from the blacklist.")) + + @audioset.group(name="autoplay") + @checks.mod_or_permissions(manage_messages=True) + async def _autoplay(self, ctx: commands.Context): + """Change auto-play setting.""" + + @_autoplay.command(name="toggle") + async def _autoplay_toggle(self, ctx: commands.Context): + """Toggle auto-play when there no songs in queue.""" + autoplay = await self.config.guild(ctx.guild).auto_play() + repeat = await self.config.guild(ctx.guild).repeat() + disconnect = await self.config.guild(ctx.guild).disconnect() + msg = _("Auto-play when queue ends: {true_or_false}.").format( + true_or_false=_("Enabled") if not autoplay else _("Disabled") + ) + await self.config.guild(ctx.guild).auto_play.set(not autoplay) + if autoplay is not True and repeat is True: + msg += _("\nRepeat has been disabled.") + await self.config.guild(ctx.guild).repeat.set(False) + if autoplay is not True and disconnect is True: + 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) + if self._player_check(ctx): + await self._data_check(ctx) + + @_autoplay.command(name="playlist", usage=" [args]") + async def __autoplay_playlist( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Set a playlist to auto-play songs from. + + **Usage**: + ​ ​ ​ ​ [p]audioset autoplay playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]audioset autoplay MyGuildPlaylist + ​ ​ ​ ​ [p]audioset autoplay MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]audioset autoplay PersonalPlaylist --scope User --author Draper + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + + scope, author, guild, specified_user = scope_data + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("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) + ) + 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( + 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.") + ) + else: + return await self._embed_msg( + ctx, + _("Playlist {name} (`{id}`) [**{scope}**] will be used for autoplay.").format( + name=playlist.name, + id=playlist.id, + scope=humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ), + ), + ) + + @_autoplay.command(name="reset") + async def _autoplay_reset(self, ctx: commands.Context): + """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.")) @audioset.command() @checks.admin_or_permissions(manage_roles=True) - async def dj(self, ctx): + async def dj(self, ctx: commands.Context): """Toggle DJ mode. DJ mode allows users with the DJ role to use audio commands. """ - dj_role_id = await self.config.guild(ctx.guild).dj_role() - if dj_role_id is None: + dj_role = ctx.guild.get_role(await self.config.guild(ctx.guild).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.") ) @@ -403,16 +985,19 @@ class Audio(commands.Cog): dj_enabled = await self.config.guild(ctx.guild).dj_enabled() await self.config.guild(ctx.guild).dj_enabled.set(not dj_enabled) await self._embed_msg( - ctx, _("DJ role enabled: {true_or_false}.").format(true_or_false=not dj_enabled) + ctx, + _("DJ role: {true_or_false}.").format( + true_or_false=_("Enabled") if not dj_enabled else _("Disabled") + ), ) @audioset.command() @checks.mod_or_permissions(administrator=True) - async def emptydisconnect(self, ctx, seconds: int): + async def emptydisconnect(self, ctx: commands.Context, seconds: int): """Auto-disconnection after x seconds while stopped. 0 to disable.""" if seconds < 0: return await self._embed_msg(ctx, _("Can't be less than zero.")) - if seconds < 10 and seconds > 0: + if 10 > seconds > 0: seconds = 10 if seconds == 0: enabled = False @@ -422,7 +1007,7 @@ class Audio(commands.Cog): await self._embed_msg( ctx, _("Empty disconnect timer set to {num_seconds}.").format( - num_seconds=self._dynamic_time(seconds) + num_seconds=dynamic_time(seconds) ), ) @@ -431,7 +1016,29 @@ class Audio(commands.Cog): @audioset.command() @checks.mod_or_permissions(administrator=True) - async def jukebox(self, ctx, price: int): + async def emptypause(self, ctx: commands.Context, seconds: int): + """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.")) + if 10 > seconds > 0: + seconds = 10 + if seconds == 0: + enabled = False + await self._embed_msg(ctx, _("Empty pause disabled.")) + else: + enabled = True + await self._embed_msg( + ctx, + _("Empty pause timer set to {num_seconds}.").format( + num_seconds=dynamic_time(seconds) + ), + ) + await self.config.guild(ctx.guild).emptypause_timer.set(seconds) + await self.config.guild(ctx.guild).emptypause_enabled.set(enabled) + + @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.""" if price < 0: return await self._embed_msg(ctx, _("Can't be less than zero.")) @@ -452,7 +1059,7 @@ class Audio(commands.Cog): @audioset.command() @checks.is_owner() - async def localpath(self, ctx, local_path=None): + async def localpath(self, ctx: commands.Context, *, local_path=None): """Set the localtracks path if the Lavalink.jar is not run from the Audio data folder. Leave the path blank to reset the path to the default, the Audio data directory. @@ -460,14 +1067,17 @@ class Audio(commands.Cog): if not local_path: await self.config.localpath.set(str(cog_data_path(raw_name="Audio"))) + pass_config_to_dependencies( + 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.") ) info_msg = _( "This setting is only for bot owners to set a localtracks folder location " - "if the Lavalink.jar is being ran from outside of the Audio data directory.\n" - "In the example below, the full path for 'ParentDirectory' must be passed to this command.\n" + "In the example below, the full path for 'ParentDirectory' " + "must be passed to this command.\n" "The path must not contain spaces.\n" "```\n" "ParentDirectory\n" @@ -475,12 +1085,12 @@ class Audio(commands.Cog): " | |__ Awesome Album Name (folder)\n" " | |__01 Cool Song.mp3\n" " | |__02 Groovy Song.mp3\n" - " |\n" - " |__ Lavalink.jar\n" - " |__ application.yml\n" "```\n" - "The folder path given to this command must contain the Lavalink.jar, the application.yml, and the localtracks folder.\n" - "Use this command with no path given to reset it to the default, the Audio data directory for this bot.\n" + "The folder path given to this command must contain the localtracks folder.\n" + "**This folder and files need to be visible to the user where `" + "Lavalink.jar` is being run from.**\n" + "Use this command with no path given to reset it to the default, " + "the Audio data directory for this bot.\n" "Do you want to continue to set the provided path for local tracks?" ) info = await ctx.maybe_send_embed(info_msg) @@ -490,127 +1100,142 @@ class Audio(commands.Cog): await ctx.bot.wait_for("reaction_add", check=pred) if not pred.result: - try: + with contextlib.suppress(discord.HTTPException): await info.delete() - except discord.errors.Forbidden: - pass return - - try: - if os.getcwd() != local_path: - os.chdir(local_path) - os.listdir(local_path) - except OSError: + temp = dataclasses.LocalPath(local_path, forced=True) + 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), ) - jar_check = os.path.isfile(local_path + "/Lavalink.jar") - yml_check = os.path.isfile(local_path + "/application.yml") - - if not jar_check and not yml_check: - filelist = "a Lavalink.jar and an application.yml" - elif jar_check and not yml_check: - filelist = "an application.yml" - elif not jar_check and yml_check: - filelist = "a Lavalink.jar" - else: - filelist = None - if filelist is not None: + if not temp.localtrack_folder.exists(): warn_msg = _( - "The path that was entered does not have {filelist} file in " - "that location. The path will still be saved, but please check the path and " - "the file location before attempting to play local tracks or start your " - "Lavalink.jar." - ).format(filelist=filelist) - await self._embed_msg(ctx, warn_msg) - + "`{localtracks}` does not exist. " + "The path will still be saved, but please check the path and " + "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(), + ) + ) + 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) ) @audioset.command() @checks.mod_or_permissions(administrator=True) - async def maxlength(self, ctx, seconds): + async def maxlength(self, ctx: commands.Context, seconds: Union[int, str]): """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.""" if not isinstance(seconds, int): - seconds = int(await self._time_convert(seconds) / 1000) + seconds = time_convert(seconds) if seconds < 0: return await self._embed_msg(ctx, _("Can't be less than zero.")) if seconds == 0: await self._embed_msg(ctx, _("Track max length disabled.")) else: await self._embed_msg( - ctx, - _("Track max length set to {seconds}.").format( - seconds=self._dynamic_time(seconds) - ), + ctx, _("Track max length set to {seconds}.").format(seconds=dynamic_time(seconds)) ) await self.config.guild(ctx.guild).maxlength.set(seconds) @audioset.command() @checks.mod_or_permissions(manage_messages=True) - async def notify(self, ctx): + async def notify(self, ctx: commands.Context): """Toggle track announcement and other bot messages.""" notify = await self.config.guild(ctx.guild).notify() await self.config.guild(ctx.guild).notify.set(not notify) await self._embed_msg( - ctx, _("Verbose mode on: {true_or_false}.").format(true_or_false=not notify) + ctx, + _("Verbose mode: {true_or_false}.").format( + true_or_false=_("Enabled") if not notify else _("Disabled") + ), ) @audioset.command() @checks.is_owner() - async def restrict(self, ctx): + async def restrict(self, ctx: commands.Context): """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(true_or_false=not restrict) + ctx, + _("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, 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)) @audioset.command() - async def settings(self, ctx): + async def settings(self, ctx: commands.Context): """Show the current settings.""" - is_owner = ctx.author.id == self.bot.owner_id - data = await self.config.guild(ctx.guild).all() + is_owner = await ctx.bot.is_owner(ctx.author) global_data = await self.config.all() + data = await self.config.guild(ctx.guild).all() dj_role_obj = ctx.guild.get_role(data["dj_role"]) dj_enabled = data["dj_enabled"] emptydc_enabled = data["emptydc_enabled"] emptydc_timer = data["emptydc_timer"] + emptypause_enabled = data["emptypause_enabled"] + emptypause_timer = data["emptypause_timer"] jukebox = data["jukebox"] jukebox_price = data["jukebox_price"] thumbnail = data["thumbnail"] dc = data["disconnect"] - jarbuild = redbot.core.__version__ + autoplay = data["auto_play"] maxlength = data["maxlength"] vote_percent = data["vote_percent"] - msg = "----" + _("Server Settings") + "---- \n" - if dc: - msg += _("Auto-disconnect: [{dc}]\n").format(dc=dc) + current_level = CacheLevel(global_data["cache_level"]) + song_repeat = _("Enabled") if data["repeat"] else _("Disabled") + song_shuffle = _("Enabled") if data["shuffle"] else _("Disabled") + song_notify = _("Enabled") if data["notify"] else _("Disabled") + song_status = _("Enabled") if global_data["status"] else _("Disabled") + spotify_cache = CacheLevel.set_spotify() + youtube_cache = CacheLevel.set_youtube() + lavalink_cache = CacheLevel.set_lavalink() + has_spotify_cache = current_level.is_superset(spotify_cache) + has_youtube_cache = current_level.is_superset(youtube_cache) + has_lavalink_cache = current_level.is_superset(lavalink_cache) + autoplaylist = data["autoplaylist"] + vote_enabled = data["vote_enabled"] + msg = "----" + _("Server Settings") + "---- \n" + msg += _("Auto-disconnect: [{dc}]\n").format(dc=_("Enabled") if dc else _("Disabled")) + msg += _("Auto-play: [{autoplay}]\n").format( + autoplay=_("Enabled") if autoplay else _("Disabled") + ) if emptydc_enabled: msg += _("Disconnect timer: [{num_seconds}]\n").format( - num_seconds=self._dynamic_time(emptydc_timer) + num_seconds=dynamic_time(emptydc_timer) ) - if dj_enabled: + if emptypause_enabled: + msg += _("Auto Pause timer: [{num_seconds}]\n").format( + num_seconds=dynamic_time(emptypause_timer) + ) + if dj_enabled and dj_role_obj: msg += _("DJ Role: [{role.name}]\n").format(role=dj_role_obj) if jukebox: msg += _("Jukebox: [{jukebox_name}]\n").format(jukebox_name=jukebox) @@ -619,26 +1244,85 @@ class Audio(commands.Cog): ) if maxlength > 0: msg += _("Max track length: [{tracklength}]\n").format( - tracklength=self._dynamic_time(maxlength) + tracklength=dynamic_time(maxlength) ) msg += _( "Repeat: [{repeat}]\n" "Shuffle: [{shuffle}]\n" "Song notify msgs: [{notify}]\n" "Songs as status: [{status}]\n" - ).format(**global_data, **data) + ).format(repeat=song_repeat, shuffle=song_shuffle, notify=song_notify, status=song_status) if thumbnail: - msg += _("Thumbnails: [{0}]\n").format(thumbnail) + msg += _("Thumbnails: [{0}]\n").format( + _("Enabled") if thumbnail else _("Disabled") + ) if vote_percent > 0: msg += _( "Vote skip: [{vote_enabled}]\nSkip percentage: [{vote_percent}%]\n" - ).format(**data) + ).format( + vote_percent=vote_percent, + 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 autoplaylist["enabled"]: + pname = autoplaylist["name"] + pid = autoplaylist["id"] + pscope = autoplaylist["scope"] + if pscope == PlaylistScope.GUILD.value: + pscope = f"Server" + elif pscope == PlaylistScope.USER.value: + pscope = f"User" + else: + pscope = "Global" + else: + pname = _("Cached") + pid = _("Cached") + pscope = _("Cached") + msg += ( + "\n---" + + _("Auto-play Settings") + + "--- \n" + + _("Playlist name: [{pname}]\n") + + _("Playlist ID: [{pid}]\n") + + _("Playlist scope: [{pscope}]\n") + ).format(pname=pname, pid=pid, pscope=pscope) + + if is_owner: + msg += ( + "\n---" + + _("Cache Settings") + + "--- \n" + + _("Max age: [{max_age}]\n") + + _("Spotify cache: [{spotify_status}]\n") + + _("Youtube cache: [{youtube_status}]\n") + + _("Lavalink cache: [{lavalink_status}]\n") + ).format( + max_age=str(await self.config.cache_age()) + " " + _("days"), + spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), + youtube_status=_("Enabled") if has_youtube_cache else _("Disabled"), + lavalink_status=_("Enabled") if has_lavalink_cache else _("Disabled"), + ) + msg += _( - "---Lavalink Settings--- \n" + "\n---" + _("Lavalink Settings") + "--- \n" "Cog version: [{version}]\n" - "Jar build: [{jarbuild}]\n" + "Red-Lavalink: [{redlava}]\n" "External server: [{use_external_lavalink}]\n" - ).format(version=__version__, jarbuild=jarbuild, **global_data) + ).format( + version=__version__, + redlava=lavalink.__version__, + use_external_lavalink=_("Enabled") + if global_data["use_external_lavalink"] + else _("Disabled"), + ) if is_owner: msg += _("Localtracks path: [{localpath}]\n").format(**global_data) @@ -647,7 +1331,7 @@ class Audio(commands.Cog): @audioset.command() @checks.is_owner() - async def spotifyapi(self, ctx): + async def spotifyapi(self, ctx: commands.Context): """Instructions to set the Spotify API tokens.""" message = _( "1. Go to Spotify developers and log in with your Spotify account.\n" @@ -664,27 +1348,33 @@ class Audio(commands.Cog): @checks.is_owner() @audioset.command() - async def status(self, ctx): + async def status(self, ctx: commands.Context): """Enable/disable tracks' titles as status.""" status = await self.config.status() await self.config.status.set(not status) await self._embed_msg( - ctx, _("Song titles as status: {true_or_false}.").format(true_or_false=not status) + ctx, + _("Song titles as status: {true_or_false}.").format( + true_or_false=_("Enabled") if not status else _("Disabled") + ), ) @audioset.command() @checks.mod_or_permissions(administrator=True) - async def thumbnail(self, ctx): + async def thumbnail(self, ctx: commands.Context): """Toggle displaying a thumbnail on audio messages.""" thumbnail = await self.config.guild(ctx.guild).thumbnail() await self.config.guild(ctx.guild).thumbnail.set(not thumbnail) await self._embed_msg( - ctx, _("Thumbnail display: {true_or_false}.").format(true_or_false=not thumbnail) + ctx, + _("Thumbnail display: {true_or_false}.").format( + true_or_false=_("Enabled") if not thumbnail else _("Disabled") + ), ) @audioset.command() @checks.mod_or_permissions(administrator=True) - async def vote(self, ctx, percent: int): + async def vote(self, ctx: commands.Context, percent: int): """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.")) @@ -706,14 +1396,15 @@ class Audio(commands.Cog): @audioset.command() @checks.is_owner() - async def youtubeapi(self, ctx): + async def youtubeapi(self, ctx: commands.Context): """Instructions to set the YouTube API key.""" message = _( f"1. Go to Google Developers Console and log in with your Google account.\n" "(https://console.developers.google.com/)\n" "2. You should be prompted to create a new project (name does not matter).\n" "3. Click on Enable APIs and Services at the top.\n" - "4. In the list of APIs choose or search for YouTube Data API v3 and click on it. Choose Enable.\n" + "4. In the list of APIs choose or search for YouTube Data API v3 and " + "click on it. Choose Enable.\n" "5. Click on Credentials on the left navigation bar.\n" "6. Click on Create Credential at the top.\n" '7. At the top click the link for "API key".\n' @@ -722,24 +1413,143 @@ class Audio(commands.Cog): ).format(prefix=ctx.prefix) await ctx.maybe_send_embed(message) + @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. + + Level can be one of the following: + + 0: Disables all caching + 1: Enables Spotify Cache + 2: Enables YouTube Cache + 3: Enables Lavalink Cache + 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() + youtube_cache = CacheLevel.set_youtube() + lavalink_cache = CacheLevel.set_lavalink() + has_spotify_cache = current_level.is_superset(spotify_cache) + has_youtube_cache = current_level.is_superset(youtube_cache) + has_lavalink_cache = current_level.is_superset(lavalink_cache) + + if level is None: + msg = ( + "---" + + _("Cache Settings") + + "--- \n" + + _("Max age: [{max_age}]\n") + + _("Spotify cache: [{spotify_status}]\n") + + _("Youtube cache: [{youtube_status}]\n") + + _("Lavalink cache: [{lavalink_status}]\n") + ).format( + max_age=str(await self.config.cache_age()) + " " + _("days"), + spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), + 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") + ) + ) + return await ctx.send_help() + if level not in [5, 3, 2, 1, 0, -1, -2, -3]: + return await ctx.send_help() + + removing = level < 0 + + if level == 5: + newcache = CacheLevel.all() + elif level == 0: + newcache = CacheLevel.none() + elif level in [-3, 3]: + if removing: + newcache = current_level - lavalink_cache + else: + newcache = current_level + lavalink_cache + elif level in [-2, 2]: + if removing: + newcache = current_level - youtube_cache + else: + newcache = current_level + youtube_cache + elif level in [-1, 1]: + if removing: + newcache = current_level - spotify_cache + else: + newcache = current_level + spotify_cache + else: + return await ctx.send_help() + + has_spotify_cache = newcache.is_superset(spotify_cache) + 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") + + _("Spotify cache: [{spotify_status}]\n") + + _("Youtube cache: [{youtube_status}]\n") + + _("Lavalink cache: [{lavalink_status}]\n") + ).format( + max_age=str(await self.config.cache_age()) + " " + _("days"), + spotify_status=_("Enabled") if has_spotify_cache else _("Disabled"), + 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.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. + """ + msg = "" + if age < 7: + msg = _( + "Cache age cannot be less than 7 days. If you wish to disable it run " + "{prefix}audioset cache.\n" + ).format(prefix=ctx.prefix) + 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) + @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def audiostats(self, ctx): + async def audiostats(self, ctx: commands.Context): """Audio stats.""" server_num = len(lavalink.active_players()) total_num = len(lavalink.all_players()) + localtracks = await self.config.localpath() msg = "" for p in lavalink.all_players(): connect_start = p.fetch("connect") - connect_dur = self._dynamic_time( + connect_dur = dynamic_time( int((datetime.datetime.utcnow() - connect_start).total_seconds()) ) try: - if "localtracks/" in p.current.uri: + if any( + x in p.current.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): if p.current.title == "Unknown title": - current_title = p.current.uri.replace("localtracks/", "") + current_title = localtracks.LocalPath(p.current.uri).to_string_hidden() msg += "{} [`{}`]: **{}**\n".format( p.channel.guild.name, connect_dur, current_title ) @@ -782,7 +1592,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def bump(self, ctx, index: int): + 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() if not self._player_check(ctx): @@ -806,21 +1616,28 @@ class Audio(commands.Cog): bump_song = player.queue[bump_index] player.queue.insert(0, bump_song) removed = player.queue.pop(index) - if "localtracks/" in removed.uri: - if removed.title == "Unknown title": - removed_title = removed.uri.replace("localtracks/", "") + if any(x in removed.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): + localtrack = dataclasses.LocalPath(removed.uri) + if removed.title != "Unknown title": + description = "**{} - {}**\n{}".format( + removed.author, removed.title, localtrack.to_string_hidden() + ) else: - removed_title = "{} - {}".format(removed.author, removed.title) + description = localtrack.to_string_hidden() else: - removed_title = removed.title - await self._embed_msg( - ctx, _("Moved {track} to the top of the queue.").format(track=removed_title) + 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, + ) ) @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def disconnect(self, ctx): + 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.")) @@ -836,8 +1653,12 @@ class Audio(commands.Cog): ): return await self._embed_msg(ctx, _("There are other people listening to music.")) else: + await self._embed_msg(ctx, _("Disconnecting...")) + self.bot.dispatch("red_audio_audio_disconnect", ctx.guild) self._play_lock(ctx, False) eq = player.fetch("eq") + player.queue = [] + player.store("playing_song", None) if eq: await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands) await player.stop() @@ -845,11 +1666,12 @@ class Audio(commands.Cog): @commands.group(invoke_without_command=True) @commands.guild_only() - @commands.cooldown(1, 15, discord.ext.commands.BucketType.guild) + @commands.cooldown(1, 15, commands.BucketType.guild) @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def eq(self, ctx): + async def eq(self, ctx: commands.Context): """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() player = lavalink.get_player(ctx.guild.id) @@ -864,18 +1686,14 @@ class Audio(commands.Cog): except discord.errors.NotFound: pass else: - for reaction in reactions: - try: - await eq_message.add_reaction(reaction) - except discord.errors.NotFound: - pass + start_adding_reactions(eq_message, reactions, self.bot.loop) eq_msg_with_reacts = await ctx.fetch_message(eq_message.id) player.store("eq_message", eq_msg_with_reacts) await self._eq_interact(ctx, player, eq, eq_msg_with_reacts, 0) - @eq.command(name="delete") - async def _eq_delete(self, ctx, eq_preset: str): + @eq.command(name="delete", aliases=["del", "remove"]) + async def _eq_delete(self, ctx: commands.Context, eq_preset: str): """Delete a saved eq preset.""" async with self.config.custom("EQUALIZER", ctx.guild.id).eq_presets() as eq_presets: eq_preset = eq_preset.lower() @@ -909,7 +1727,7 @@ class Audio(commands.Cog): ) @eq.command(name="list") - async def _eq_list(self, ctx): + async def _eq_list(self, ctx: commands.Context): """List saved eq presets.""" eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets() if not eq_presets.keys(): @@ -927,18 +1745,17 @@ class Audio(commands.Cog): preset_list = "" for preset, bands in eq_presets.items(): try: - bands["author"] author = self.bot.get_user(bands["author"]) except TypeError: author = "None" - msg = f"{preset}{space*(22 - len(preset))}{author}\n" + msg = f"{preset}{space * (22 - len(preset))}{author}\n" preset_list += msg page_list = [] 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}") + colour=await ctx.embed_colour(), description=f"{header}\n{formatted_page}" ) embed.set_footer( text=_("{num} preset(s)").format(num=humanize_number(len(list(eq_presets.keys())))) @@ -949,7 +1766,7 @@ class Audio(commands.Cog): await menu(ctx, page_list, DEFAULT_CONTROLS) @eq.command(name="load") - async def _eq_load(self, ctx, eq_preset: str): + async def _eq_load(self, ctx: commands.Context, eq_preset: str): """Load a saved eq preset.""" eq_preset = eq_preset.lower() eq_presets = await self.config.custom("EQUALIZER", ctx.guild.id).eq_presets() @@ -987,7 +1804,7 @@ class Audio(commands.Cog): player.store("eq_message", message) @eq.command(name="reset") - async def _eq_reset(self, ctx): + 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.")) @@ -1016,8 +1833,8 @@ class Audio(commands.Cog): player.store("eq_message", message) @eq.command(name="save") - @commands.cooldown(1, 15, discord.ext.commands.BucketType.guild) - async def _eq_save(self, ctx, eq_preset: str = None): + @commands.cooldown(1, 15, commands.BucketType.guild) + 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.")) @@ -1084,11 +1901,12 @@ class Audio(commands.Cog): await ctx.send(embed=embed3) @eq.command(name="set") - async def _eq_set(self, ctx, band_name_or_position, band_value: float): + async def _eq_set(self, ctx: commands.Context, band_name_or_position, band_value: float): """Set an eq band with a band number or name and value. Band positions are 1-15 and values have a range of -0.25 to 1.0. - Band names are 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k, 6.3k, 10k, and 16k Hz. + Band names are 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k, + 6.3k, 10k, and 16k Hz. Setting a band value to -0.25 nullifies it while +0.25 is double. """ if not self._player_check(ctx): @@ -1138,7 +1956,8 @@ class Audio(commands.Cog): return await self._embed_msg( ctx, _( - "Valid band numbers are 1-15 or the band names listed in the help for this command." + "Valid band numbers are 1-15 or the band names listed in " + "the help for this command." ), ) @@ -1172,40 +1991,45 @@ class Audio(commands.Cog): @commands.group() @commands.guild_only() @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def local(self, ctx): + async def local(self, ctx: commands.Context): """Local playback commands.""" - pass @local.command(name="folder", aliases=["start"]) - async def local_folder(self, ctx, folder=None): + async def local_folder( + self, ctx: commands.Context, play_subfolders: Optional[bool] = True, *, folder: str = None + ): """Play all songs in a localtracks folder.""" if not await self._localtracks_check(ctx): return + if not folder: - await ctx.invoke(self.local_play) + await ctx.invoke(self.local_play, play_subfolders=play_subfolders) else: - try: - folder_path = os.getcwd() + "/localtracks/{}/".format(folder) - os.listdir(folder_path) - except OSError: + folder = folder.strip() + _dir = dataclasses.LocalPath.joinpath(folder) + if not _dir.exists(): return await self._embed_msg( ctx, _("No localtracks folder named {name}.").format(name=folder) ) - await self._local_play_all(ctx, folder) + query = dataclasses.Query.process_input(_dir, search_subfolders=play_subfolders) + await self._local_play_all(ctx, query, from_search=False if not folder else True) @local.command(name="play") - async def local_play(self, ctx): + async def local_play(self, ctx: commands.Context, play_subfolders: Optional[bool] = True): """Play a local track.""" if not await self._localtracks_check(ctx): return - localtracks_folders = await self._localtracks_folders(ctx) + localtracks_folders = await self._localtracks_folders( + ctx, search_subfolders=play_subfolders + ) if not localtracks_folders: - return await self._embed_msg(ctx, _("No local track folders found.")) - len_folder_pages = math.ceil(len(localtracks_folders) / 5) - folder_page_list = [] - for page_num in range(1, len_folder_pages + 1): - embed = await self._build_search_page(ctx, localtracks_folders, page_num) - folder_page_list.append(embed) + return await self._embed_msg(ctx, _("No album folders found.")) + async with ctx.typing(): + len_folder_pages = math.ceil(len(localtracks_folders) / 5) + folder_page_list = [] + for page_num in range(1, len_folder_pages + 1): + embed = await self._build_search_page(ctx, localtracks_folders, page_num) + folder_page_list.append(embed) async def _local_folder_menu( ctx: commands.Context, @@ -1217,11 +2041,12 @@ class Audio(commands.Cog): emoji: str, ): if message: - await message.delete() + with contextlib.suppress(discord.HTTPException): + await message.delete() await self._search_button_action(ctx, localtracks_folders, emoji, page) return None - LOCAL_FOLDER_CONTROLS = { + local_folder_controls = { "1⃣": _local_folder_menu, "2⃣": _local_folder_menu, "3⃣": _local_folder_menu, @@ -1233,139 +2058,137 @@ class Audio(commands.Cog): } 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, folder_page_list, DEFAULT_CONTROLS) - else: - await menu(ctx, folder_page_list, LOCAL_FOLDER_CONTROLS) + if dj_enabled and not await self._can_instaskip(ctx, ctx.author): + return await menu(ctx, folder_page_list, DEFAULT_CONTROLS) else: - await menu(ctx, folder_page_list, LOCAL_FOLDER_CONTROLS) + await menu(ctx, folder_page_list, local_folder_controls) @local.command(name="search") - async def local_search(self, ctx, *, search_words): + async def local_search( + self, ctx: commands.Context, play_subfolders: Optional[bool] = True, *, search_words + ): """Search for songs across all localtracks folders.""" if not await self._localtracks_check(ctx): return - localtracks_folders = await self._localtracks_folders(ctx) - if not localtracks_folders: + all_tracks = await self._folder_list( + ctx, + ( + dataclasses.Query.process_input( + dataclasses.LocalPath( + await self.config.localpath() + ).localtrack_folder.absolute(), + search_subfolders=play_subfolders, + ) + ), + ) + if not all_tracks: return await self._embed_msg(ctx, _("No album folders found.")) - all_tracks = [] - for local_folder in localtracks_folders: - folder_tracks = await self._folder_list(ctx, local_folder) - all_tracks = all_tracks + folder_tracks - search_list = await self._build_local_search_list(all_tracks, search_words) + 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.")) - await ctx.invoke(self.search, query=search_list) + return await ctx.invoke(self.search, query=search_list) - async def _all_folder_tracks(self, ctx, folder): + async def _localtracks_folders(self, ctx: commands.Context, search_subfolders=False): + audio_data = dataclasses.LocalPath( + dataclasses.LocalPath(None).localtrack_folder.absolute() + ) if not await self._localtracks_check(ctx): return - allowed_files = (".mp3", ".flac", ".ogg") - current_folder = os.getcwd() + "/localtracks/{}/".format(folder) - folder_list = sorted( - ( - f - for f in os.listdir(current_folder) - if (f.lower().endswith(allowed_files)) and (os.path.isfile(current_folder + f)) - ), - key=lambda s: s.casefold(), + + return audio_data.subfolders_in_tree() if search_subfolders else audio_data.subfolders() + + async def _folder_list(self, ctx: commands.Context, query: dataclasses.Query): + if not await self._localtracks_check(ctx): + return + query = dataclasses.Query.process_input(query) + if not query.track.exists(): + return + return ( + query.track.tracks_in_tree() + if query.search_subfolders + else query.track.tracks_in_folder() ) - track_listing = [] - for localtrack_location in folder_list: - track_listing.append(localtrack_location) - return track_listing + + async def _folder_tracks( + self, ctx, player: lavalink.player_manager.Player, query: dataclasses.Query + ): + if not await self._localtracks_check(ctx): + return + + audio_data = dataclasses.LocalPath(None) + try: + query.track.path.relative_to(audio_data.to_string()) + except ValueError: + return + local_tracks = [] + for local_file in await self._all_folder_tracks(ctx, query): + trackdata, called_api = await self.music_cache.lavalink_query(ctx, player, local_file) + with contextlib.suppress(IndexError): + local_tracks.append(trackdata.tracks[0]) + return local_tracks + + async def _local_play_all( + self, ctx: commands.Context, query: dataclasses.Query, from_search=False + ): + if not await self._localtracks_check(ctx): + return + if from_search: + query = dataclasses.Query.process_input( + query.track.to_string(), invoked_from="local folder" + ) + await ctx.invoke(self.search, query=query) + + async def _all_folder_tracks(self, ctx: commands.Context, query: dataclasses.Query): + if not await self._localtracks_check(ctx): + return + + return ( + query.track.tracks_in_tree() + if query.search_subfolders + else query.track.tracks_in_folder() + ) + + async def _localtracks_check(self, ctx: commands.Context): + folder = dataclasses.LocalPath(None) + if folder.localtrack_folder.exists(): + return True + if ctx.invoked_with != "start": + await self._embed_msg(ctx, _("No localtracks folder.")) + return False @staticmethod async def _build_local_search_list(to_search, search_words): - search_results = process.extract(search_words, to_search, limit=50) + to_search_string = {i.track.name for i in to_search} + search_results = process.extract(search_words, to_search_string, limit=50) search_list = [] for track_match, percent_match in search_results: - if percent_match > 75: - search_list.append(track_match) - return search_list - - async def _folder_list(self, ctx, folder): - if not await self._localtracks_check(ctx): - return - if not os.path.isdir(os.getcwd() + "/localtracks/{}/".format(folder)): - return - allowed_files = (".mp3", ".flac", ".ogg") - folder_list = sorted( - ( - os.getcwd() + "/localtracks/{}/{}".format(folder, f) - for f in os.listdir(os.getcwd() + "/localtracks/{}/".format(folder)) - if (f.lower().endswith(allowed_files)) - and (os.path.isfile(os.getcwd() + "/localtracks/{}/{}".format(folder, f))) - ), - key=lambda s: s.casefold(), - ) - track_listing = [] - if ctx.invoked_with == "search": - local_path = await self.config.localpath() - for localtrack_location in folder_list: - track_listing.append( - localtrack_location.replace("{}/localtracks/".format(local_path), "") + if percent_match > 60: + search_list.extend( + [i.track.to_string_hidden() for i in to_search if i.track.name == track_match] ) - else: - for localtrack_location in folder_list: - localtrack_location = "localtrack:{}".format(localtrack_location) - track_listing.append(localtrack_location) - return track_listing - - async def _folder_tracks(self, ctx, player, folder): - if not await self._localtracks_check(ctx): - return - if not os.path.isdir(os.getcwd() + "/localtracks/{}/".format(folder)): - return - local_tracks = [] - for local_file in await self._all_folder_tracks(ctx, folder): - track = await player.get_tracks("localtracks/{}/{}".format(folder, local_file)) - try: - local_tracks.append(track[0]) - except IndexError: - pass - return local_tracks - - async def _local_play_all(self, ctx, folder): - if not await self._localtracks_check(ctx): - return - await ctx.invoke(self.search, query=("folder:" + folder)) - - async def _localtracks_check(self, ctx): - audio_data = await self.config.localpath() - if os.getcwd() != audio_data: - os.chdir(audio_data) - localtracks_folder = any( - f for f in os.listdir(os.getcwd()) if not os.path.isfile(f) if f == "localtracks" - ) - if not localtracks_folder: - if ctx.invoked_with == "start": - return False - else: - await self._embed_msg(ctx, _("No localtracks folder.")) - return False - else: - return True + return search_list @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def now(self, ctx): + async def now(self, ctx: commands.Context): """Now playing.""" if not self._player_check(ctx): return await self._embed_msg(ctx, _("Nothing playing.")) - expected = ("⏮", "⏹", "⏸", "⏭") - emoji = {"prev": "⏮", "stop": "⏹", "pause": "⏸", "next": "⏭"} + expected = ("⏮", "⏹", "⏯", "⏭") + emoji = {"prev": "⏮", "stop": "⏹", "pause": "⏯", "next": "⏭"} player = lavalink.get_player(ctx.guild.id) if player.current: - arrow = await self._draw_time(ctx) + arrow = await draw_time(ctx) pos = lavalink.utils.format_time(player.position) if player.current.is_stream: dur = "LIVE" else: dur = lavalink.utils.format_time(player.current.length) - if "localtracks" in player.current.uri: + if any( + x in player.current.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): if not player.current.title == "Unknown title": song = "**{track.author} - {track.title}**\n{uri}\n" else: @@ -1376,7 +2199,12 @@ class Audio(commands.Cog): song += "\n\n{arrow}`{pos}`/`{dur}`" song = song.format( track=player.current, - uri=player.current.uri.replace("localtracks/", ""), + uri=dataclasses.LocalPath(player.current.uri).to_string_hidden() + if any( + x in player.current.uri + for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ) + else player.current.uri, arrow=arrow, pos=pos, dur=dur, @@ -1385,10 +2213,8 @@ class Audio(commands.Cog): song = _("Nothing.") if player.fetch("np_message") is not None: - try: + with contextlib.suppress(discord.HTTPException): await player.fetch("np_message").delete() - except discord.errors.NotFound: - pass embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Now Playing"), description=song @@ -1396,7 +2222,32 @@ class Audio(commands.Cog): 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 + text = "" + text += ( + _("Auto-Play") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}") + ) + text += ( + (" | " if text else "") + + _("Shuffle") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}") + ) + text += ( + (" | " if text else "") + + _("Repeat") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}") + ) + embed.set_footer(text=text) + message = await ctx.send(embed=embed) + player.store("np_message", message) dj_enabled = await self.config.guild(ctx.guild).dj_enabled() @@ -1441,7 +2292,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def pause(self, ctx): + async def pause(self, ctx: commands.Context): """Pause or resume a playing track.""" dj_enabled = await self.config.guild(ctx.guild).dj_enabled() if not self._player_check(ctx): @@ -1463,12 +2314,13 @@ class Audio(commands.Cog): if not player.current: return await self._embed_msg(ctx, _("Nothing playing.")) - if "localtracks/" in player.current.uri: + if any(x in player.current.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): + query = dataclasses.Query.process_input(player.current.uri) if player.current.title == "Unknown title": - description = player.current.uri + description = "{}".format(query.track.to_string_hidden()) else: song = bold("{} - {}").format(player.current.author, player.current.title) - description = "{}\n{}".format(song, player.current.uri.replace("localtracks/", "")) + description = "{}\n{}".format(song, query.track.to_string_hidden()) else: description = bold("[{}]({})").format(player.current.title, player.current.uri) @@ -1490,7 +2342,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def percent(self, ctx): + async def percent(self, ctx: commands.Context): """Queue percentage.""" if not self._player_check(ctx): return await self._embed_msg(ctx, _("Nothing playing.")) @@ -1517,7 +2369,7 @@ class Audio(commands.Cog): ) await _usercount(req_username) except AttributeError: - return await self._embed_msg(ctx, _("Nothing in the queue.")) + return await self._embed_msg(ctx, _("There's nothing in the queue.")) for req_username in requesters["users"]: percentage = float(requesters["users"][req_username]["songcount"]) / float( @@ -1547,15 +2399,14 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def play(self, ctx, *, query): + async def play(self, ctx: commands.Context, *, query: str): """Play a URL or search for a track.""" guild_data = await self.config.guild(ctx.guild).all() restrict = await self.config.restrict() - if restrict: - if self._match_url(query): - url_check = self._url_check(query) - if not url_check: - return await self._embed_msg(ctx, _("That URL is not allowed.")) + 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.")) if not self._player_check(ctx): if self._connection_aborted: msg = _("Connection to Lavalink has failed.") @@ -1566,7 +2417,7 @@ class Audio(commands.Cog): if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect or not ctx.author.voice.channel.permissions_for(ctx.me).move_members - and self._userlimit(ctx.author.voice.channel) + and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( ctx, _("I don't have permission to connect to your channel.") @@ -1597,146 +2448,417 @@ class Audio(commands.Cog): ) if not await self._currency_check(ctx, guild_data["jukebox_price"]): return - - if not query: + query = dataclasses.Query.process_input(query) + if not query.valid: return await self._embed_msg(ctx, _("No tracks to play.")) - query = query.strip("<>") - - if "open.spotify.com" in query: - query = "spotify:{}".format( - re.sub("(http[s]?:\/\/)?(open.spotify.com)\/", "", query).replace("/", ":") - ) - if query.startswith("spotify:"): + if query.is_spotify: return await self._get_spotify_tracks(ctx, query) - - if query.startswith("localtrack:"): - local_path = await self.config.localpath() - await self._localtracks_check(ctx) - query = query.replace("localtrack:", "").replace(((local_path) + "/"), "") - allowed_files = (".mp3", ".flac", ".ogg") - if not self._match_url(query) and not (query.lower().endswith(allowed_files)): - query = "ytsearch:{}".format(query) - await self._enqueue_tracks(ctx, query) - async def _get_spotify_tracks(self, ctx, query): - if ctx.invoked_with == "play": + @commands.command() + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + async def genre(self, ctx: commands.Context): + """Pick a Spotify playlist from a list of categories to start playing.""" + + async def _category_search_menu( + ctx: commands.Context, + pages: list, + controls: dict, + message: discord.Message, + page: int, + timeout: float, + emoji: str, + ): + if message: + output = await self._genre_search_button_action(ctx, category_list, emoji, page) + with contextlib.suppress(discord.HTTPException): + await message.delete() + return output + + async def _playlist_search_menu( + ctx: commands.Context, + pages: list, + controls: dict, + message: discord.Message, + page: int, + timeout: float, + emoji: str, + ): + if message: + output = await self._genre_search_button_action( + ctx, playlists_list, emoji, page, playlist=True + ) + with contextlib.suppress(discord.HTTPException): + await message.delete() + return output + + category_search_controls = { + "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, + } + 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, + } + + api_data = await self._check_api_tokens() + if any( + [ + not api_data["spotify_client_id"], + not api_data["spotify_client_secret"], + not api_data["youtube_api"], + ] + ): + return await self._embed_msg( + ctx, + _( + "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` " + "for instructions." + ).format(prefix=ctx.prefix), + ) + 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.") + if await ctx.bot.is_owner(ctx.author): + msg += " " + _("Please check your console or logs for details.") + return await self._embed_msg(ctx, msg) + 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, _("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: + return await self._embed_msg( + ctx, _("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.")) + 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, _("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)) + if not category_list: + return await self._embed_msg(ctx, _("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): + embed = await self._build_genre_search_page( + ctx, category_list, page_num, _("Categories") + ) + category_search_page_list.append(embed) + cat_menu_output = await menu(ctx, category_search_page_list, category_search_controls) + if not cat_menu_output: + return await self._embed_msg(ctx, _("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.")) + len_folder_pages = math.ceil(len(playlists_list) / 5) + playlists_search_page_list = [] + for page_num in range(1, len_folder_pages + 1): + embed = await self._build_genre_search_page( + ctx, + playlists_list, + page_num, + _("Playlists for {friendly_name}").format(friendly_name=category_name), + playlist=True, + ) + playlists_search_page_list.append(embed) + playlists_pick = await menu(ctx, playlists_search_page_list, playlist_search_controls) + query = dataclasses.Query.process_input(playlists_pick) + if not query.valid: + return await self._embed_msg(ctx, _("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.")) + + @staticmethod + async def _genre_search_button_action( + ctx: commands.Context, options, emoji, page, playlist=False + ): + try: + if emoji == "1⃣": + search_choice = options[0 + (page * 5)] + elif emoji == "2⃣": + search_choice = options[1 + (page * 5)] + elif emoji == "3⃣": + search_choice = options[2 + (page * 5)] + elif emoji == "4⃣": + search_choice = options[3 + (page * 5)] + elif emoji == "5⃣": + 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: + return list(search_choice.items())[0] + else: + return search_choice.get("uri") + + @staticmethod + async def _build_genre_search_page( + ctx: commands.Context, tracks, page_num, title, playlist=False + ): + search_num_pages = math.ceil(len(tracks) / 5) + search_idx_start = (page_num - 1) * 5 + search_idx_end = search_idx_start + 5 + search_list = "" + for i, entry in enumerate(tracks[search_idx_start:search_idx_end], start=search_idx_start): + search_track_num = i + 1 + if search_track_num > 5: + search_track_num = search_track_num % 5 + if search_track_num == 0: + search_track_num = 5 + if playlist: + name = "**[{}]({})** - {}".format( + entry.get("name"), + entry.get("url"), + str(entry.get("tracks")) + " " + _("tracks"), + ) + else: + name = f"{list(entry.keys())[0]}" + search_list += "`{}.` {}\n".format(search_track_num, name) + + embed = discord.Embed( + colour=await ctx.embed_colour(), title=title, description=search_list + ) + embed.set_footer( + text=_("Page {page_num}/{total_pages}").format( + page_num=page_num, total_pages=search_num_pages + ) + ) + return embed + + @commands.command() + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True) + @checks.mod_or_permissions(manage_messages=True) + async def autoplay(self, ctx: commands.Context): + """Starts auto play.""" + if not self._player_check(ctx): + if self._connection_aborted: + msg = _("Connection to Lavalink has failed.") + if await ctx.bot.is_owner(ctx.author): + msg += " " + _("Please check your console or logs for details.") + return await self._embed_msg(ctx, msg) + 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, _("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: + return await self._embed_msg( + ctx, _("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.")) + 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, _("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: + await self.music_cache.autoplay(player) + else: + self.bot.dispatch( + "red_audio_should_auto_play", + player, + player.channel.guild, + player.channel, + self.play_query, + ) + 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.")) + elif player.current: + await self._embed_msg(ctx, _("Adding a track to queue.")) + + async def _get_spotify_tracks(self, ctx: commands.Context, query: dataclasses.Query): + if ctx.invoked_with in ["play", "genre"]: enqueue_tracks = True else: enqueue_tracks = False player = lavalink.get_player(ctx.guild.id) api_data = await self._check_api_tokens() - guild_data = await self.config.guild(ctx.guild).all() - if "open.spotify.com" in query: - query = "spotify:{}".format( - re.sub("(http[s]?:\/\/)?(open.spotify.com)\/", "", query).replace("/", ":") + + if ( + not api_data["spotify_client_id"] + or not api_data["spotify_client_secret"] + or not api_data["youtube_api"] + ): + return await self._embed_msg( + ctx, + _( + "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` " + "for instructions." + ).format(prefix=ctx.prefix), ) - if query.startswith("spotify:"): - if ( - not api_data["spotify_client_id"] - or not api_data["spotify_client_secret"] - or not api_data["youtube_api"] - ): + try: + if self.play_lock[ctx.message.guild.id]: + return await self._embed_msg( + ctx, _("Wait until the playlist has finished loading.") + ) + except KeyError: + pass + + if query.single_track: + try: + res = await self.music_cache.spotify_query( + ctx, "track", query.id, skip_youtube=True, notifier=None + ) + if not res: + return await self._embed_msg(ctx, _("Nothing found.")) + except SpotifyFetchError as error: + self._play_lock(ctx, False) + return await self._embed_msg(ctx, _(error.message).format(prefix=ctx.prefix)) + self._play_lock(ctx, False) + try: + if enqueue_tracks: + new_query = dataclasses.Query.process_input(res[0]) + 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, dataclasses.Query.process_input(res[0]) + ) + tracks = result.tracks + if not tracks: + return await self._embed_msg(ctx, _("Nothing found.")) + single_track = tracks[0] + single_track.start_timestamp = query.start_time * 1000 + single_track = [single_track] + + return single_track + + except KeyError: + self._play_lock(ctx, False) return await self._embed_msg( ctx, _( - "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` " - "for instructions." + "The Spotify API key or client secret has not been set properly. " + "\nUse `{prefix}audioset spotifyapi` for instructions." ).format(prefix=ctx.prefix), ) - try: - if self.play_lock[ctx.message.guild.id]: - return await self._embed_msg( - ctx, _("Wait until the playlist has finished loading.") - ) - except KeyError: - pass + elif query.is_album or query.is_playlist: + self._play_lock(ctx, True) + track_list = await self._spotify_playlist( + ctx, "album" if query.is_album else "playlist", query, enqueue_tracks + ) + self._play_lock(ctx, False) + return track_list + else: + return await self._embed_msg( + ctx, _("This doesn't seem to be a supported Spotify URL or code.") + ) - parts = query.split(":") - if "track" in parts: - res = await self._make_spotify_req( - "https://api.spotify.com/v1/tracks/{0}".format(parts[-1]) - ) - try: - query = "{} {}".format(res["artists"][0]["name"], res["name"]) - if enqueue_tracks: - return await self._enqueue_tracks(ctx, query) - else: - tracks = await player.get_tracks(f"ytsearch:{query}") - if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) - single_track = [] - single_track.append(tracks[0]) - return single_track - - except KeyError: - return await self._embed_msg( - ctx, - _( - "The Spotify API key or client secret has not been set properly. " - "\nUse `{prefix}audioset spotifyapi` for instructions." - ).format(prefix=ctx.prefix), - ) - elif "album" in parts: - query = parts[-1] - self._play_lock(ctx, True) - track_list = await self._spotify_playlist( - ctx, "album", api_data["youtube_api"], query - ) - if not track_list: - self._play_lock(ctx, False) - return - if enqueue_tracks: - return await self._enqueue_tracks(ctx, track_list) - else: - return track_list - elif "playlist" in parts: - query = parts[-1] - self._play_lock(ctx, True) - if "user" in parts: - track_list = await self._spotify_playlist( - ctx, "user_playlist", api_data["youtube_api"], query - ) - else: - track_list = await self._spotify_playlist( - ctx, "playlist", api_data["youtube_api"], query - ) - if not track_list: - self._play_lock(ctx, False) - return - if enqueue_tracks: - return await self._enqueue_tracks(ctx, track_list) - else: - return track_list - - else: - return await self._embed_msg( - ctx, _("This doesn't seem to be a valid Spotify URL or code.") - ) - - async def _enqueue_tracks(self, ctx, query): + async def _enqueue_tracks(self, ctx: commands.Context, query: Union[dataclasses.Query, list]): 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.") + ) + except KeyError: + self._play_lock(ctx, True) guild_data = await self.config.guild(ctx.guild).all() first_track_only = False + index = None + playlist_data = None + seek = 0 if type(query) is not list: - if not ( - query.startswith("http") - or query.startswith("localtracks") - or query.startswith("ytsearch:") - ): - query = f"ytsearch:{query}" - if query.startswith(("ytsearch", "localtracks")): + + if query.single_track: first_track_only = True - tracks = await player.get_tracks(query) + index = query.track_index + if query.start_time: + seek = query.start_time + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + tracks = result.tracks + playlist_data = result.playlist_info if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) + self._play_lock(ctx, False) + embed = discord.Embed(title=_("Nothing found."), colour=await ctx.embed_colour()) + 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." + ) + return await ctx.send(embed=embed) else: tracks = query - - queue_duration = await self._queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_duration) + queue_dur = await queue_duration(ctx) + queue_total_duration = lavalink.utils.format_time(queue_dur) before_queue_length = len(player.queue) if not first_track_only and len(tracks) > 1: @@ -1745,14 +2867,32 @@ class Audio(commands.Cog): # url where Lavalink handles providing all Track objects to use, like a # YouTube or Soundcloud playlist track_len = 0 + empty_queue = not player.queue for track in tracks: - if guild_data["maxlength"] > 0: - if self._track_limit(ctx, track, guild_data["maxlength"]): + if not await is_allowed( + ctx.guild, + ( + f"{track.title} {track.author} {track.uri} " + f"{str(dataclasses.Query.process_input(track))}" + ), + ): + log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") + continue + elif guild_data["maxlength"] > 0: + if track_limit(track, guild_data["maxlength"]): track_len += 1 player.add(ctx.author, track) + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, track, ctx.author + ) + else: track_len += 1 player.add(ctx.author, track) + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, track, ctx.author + ) + player.maybe_shuffle(0 if empty_queue else 1) if len(tracks) > track_len: maxlength_msg = " {bad_tracks} tracks cannot be queued.".format( @@ -1762,12 +2902,17 @@ class Audio(commands.Cog): maxlength_msg = "" embed = discord.Embed( colour=await ctx.embed_colour(), - title=_("Playlist Enqueued"), - description=_("Added {num} tracks to the queue.{maxlength_msg}").format( - num=track_len, maxlength_msg=maxlength_msg + description="{name}".format( + name=playlist_data.name if playlist_data else _("No Title") ), + title=_("Playlist Enqueued"), ) - if not guild_data["shuffle"] and queue_duration > 0: + embed.set_footer( + text=_("Added {num} tracks to the queue.{maxlength_msg}").format( + num=track_len, maxlength_msg=maxlength_msg + ) + ) + if not guild_data["shuffle"] and queue_dur > 0: embed.set_footer( text=_( "{time} until start of playlist playback: starts at #{position} in queue" @@ -1780,455 +2925,1219 @@ class Audio(commands.Cog): # this is in the case of [p]play , a single Spotify url/code # or this is a localtrack item try: - single_track = tracks[0] - if guild_data["maxlength"] > 0: - if self._track_limit(ctx, single_track, guild_data["maxlength"]): + + single_track = 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(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, _("This track is not allowed in this server.") + ) + elif guild_data["maxlength"] > 0: + if track_limit(single_track, guild_data["maxlength"]): player.add(ctx.author, single_track) + player.maybe_shuffle() + self.bot.dispatch( + "red_audio_track_enqueue", + player.channel.guild, + single_track, + ctx.author, + ) else: + self._play_lock(ctx, False) return await self._embed_msg(ctx, _("Track exceeds maximum length.")) else: player.add(ctx.author, single_track) + player.maybe_shuffle() + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, single_track, ctx.author + ) except IndexError: + self._play_lock(ctx, False) return await self._embed_msg( ctx, _("Nothing found. Check your Lavalink logs for details.") ) - - if "localtracks" in single_track.uri: - if not single_track.title == "Unknown title": + if any( + x in single_track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): + if single_track.title != "Unknown title": description = "**{} - {}**\n{}".format( single_track.author, single_track.title, - single_track.uri.replace("localtracks/", ""), + dataclasses.LocalPath(single_track.uri).to_string_hidden(), ) else: - description = "{}".format(single_track.uri.replace("localtracks/", "")) + description = "{}".format( + 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 ) - if not guild_data["shuffle"] and queue_duration > 0: + if not guild_data["shuffle"] and queue_dur > 0: embed.set_footer( text=_("{time} until track playback: #{position} in queue").format( time=queue_total_duration, position=before_queue_length + 1 ) ) - elif queue_duration > 0: - embed.set_footer(text=_("#{position} in queue").format(position=len(player.queue))) - if not player.current: - await player.play() + await ctx.send(embed=embed) - if type(query) is list: - self._play_lock(ctx, False) + if not player.current: + await player.play() + + self._play_lock(ctx, False) + + async def _spotify_playlist( + self, ctx: commands.Context, stype: str, query: dataclasses.Query, enqueue: bool = False + ): - async def _spotify_playlist(self, ctx, stype, yt_key, query): player = lavalink.get_player(ctx.guild.id) - spotify_info = [] - if stype == "album": - r = await self._make_spotify_req("https://api.spotify.com/v1/albums/{0}".format(query)) - else: - r = await self._make_spotify_req( - "https://api.spotify.com/v1/playlists/{0}/tracks".format(query) - ) try: - if r["error"]["status"] == 401: - return await self._embed_msg( - ctx, - _( - "The Spotify API key or client secret has not been set properly. " - "\nUse `{prefix}audioset spotifyapi` for instructions." - ).format(prefix=ctx.prefix), - ) - except KeyError: - pass - while True: - try: - try: - spotify_info.extend(r["tracks"]["items"]) - except KeyError: - spotify_info.extend(r["items"]) - except KeyError: - return await self._embed_msg( - ctx, _("This doesn't seem to be a valid Spotify URL or code.") - ) + embed1 = discord.Embed( + colour=await ctx.embed_colour(), title=_("Please wait, finding tracks...") + ) + playlist_msg = await ctx.send(embed=embed1) + notifier = Notifier( + ctx, + playlist_msg, + { + "spotify": _("Getting track {num}/{total}..."), + "youtube": _("Matching track {num}/{total}..."), + "lavalink": _("Loading track {num}/{total}..."), + "lavalink_time": _("Approximate time remaining: {seconds}"), + }, + ) + track_list = await self.music_cache.spotify_enqueue( + ctx, + stype, + query.id, + enqueue=enqueue, + player=player, + lock=self._play_lock, + notifier=notifier, + ) + except SpotifyFetchError as error: + self._play_lock(ctx, False) + return await self._embed_msg(ctx, _(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."), + ) + await ctx.send(embed=error_embed) + return None + except Exception as e: + self._play_lock(ctx, False) + raise e + self._play_lock(ctx, False) + return track_list - try: - if r["next"] is not None: - r = await self._make_spotify_req(r["next"]) - continue - else: - break - except KeyError: - if r["tracks"]["next"] is not None: - r = await self._make_spotify_req(r["tracks"]["next"]) - continue - else: - break + async def can_manage_playlist( + self, scope: str, playlist: Playlist, ctx: commands.Context, user, guild + ): - embed1 = discord.Embed( - colour=await ctx.embed_colour(), title=_("Please wait, adding tracks...") + is_owner = await ctx.bot.is_owner(ctx.author) + has_perms = False + user_to_query = user + guild_to_query = guild + dj_enabled = None + playlist_author = ( + guild.get_member(playlist.author) + if guild + else self.bot.get_user(playlist.author) or user ) - playlist_msg = await ctx.send(embed=embed1) - track_list = [] - track_count = 0 - now = int(time.time()) - for i in spotify_info: - if stype == "album": - song_info = "{} {}".format(i["name"], i["artists"][0]["name"]) - else: - song_info = "{} {}".format(i["track"]["name"], i["track"]["artists"][0]["name"]) - try: - track_url = await self._youtube_api_search(yt_key, song_info) - except (RuntimeError, aiohttp.client_exceptions.ServerDisconnectedError): - error_embed = discord.Embed( - colour=await ctx.embed_colour(), - title=_("The connection was reset while loading the playlist."), - ) - await playlist_msg.edit(embed=error_embed) - return None - try: - yt_track = await player.get_tracks(track_url) - except (RuntimeError, aiohttp.client_exceptions.ServerDisconnectedError): - return - try: - track_list.append(yt_track[0]) - except IndexError: - pass - track_count += 1 - if (track_count % 5 == 0) or (track_count == len(spotify_info)): - embed2 = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Loading track {num}/{total}...").format( - num=track_count, total=len(spotify_info) + is_different_user = len({playlist.author, user_to_query.id, ctx.author.id}) != 1 + is_different_guild = True if guild_to_query is None else ctx.guild.id != guild_to_query.id + + if is_owner: + has_perms = True + elif playlist.scope == PlaylistScope.USER.value: + if not is_different_user: + has_perms = True + elif playlist.scope == PlaylistScope.GUILD.value: + if not is_different_guild: + dj_enabled = 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): + has_perms = True + elif await ctx.bot.is_mod(ctx.author): + has_perms = True + elif not dj_enabled and not is_different_user: + has_perms = True + + if has_perms is False: + if hasattr(playlist, "name"): + msg = _( + "You do not have the permissions to manage {name} " "(`{id}`) [**{scope}**]." + ).format( + user=playlist_author, + name=playlist.name, + id=playlist.id, + scope=humanize_scope( + playlist.scope, + ctx=guild_to_query + if playlist.scope == PlaylistScope.GUILD.value + else playlist_author + if playlist.scope == PlaylistScope.USER.value + else None, ), ) - if track_count == 5: - five_time = int(time.time()) - now - if track_count >= 5: - remain_tracks = len(spotify_info) - track_count - time_remain = (remain_tracks / 5) * five_time - if track_count < len(spotify_info): - seconds = self._dynamic_time(int(time_remain)) - if track_count == len(spotify_info): - seconds = "0s" - embed2.set_footer( - text=_("Approximate time remaining: {seconds}").format(seconds=seconds) - ) - try: - await playlist_msg.edit(embed=embed2) - except discord.errors.NotFound: - pass + elif playlist.scope == PlaylistScope.GUILD.value and ( + is_different_guild or dj_enabled + ): + msg = _( + "You do not have the permissions to manage that playlist in {guild}." + ).format(guild=guild_to_query) + elif ( + playlist.scope in [PlaylistScope.GUILD.value, PlaylistScope.USER.value] + and is_different_user + ): + msg = _( + "You do not have the permissions to manage playlist owned by {user}." + ).format(user=playlist_author) + else: + msg = _( + "You do not have the permissions to manage " + "playlists in {scope} scope.".format(scope=humanize_scope(scope, the=True)) + ) - if len(track_list) == 0: - embed3 = discord.Embed( - colour=await ctx.embed_colour(), - title=_( - "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), + await self._embed_msg(ctx, msg) + return False + return True + + async def _get_correct_playlist_id( + self, + context: commands.Context, + matches: dict, + scope: str, + author: discord.User, + guild: discord.Guild, + specified_user: bool = False, + ) -> Tuple[Optional[int], str]: + """ + Parameters + ---------- + context: commands.Context + The context in which this is being called. + matches: dict + A dict of the matches found where key is scope and value is matches. + scope:str + The custom config scope. A value from :code:`PlaylistScope`. + author: discord.User + The user. + guild: discord.Guild + The guild. + specified_user: bool + Whether or not a user ID was specified via argparse. + Returns + ------- + Tuple[Optional[int], str] + Tuple of Playlist ID or None if none found and original user input. + Raises + ------ + `TooManyMatches` + When more than 10 matches are found or + When multiple matches are found but none is selected. + + """ + original_input = matches.get("arg") + correct_scope_matches = matches.get(scope) + guild_to_query = guild.id + user_to_query = author.id + if not correct_scope_matches: + 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] + ] + 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 + ] + 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] + ] + 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 + ] + else: + correct_scope_matches = [ + (i[2]["id"], i[2]["name"], len(i[2]["tracks"]), i[2]["author"]) + for i in correct_scope_matches + ] + 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 + ] + 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." + ) + elif match_count == 1: + return correct_scope_matches[0][0], 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" ) - try: - return await playlist_msg.edit(embed=embed3) - except discord.errors.NotFound: - pass + playlists += line + + embed = discord.Embed( + title="Playlists found, which one would you like?", + description=box(playlists, lang="md"), + colour=await context.embed_colour(), + ) + msg = await context.send(embed=embed) + avaliable_emojis = ReactionPredicate.NUMBER_EMOJIS[1:] + avaliable_emojis.append("🔟") + emojis = avaliable_emojis[: len(correct_scope_matches)] + emojis.append("❌") + start_adding_reactions(msg, emojis) + pred = ReactionPredicate.with_emojis(emojis, msg, user=context.author) try: - await playlist_msg.delete() - except discord.errors.NotFound: - pass - return track_list + await context.bot.wait_for("reaction_add", check=pred, timeout=60) + except asyncio.TimeoutError: + with contextlib.suppress(discord.HTTPException): + await msg.delete() + raise TooManyMatches( + "Too many matches found and you did not select which one you wanted." + ) + if emojis[pred.result] == "❌": + with contextlib.suppress(discord.HTTPException): + await msg.delete() + raise TooManyMatches( + "Too many matches found and you did not select which one you wanted." + ) + with contextlib.suppress(discord.HTTPException): + await msg.delete() + return correct_scope_matches[pred.result][0], original_input @commands.group() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def playlist(self, ctx): - """Playlist configuration options.""" + async def playlist(self, ctx: commands.Context): + """Playlist configuration options. + + Scope info: + ​ ​ ​ ​ **Global**: + ​ ​ ​ ​ ​ ​ ​ ​ Visible to all users of this bot. + ​ ​ ​ ​ ​ ​ ​ ​ 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. + ​ ​ ​ ​ **User**: + ​ ​ ​ ​ ​ ​ ​ ​ Visible to all bot users, if --author is passed. + ​ ​ ​ ​ ​ ​ ​ ​ Editable by bot owner and creator. + + """ pass - @playlist.command(name="append") - async def _playlist_append(self, ctx, playlist_name, *, url): + @playlist.command(name="append", usage=" [args]") + async def _playlist_append( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + query: LazyGreedyConverter, + *, + scope_data: ScopeParser = None, + ): """Add a track URL, playlist link, or quick search to a playlist. The track(s) will be appended to the end of the playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist append playlist_name_OR_id track_name_OR_url args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist append MyGuildPlaylist Hello by Adele + ​ ​ ​ ​ [p]playlist append MyGlobalPlaylist Hello by Adele --scope Global + ​ ​ ​ ​ [p]playlist append MyGlobalPlaylist Hello by Adele --scope Global + --Author Draper#6666 """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data if not await self._playlist_check(ctx): return - async with self.config.guild(ctx.guild).playlists() as playlists: - try: - if playlists[playlist_name][ - "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 playlist.") - ) - player = lavalink.get_player(ctx.guild.id) - to_append = await self._playlist_tracks(ctx, player, url) - if not to_append: - return - track_list = playlists[playlist_name]["tracks"] - if track_list and len(to_append) == 1 and to_append[0] in track_list: - return await self._embed_msg( - ctx, - _("{track} is already in {playlist}.").format( - track=to_append[0]["info"]["title"], playlist=playlist_name - ), - ) - if track_list: - playlists[playlist_name]["tracks"] = track_list + to_append - else: - playlists[playlist_name]["tracks"] = to_append - except KeyError: - return await self._embed_msg(ctx, _("No playlist with that name.")) - if playlists[playlist_name]["playlist_url"] is not None: - playlists[playlist_name]["playlist_url"] = None - if len(to_append) == 1: + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("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( + 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.") + ) + + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + player = lavalink.get_player(ctx.guild.id) + to_append = await self._playlist_tracks( + ctx, player, dataclasses.Query.process_input(query) + ) + if not to_append: + return await self._embed_msg(ctx, _("Could not find a track matching your query.")) + track_list = playlist.tracks + tracks_obj_list = playlist.tracks_obj + to_append_count = len(to_append) + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + appended = 0 + + if to_append and to_append_count == 1: + to = lavalink.Track(to_append[0]) + if to in tracks_obj_list: + return await self._embed_msg( + ctx, + _("{track} is already in {playlist} (`{id}`) [**{scope}**].").format( + track=to.title, playlist=playlist.name, id=playlist.id, scope=scope_name + ), + ) + else: + appended += 1 + if to_append and to_append_count > 1: + to_append_temp = [] + for t in to_append: + to = lavalink.Track(t) + if to not in tracks_obj_list: + appended += 1 + to_append_temp.append(t) + to_append = to_append_temp + if appended > 0: + track_list.extend(to_append) + update = {"tracks": track_list, "url": None} + await playlist.edit(update) + + if to_append_count == 1 and appended == 1: track_title = to_append[0]["info"]["title"] return await self._embed_msg( ctx, - _("{track} appended to {playlist}.").format( - track=track_title, playlist=playlist_name + _("{track} appended to {playlist} (`{id}`) [**{scope}**].").format( + track=track_title, playlist=playlist.name, id=playlist.id, scope=scope_name ), ) - await self._embed_msg( + + desc = _("{num} tracks appended to {playlist} (`{id}`) [**{scope}**].").format( + num=appended, playlist=playlist.name, id=playlist.id, scope=scope_name + ) + if to_append_count > appended: + diff = to_append_count - appended + desc += _("\n{existing} {plural} already in the playlist and were skipped.").format( + 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) + + @playlist.command(name="copy", usage=" [args]") + async def _playlist_copy( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ComplexScopeParser = None, + ): + + """Copy a playlist from one scope to another. + + **Usage**: + ​ ​ ​ ​ [p]playlist copy playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --from-scope + ​ ​ ​ ​ ​ ​ ​ ​ --from-author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --from-guild [guild] **Only the bot owner can use this** + + ​ ​ ​ ​ ​ ​ ​ ​ --to-scope + ​ ​ ​ ​ ​ ​ ​ ​ --to-author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --to-guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist copy MyGuildPlaylist --from-scope Guild --to-scope Global + ​ ​ ​ ​ [p]playlist copy MyGlobalPlaylist --from-scope Global --to-author Draper#6666 + --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: + scope_data = [ + PlaylistScope.GUILD.value, + ctx.author, + ctx.guild, + False, + PlaylistScope.GUILD.value, + ctx.author, + ctx.guild, + False, + ] + ( + from_scope, + from_author, + from_guild, + specified_from_user, + to_scope, + to_author, + to_guild, + specified_to_user, + ) = scope_data + + try: + playlist_id, playlist_arg = await self._get_correct_playlist_id( + ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user + ) + except TooManyMatches as e: + return await self._embed_msg(ctx, str(e)) + + if playlist_id is None: + return await self._embed_msg( + ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ) + + temp_playlist = FakePlaylist(to_author.id, to_scope) + if not await self.can_manage_playlist(to_scope, temp_playlist, ctx, to_author, to_guild): + return + + try: + from_playlist = await get_playlist( + playlist_id, from_scope, self.bot, from_guild, from_author.id + ) + except RuntimeError: + return await self._embed_msg( + ctx, + _("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.") + ) + + to_playlist = await create_playlist( ctx, - _("{num} tracks appended to {playlist}.").format( - num=len(to_append), playlist=playlist_name + to_scope, + from_playlist.name, + from_playlist.url, + from_playlist.tracks, + to_author, + to_guild, + ) + if to_scope == PlaylistScope.GLOBAL.value: + to_scope_name = "the Global" + elif to_scope == PlaylistScope.USER.value: + to_scope_name = to_author + else: + to_scope_name = to_guild + + if from_scope == PlaylistScope.GLOBAL.value: + from_scope_name = "the Global" + elif from_scope == PlaylistScope.USER.value: + from_scope_name = from_author + else: + from_scope_name = from_guild + + return await self._embed_msg( + ctx, + _( + "Playlist {name} (`{from_id}`) copied from {from_scope} to {to_scope} (`{to_id}`)." + ).format( + name=from_playlist.name, + from_id=from_playlist.id, + from_scope=humanize_scope(from_scope, ctx=from_scope_name, the=True), + to_scope=humanize_scope(to_scope, ctx=to_scope_name, the=True), + to_id=to_playlist.id, ), ) - @checks.is_owner() - @playlist.command(name="copy") - async def _playlist_copy(self, ctx, playlist_name, from_server_id: int, to_server_id: int): - """Copy a playlist from one server to another.""" - from_guild = self.bot.get_guild(from_server_id) - to_guild = self.bot.get_guild(to_server_id) - if not from_guild: - return await self._embed_msg(ctx, _("Invalid server ID for source server.")) - if not to_guild: - return await self._embed_msg(ctx, _("Invalid server ID for target server.")) - async with self.config.guild(from_guild).playlists() as from_playlists: - if playlist_name not in from_playlists: - return await self._embed_msg( - ctx, - _("No playlist with that name in {from_guild_name}.").format( - from_guild_name=from_guild.name - ), - ) - async with self.config.guild(to_guild).playlists() as to_playlists: - try: - target_playlists = to_playlists[playlist_name] - except KeyError: - to_playlists[playlist_name] = from_playlists[playlist_name] - return await self._embed_msg( - ctx, - _( - "Playlist {name} copied from {from_guild_name} to {to_guild_name}." - ).format( - name=playlist_name, - from_guild_name=from_guild.name, - to_guild_name=to_guild.name, - ), - ) + @playlist.command(name="create", usage=" [args]") + async def _playlist_create( + self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None + ): + """Create an empty playlist. - if target_playlists: - await self._embed_msg( - ctx, - _( - "A playlist with that name already exists in {to_guild_name}.\nPlease enter a new name for this playlist." - ).format(to_guild_name=to_guild.name), - ) - try: - playlist_name_msg = await ctx.bot.wait_for( - "message", - timeout=15.0, - check=MessagePredicate.regex(fr"^(?!{re.escape(ctx.prefix)})", ctx), - ) - new_playlist_name = playlist_name_msg.content.split(" ")[0].strip('"') - if len(new_playlist_name) > 20: - return await self._embed_msg( - ctx, _("Try the playlist copy command again with a shorter name.") - ) - if new_playlist_name in to_playlists: - return await self._embed_msg( - ctx, - _( - "Playlist name already exists in {to_guild_name}, try the playlist copy command again with a different name." - ).format(to_guild_name=to_guild.name), - ) - except asyncio.TimeoutError: - return await self._embed_msg( - ctx, _("No playlist name entered, try again later.") - ) - to_playlists[new_playlist_name] = from_playlists[playlist_name] - return await self._embed_msg( - ctx, - _( - "Playlist {name} copied from {from_guild_name} to {to_guild_name}.\nNew playlist name on {to_guild_name}: {new_name}" - ).format( - name=playlist_name, - from_guild_name=from_guild.name, - to_guild_name=to_guild.name, - new_name=new_playlist_name, - ), - ) + **Usage**: + ​ ​ ​ ​ [p]playlist create playlist_name args - @playlist.command(name="create") - async def _playlist_create(self, ctx, playlist_name): - """Create an empty playlist.""" - 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 save playlists.")) - async with self.config.guild(ctx.guild).playlists() as playlists: - if playlist_name in playlists: - return await self._embed_msg( - ctx, _("Playlist name already exists, try again with a different name.") - ) - playlist_name = playlist_name.split(" ")[0].strip('"') - playlist_list = self._to_json(ctx, None, None) - async with self.config.guild(ctx.guild).playlists() as playlists: - playlists[playlist_name] = playlist_list - await self._embed_msg(ctx, _("Empty playlist {name} created.").format(name=playlist_name)) + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** - @playlist.command(name="delete") - async def _playlist_delete(self, ctx, playlist_name): - """Delete a saved playlist.""" - async with self.config.guild(ctx.guild).playlists() as playlists: - try: - if playlists[playlist_name][ - "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 playlist.") - ) - del playlists[playlist_name] - except KeyError: - return await self._embed_msg(ctx, _("No playlist with that name.")) - await self._embed_msg(ctx, _("{name} playlist deleted.").format(name=playlist_name)) + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist create MyGuildPlaylist + ​ ​ ​ ​ [p]playlist create MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist create MyPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + temp_playlist = FakePlaylist(author.id, scope) + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return + playlist_name = playlist_name.split(" ")[0].strip('"')[:32] + if playlist_name.isnumeric(): + return await self._embed_msg( + ctx, + _( + "Playlist names must be a single word (up to 32 " + "characters) and not numbers only." + ), + ) + 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( + name=playlist.name, id=playlist.id, scope=scope_name + ), + ) + + @playlist.command(name="delete", aliases=["del"], usage=" [args]") + async def _playlist_delete( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Delete a saved playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist delete playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist delete MyGuildPlaylist + ​ ​ ​ ​ [p]playlist delete MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist delete MyPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("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( + 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.") + ) + + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + await delete_playlist(scope, playlist.id, guild or ctx.guild, author or ctx.author) + + await self._embed_msg( + ctx, + _("{name} (`{id}`) [**{scope}**] playlist deleted.").format( + name=playlist.name, id=playlist.id, scope=scope_name + ), + ) + + @playlist.command(name="dedupe", usage=" [args]") + async def _playlist_remdupe( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Remove duplicate tracks from a saved playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist dedupe playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist dedupe MyGuildPlaylist + ​ ​ ​ ​ [p]playlist dedupe MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist dedupe MyPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("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( + 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.") + ) + + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + + track_objects = playlist.tracks_obj + original_count = len(track_objects) + unique_tracks = set() + unique_tracks_add = unique_tracks.add + track_objects = [ + x for x in track_objects if not (x in unique_tracks or unique_tracks_add(x)) + ] + + tracklist = [] + for track in track_objects: + track_keys = track._info.keys() + track_values = track._info.values() + track_id = track.track_identifier + track_info = {} + for k, v in zip(track_keys, track_values): + track_info[k] = v + keys = ["track", "info"] + values = [track_id, track_info] + track_obj = {} + for key, value in zip(keys, values): + track_obj[key] = value + 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, + ), + ) + else: + await self._embed_msg( + ctx, + _("{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") + @playlist.command(name="download", usage=" [v2=False] [args]") @commands.bot_has_permissions(attach_files=True) - async def _playlist_download(self, ctx, playlist_name, v2=False): + async def _playlist_download( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + v2: Optional[bool] = False, + *, + scope_data: ScopeParser = None, + ): """Download a copy of a playlist. These files can be used with the [p]playlist upload command. Red v2-compatible playlists can be generated by passing True - for the v2 variable.""" - if not await self._playlist_check(ctx): - return - playlists = await self.config.guild(ctx.guild).playlists.get_raw() - v2_valid_urls = ["https://www.youtube.com/watch?v=", "https://soundcloud.com/"] - song_list = [] - playlist_url = None + for the v2 variable. + + **Usage**: + ​ ​ ​ ​ [p]playlist download playlist_name_OR_id [v2=True_OR_False] args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist download MyGuildPlaylist True + ​ ​ ​ ​ [p]playlist download MyGlobalPlaylist False --scope Global + ​ ​ ​ ​ [p]playlist download MyPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data try: - if playlists[playlist_name]["playlist_url"]: - playlist_url = playlists[playlist_name]["playlist_url"] - for track in playlists[playlist_name]["tracks"]: - if v2: - if track["info"]["uri"].startswith(tuple(v2_valid_urls)): - song_list.append(track["info"]["uri"]) - else: - song_list.append(track["info"]["uri"]) - except TypeError: - return await self._embed_msg(ctx, _("That playlist has no tracks.")) - except KeyError: - return await self._embed_msg(ctx, _("That playlist doesn't exist.")) + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ) - playlist_data = json.dumps( - {"author": ctx.author.id, "link": playlist_url, "playlist": song_list} - ) + 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( + 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.") + ) + + 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.")) + if version == "v2": + v2_valid_urls = ["https://www.youtube.com/watch?v=", "https://soundcloud.com/"] + song_list = [] + for track in playlist.tracks: + if track["info"]["uri"].startswith(tuple(v2_valid_urls)): + song_list.append(track["info"]["uri"]) + playlist_data = { + "author": playlist.author, + "link": playlist.url, + "playlist": song_list, + "name": playlist.name, + } + file_name = playlist.name + else: + 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 + file_name = playlist.id + playlist_data.update({"schema": schema, "version": version}) + playlist_data = json.dumps(playlist_data) to_write = StringIO() to_write.write(playlist_data) to_write.seek(0) - await ctx.send(file=discord.File(to_write, filename=f"{playlist_name}.txt")) + await ctx.send(file=discord.File(to_write, filename=f"{file_name}.txt")) to_write.close() - @playlist.command(name="info") - async def _playlist_info(self, ctx, playlist_name): - """Retrieve information from a saved playlist.""" - playlists = await self.config.guild(ctx.guild).playlists.get_raw() - try: - author_id = playlists[playlist_name]["author"] - except KeyError: - return await self._embed_msg(ctx, _("No playlist with that name.")) + @playlist.command(name="info", usage=" [args]") + async def _playlist_info( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Retrieve information from a saved playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist info playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist info MyGuildPlaylist + ​ ​ ​ ​ [p]playlist info MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist info MyPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) try: - track_len = len(playlists[playlist_name]["tracks"]) - except TypeError: - track_len = 0 + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ) - msg = "" + 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( + 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.") + ) + track_len = len(playlist.tracks) + + msg = "​" track_idx = 0 if track_len > 0: - for track in playlists[playlist_name]["tracks"]: + spaces = "\N{EN SPACE}" * (len(str(len(playlist.tracks))) + 2) + for track in playlist.tracks: track_idx = track_idx + 1 - spaces = abs(len(str(track_idx)) - 5) - msg += "`{}.` **[{}]({})**\n".format( - track_idx, track["info"]["title"], track["info"]["uri"] - ) + query = dataclasses.Query.process_input(track["info"]["uri"]) + if any(x in str(query) for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): + if track["info"]["title"] != "Unknown title": + msg += "`{}.` **{} - {}**\n{}{}\n".format( + track_idx, + track["info"]["author"], + track["info"]["title"], + spaces, + query.to_string_user(), + ) + else: + msg += "`{}.` {}\n".format(track_idx, query.to_string_user()) + else: + msg += "`{}.` **[{}]({})**\n".format( + track_idx, track["info"]["title"], track["info"]["uri"] + ) + else: msg = "No tracks." - playlist_url = playlists[playlist_name]["playlist_url"] - if not playlist_url: - embed_title = _("Playlist info for {playlist_name}:\n").format( - playlist_name=playlist_name + + if not playlist.url: + embed_title = _("Playlist info for {playlist_name} (`{id}`) [**{scope}**]:\n").format( + playlist_name=playlist.name, id=playlist.id, scope=scope_name ) else: - embed_title = _("Playlist info for {playlist_name}:\nURL: {url}").format( - playlist_name=playlist_name, url=playlist_url + embed_title = _( + "Playlist info for {playlist_name} (`{id}`) [**{scope}**]:\nURL: {url}" + ).format( + playlist_name=playlist.name, url=playlist.url, id=playlist.id, scope=scope_name ) page_list = [] - for page in pagify(msg, delims=["\n"], page_length=1000): + pages = list(pagify(msg, delims=["\n"], page_length=2000)) + total_pages = len(pages) + for numb, page in enumerate(pages, start=1): embed = discord.Embed( colour=await ctx.embed_colour(), title=embed_title, description=page ) - author_obj = self.bot.get_user(author_id) + author_obj = self.bot.get_user(playlist.author) embed.set_footer( - text=_("Author: {author_name} | {num} track(s)").format( - author_name=author_obj, num=track_len + text=_("Page {page}/{pages} | Author: {author_name} | {num} track(s)").format( + author_name=author_obj, num=track_len, pages=total_pages, page=numb ) ) page_list.append(embed) await menu(ctx, page_list, DEFAULT_CONTROLS) - @playlist.command(name="list") + @playlist.command(name="list", usage="[args]") @commands.bot_has_permissions(add_reactions=True) - async def _playlist_list(self, ctx): - """List saved playlists.""" - playlists = await self.config.guild(ctx.guild).playlists.get_raw() - if not playlists: - return await self._embed_msg(ctx, _("No saved playlists.")) + async def _playlist_list(self, ctx: commands.Context, *, scope_data: ScopeParser = None): + """List saved playlists. + + **Usage**: + ​ ​ ​ ​ [p]playlist list args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist list + ​ ​ ​ ​ [p]playlist list --scope Global + ​ ​ ​ ​ [p]playlist list --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + try: + 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.") + ) + + if scope == PlaylistScope.GUILD.value: + name = f"{guild.name}" + elif scope == PlaylistScope.USER.value: + name = f"{author}" + else: + name = "the global scope" + + if not playlists and specified_user: + return await self._embed_msg( + ctx, + _("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) + ) + playlist_list = [] space = "\N{EN SPACE}" - for playlist_name in playlists: - tracks = playlists[playlist_name]["tracks"] - if not tracks: - tracks = [] - author = playlists[playlist_name]["author"] + for playlist in playlists: playlist_list.append( ("\n" + space * 4).join( ( - bold(playlist_name), - _("Tracks: {num}").format(num=len(tracks)), - _("Author: {name}\n").format(name=self.bot.get_user(author)), + 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)), ) ) ) abc_names = sorted(playlist_list, key=str.lower) len_playlist_list_pages = math.ceil(len(abc_names) / 5) playlist_embeds = [] + for page_num in range(1, len_playlist_list_pages + 1): - embed = await self._build_playlist_list_page(ctx, page_num, abc_names) + embed = await self._build_playlist_list_page(ctx, page_num, abc_names, name) playlist_embeds.append(embed) await menu(ctx, playlist_embeds, DEFAULT_CONTROLS) - async def _build_playlist_list_page(self, ctx, page_num, abc_names): + @staticmethod + async def _build_playlist_list_page(ctx: commands.Context, page_num, abc_names, scope): plist_num_pages = math.ceil(len(abc_names) / 5) plist_idx_start = (page_num - 1) * 5 plist_idx_end = plist_idx_start + 5 @@ -2240,180 +4149,606 @@ class Audio(commands.Cog): plist += "`{}.` {}".format(item_idx, playlist_info) embed = discord.Embed( colour=await ctx.embed_colour(), - title=_("Playlists for {server_name}:").format(server_name=ctx.guild.name), + title=_("Playlists for {scope}:").format(scope=scope), description=plist, ) embed.set_footer( - text=_("Page {page_num}/{total_pages} | {num} playlists").format( + text=_("Page {page_num}/{total_pages} | {num} playlists.").format( page_num=page_num, total_pages=plist_num_pages, num=len(abc_names) ) ) return embed - @commands.cooldown(1, 15, discord.ext.commands.BucketType.guild) - @playlist.command(name="queue") - async def _playlist_queue(self, ctx, playlist_name=None): - """Save the queue to a playlist.""" - 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 save playlists.")) - async with self.config.guild(ctx.guild).playlists() as playlists: - if playlist_name in playlists: - return await self._embed_msg( - ctx, _("Playlist name already exists, try again with a different name.") - ) - if not self._player_check(ctx): - return await self._embed_msg(ctx, _("Nothing playing.")) + @commands.cooldown(1, 15, commands.BucketType.guild) + @playlist.command(name="queue", usage=" [args]") + async def _playlist_queue( + self, ctx: commands.Context, playlist_name: str, *, scope_data: ScopeParser = None + ): + """Save the queue to a playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist queue playlist_name + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist queue MyGuildPlaylist + ​ ​ ​ ​ [p]playlist queue MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist queue MyPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + temp_playlist = FakePlaylist(author.id, scope) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return + playlist_name = playlist_name.split(" ")[0].strip('"')[:32] + if playlist_name.isnumeric(): + return await self._embed_msg( + ctx, + _( + "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.")) + player = lavalink.get_player(ctx.guild.id) - if not player.current: + if not player.queue: return await self._embed_msg(ctx, _("There's nothing in the queue.")) tracklist = [] - np_song = self._track_creator(player, "np") + np_song = track_creator(player, "np") tracklist.append(np_song) for track in player.queue: queue_idx = player.queue.index(track) - track_obj = self._track_creator(player, queue_idx) + track_obj = track_creator(player, queue_idx) tracklist.append(track_obj) - if not playlist_name: - await self._embed_msg(ctx, _("Please enter a name for this playlist.")) - try: - playlist_name_msg = await ctx.bot.wait_for( - "message", - timeout=15.0, - check=MessagePredicate.regex(fr"^(?!{re.escape(ctx.prefix)})", ctx), - ) - playlist_name = playlist_name_msg.content.split(" ")[0].strip('"') - if len(playlist_name) > 20: - return await self._embed_msg( - ctx, _("Try the command again with a shorter name.") - ) - if playlist_name in playlists: - return await self._embed_msg( - ctx, _("Playlist name already exists, try again with a different name.") - ) - except asyncio.TimeoutError: - return await self._embed_msg(ctx, _("No playlist name entered, try again later.")) - playlist_list = self._to_json(ctx, None, tracklist) - async with self.config.guild(ctx.guild).playlists() as playlists: - playlist_name = playlist_name.split(" ")[0].strip('"') - playlists[playlist_name] = playlist_list + playlist = await create_playlist(ctx, scope, playlist_name, None, tracklist, author, guild) await self._embed_msg( ctx, - _("Playlist {name} saved from current queue: {num} tracks added.").format( - name=playlist_name.split(" ")[0].strip('"'), num=len(tracklist) + _( + "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 ), ) - @playlist.command(name="remove") - async def _playlist_remove(self, ctx, playlist_name, url): - """Remove a track from a playlist by url.""" - async with self.config.guild(ctx.guild).playlists() as playlists: - try: - if playlists[playlist_name][ - "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 playlist.") - ) - except KeyError: - return await self._embed_msg(ctx, _("No playlist with that name.")) - track_list = playlists[playlist_name]["tracks"] - clean_list = [track for track in track_list if not url == track["info"]["uri"]] - if len(playlists[playlist_name]["tracks"]) == len(clean_list): - return await self._embed_msg(ctx, _("URL not in playlist.")) - del_count = len(playlists[playlist_name]["tracks"]) - len(clean_list) - if not clean_list: - del playlists[playlist_name] - return await self._embed_msg(ctx, _("No tracks left, removing playlist.")) - playlists[playlist_name]["tracks"] = clean_list - if playlists[playlist_name]["playlist_url"] is not None: - playlists[playlist_name]["playlist_url"] = None + @playlist.command(name="remove", usage=" [args]") + async def _playlist_remove( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + url: str, + *, + scope_data: ScopeParser = None, + ): + """Remove a track from a playlist by url. + + **Usage**: + ​ ​ ​ ​ [p]playlist remove playlist_name_OR_id url args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist remove MyGuildPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU + ​ ​ ​ ​ [p]playlist remove MyGlobalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU + --scope Global + ​ ​ ​ ​ [p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU + --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("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( + 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.") + ) + + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + + 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.")) + 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.")) + 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_name} playlist.").format( - num=del_count, playlist_name=playlist_name + _( + "{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 ), ) else: await self._embed_msg( ctx, - _("The track has been removed from the {playlist_name} playlist.").format( - playlist_name=playlist_name - ), + _( + "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") - async def _playlist_save(self, ctx, playlist_name, playlist_url): - """Save a playlist from a url.""" + @playlist.command(name="save", usage=" [args]") + async def _playlist_save( + self, + ctx: commands.Context, + playlist_name: str, + playlist_url: str, + *, + scope_data: ScopeParser = None, + ): + """Save a playlist from a url. + + **Usage**: + ​ ​ ​ ​ [p]playlist save name url args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist save MyGuildPlaylist + https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM + ​ ​ ​ ​ [p]playlist save MyGlobalPlaylist + https://www.youtube.com/playlist?list=PLx0sYbCqOb8Q_CLZC2BdBSKEEB59BOPUM --scope Global + ​ ​ ​ ​ [p]playlist save MyPersonalPlaylist + https://open.spotify.com/playlist/1RyeIbyFeIJVnNzlGr5KkR --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + + temp_playlist = FakePlaylist(author.id, scope) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return + playlist_name = playlist_name.split(" ")[0].strip('"')[:32] + if playlist_name.isnumeric(): + return await self._embed_msg( + ctx, + _( + "Playlist names must be a single word (up to 32 " + "characters) and not numbers only." + ), + ) if not await self._playlist_check(ctx): return player = lavalink.get_player(ctx.guild.id) - tracklist = await self._playlist_tracks(ctx, player, playlist_url) - playlist_list = self._to_json(ctx, playlist_url, tracklist) + tracklist = await self._playlist_tracks( + ctx, player, dataclasses.Query.process_input(playlist_url) + ) if tracklist is not None: - async with self.config.guild(ctx.guild).playlists() as playlists: - playlist_name = playlist_name.split(" ")[0].strip('"') - playlists[playlist_name] = playlist_list - return await self._embed_msg( - ctx, - _("Playlist {name} saved: {num} tracks added.").format( - name=playlist_name, num=len(tracklist) - ), - ) + 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 + ), + ) + + @playlist.command(name="start", usage=" [args]") + async def _playlist_start( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Load a playlist into the queue. + + **Usage**: + ​ ​ ​ ​ [p]playlist start playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist start MyGuildPlaylist + ​ ​ ​ ​ [p]playlist start MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist start MyPersonalPlaylist --scope User + """ + 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() + 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.")) + return False + + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("Could not match '{arg}' to a playlist").format(arg=playlist_arg) + ) - @playlist.command(name="start") - async def _playlist_start(self, ctx, playlist_name=None): - """Load a playlist into the queue.""" if not await self._playlist_check(ctx): return + jukebox_price = await self.config.guild(ctx.guild).jukebox_price() + if not await self._currency_check(ctx, jukebox_price): + return maxlength = await self.config.guild(ctx.guild).maxlength() - playlists = await self.config.guild(ctx.guild).playlists.get_raw() author_obj = self.bot.get_user(ctx.author.id) track_len = 0 + playlist = None try: + playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) player = lavalink.get_player(ctx.guild.id) - for track in playlists[playlist_name]["tracks"]: - if track["info"]["uri"].startswith("localtracks/"): + tracks = playlist.tracks_obj + empty_queue = not player.queue + for track in tracks: + if not await is_allowed( + ctx.guild, + ( + f"{track.title} {track.author} {track.uri} " + f"{str(dataclasses.Query.process_input(track))}" + ), + ): + log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") + continue + if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): + local_path = dataclasses.LocalPath(track.uri) if not await self._localtracks_check(ctx): pass - if not os.path.isfile(track["info"]["uri"]): + if not local_path.exists() and not local_path.is_file(): continue if maxlength > 0: - if not self._track_limit(ctx, track["info"]["length"], maxlength): + if not track_limit(track.length, maxlength): continue - player.add(author_obj, lavalink.rest_api.Track(data=track)) + + player.add(author_obj, track) + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, track, ctx.author + ) track_len += 1 - if len(playlists[playlist_name]["tracks"]) > track_len: + player.maybe_shuffle(0 if empty_queue else 1) + if len(tracks) > track_len: maxlength_msg = " {bad_tracks} tracks cannot be queued.".format( - bad_tracks=(len(playlists[playlist_name]["tracks"]) - track_len) + bad_tracks=(len(tracks) - track_len) ) else: maxlength_msg = "" + if scope == PlaylistScope.GUILD.value: + scope_name = f"{guild.name}" + elif scope == PlaylistScope.USER.value: + scope_name = f"{author}" + else: + scope_name = "the global scope" + embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Playlist Enqueued"), - description=_("Added {num} tracks to the queue.{maxlength_msg}").format( - num=track_len, maxlength_msg=maxlength_msg + description=_( + "{name} - (`{id}`) [**{scope}**]\nAdded {num} " + "tracks to the queue.{maxlength_msg}" + ).format( + num=track_len, + maxlength_msg=maxlength_msg, + name=playlist.name, + id=playlist.id, + scope=scope_name, ), ) await ctx.send(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( + 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.") + ) except TypeError: - await ctx.invoke(self.play, query=playlists[playlist_name]["playlist_url"]) - except KeyError: - await self._embed_msg(ctx, _("That playlist doesn't exist.")) + if playlist: + return await ctx.invoke(self.play, query=playlist.url) + + @playlist.command(name="update", usage=" [args]") + async def _playlist_update( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + *, + scope_data: ScopeParser = None, + ): + """Updates all tracks in a playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist update playlist_name_OR_id args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist update MyGuildPlaylist + ​ ​ ​ ​ [p]playlist update MyGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist update MyPersonalPlaylist --scope User + """ + + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + try: + 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)) + + if playlist_id is None: + return await self._embed_msg( + ctx, _("Could not match '{arg}' to a playlist.").format(arg=playlist_arg) + ) + + if not await self._playlist_check(ctx): + return + try: + playlist = await get_playlist(playlist_id, scope, self.bot, guild, author) + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + if playlist.url: + 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.")) + 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) + ), + ) + except MissingGuild: + return await self._embed_msg( + ctx, _("You need to specify the Guild ID for the guild to lookup.") + ) + else: + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if added or removed: + _colour = await ctx.embed_colour() + embeds = [] + total_added = len(added) + total_removed = len(removed) + total_pages = math.ceil(total_removed / 10) + math.ceil(total_added / 10) + page_count = 0 + if removed: + removed_text = "" + for i, track in enumerate(removed, 1): + if len(track.title) > 40: + track_title = str(track.title).replace("[", "") + track_title = "{}...".format((track_title[:40]).rstrip(" ")) + else: + track_title = track.title + removed_text += f"`{i}.` **[{track_title}]({track.uri})**\n" + if i % 10 == 0 or i == total_removed: + page_count += 1 + embed = discord.Embed( + title=_("Tracks removed"), colour=_colour, description=removed_text + ) + text = _("Page {page_num}/{total_pages}").format( + page_num=page_count, total_pages=total_pages + ) + embed.set_footer(text=text) + embeds.append(embed) + removed_text = "" + if added: + added_text = "" + for i, track in enumerate(added, 1): + if len(track.title) > 40: + track_title = str(track.title).replace("[", "") + track_title = "{}...".format((track_title[:40]).rstrip(" ")) + else: + track_title = track.title + added_text += f"`{i}.` **[{track_title}]({track.uri})**\n" + if i % 10 == 0 or i == total_added: + page_count += 1 + embed = discord.Embed( + title=_("Tracks added"), colour=_colour, description=added_text + ) + text = _("Page {page_num}/{total_pages}").format( + page_num=page_count, total_pages=total_pages + ) + embed.set_footer(text=text) + embeds.append(embed) + added_text = "" + await menu(ctx, embeds, DEFAULT_CONTROLS) + else: + return await self._embed_msg( + ctx, + _("No changes for {name} (`{id}`) [**{scope}**].").format( + id=playlist.id, name=playlist.name, scope=scope_name + ), + ) @checks.is_owner() - @playlist.command(name="upload") - async def _playlist_upload(self, ctx): - """Convert a Red v2 playlist file to a playlist.""" + @playlist.command(name="upload", usage="[args]") + async def _playlist_upload(self, ctx: commands.Context, *, scope_data: ScopeParser = None): + """Uploads a playlist file as a playlist for the bot. + + V2 and old V3 playlist will be slow. + V3 Playlist made with [p]playlist download will load a lot faster. + + **Usage**: + ​ ​ ​ ​ [p]playlist upload args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist upload + ​ ​ ​ ​ [p]playlist upload --scope Global + ​ ​ ​ ​ [p]playlist upload --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + temp_playlist = FakePlaylist(author.id, scope) + if not await self.can_manage_playlist(scope, temp_playlist, ctx, author, guild): + return + if not await self._playlist_check(ctx): return player = lavalink.get_player(ctx.guild.id) + await self._embed_msg( ctx, _("Please upload the playlist file. Any other message will cancel this operation."), @@ -2429,86 +4764,311 @@ class Audio(commands.Cog): file_url = file_message.attachments[0].url except IndexError: return await self._embed_msg(ctx, _("Upload cancelled.")) - v2_playlist_name = (file_url.split("/")[6]).split(".")[0] file_suffix = file_url.rsplit(".", 1)[1] if file_suffix != "txt": return await self._embed_msg(ctx, _("Only playlist files can be uploaded.")) try: async with self.session.request("GET", file_url) as r: - v2_playlist = await r.json(content_type="text/plain") + uploaded_playlist = await r.json(content_type="text/plain") except UnicodeDecodeError: return await self._embed_msg(ctx, _("Not a valid playlist file.")) - try: - v2_playlist_url = v2_playlist["link"] - except KeyError: - v2_playlist_url = None - if ( - not v2_playlist_url - or not self._match_yt_playlist(v2_playlist_url) - or not await player.get_tracks(v2_playlist_url) - ): - track_list = [] - track_count = 0 - async with self.config.guild(ctx.guild).playlists() as v3_playlists: - try: - if v3_playlists[v2_playlist_name]: - return await self._embed_msg( - ctx, _("A playlist already exists with this name.") - ) - except KeyError: - pass - embed1 = discord.Embed( - colour=await ctx.embed_colour(), title=_("Please wait, adding tracks...") - ) - playlist_msg = await ctx.send(embed=embed1) - for song_url in v2_playlist["playlist"]: - try: - track = await player.get_tracks(song_url) - except RuntimeError: - pass - try: - track_obj = self._track_creator(player, other_track=track[0]) - track_list.append(track_obj) - track_count = track_count + 1 - except IndexError: - pass - if track_count % 5 == 0: - embed2 = discord.Embed( - colour=await ctx.embed_colour(), - title=_("Loading track {num}/{total}...").format( - num=track_count, total=len(v2_playlist["playlist"]) - ), - ) - await playlist_msg.edit(embed=embed2) - if not track_list: - return await self._embed_msg(ctx, _("No tracks found.")) - playlist_list = self._to_json(ctx, v2_playlist_url, track_list) - async with self.config.guild(ctx.guild).playlists() as v3_playlists: - v3_playlists[v2_playlist_name] = playlist_list - if len(v2_playlist["playlist"]) != track_count: - bad_tracks = len(v2_playlist["playlist"]) - track_count - msg = _( - "Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) " - "could not be loaded." - ).format(num=track_count, playlist_name=v2_playlist_name, num_bad=bad_tracks) - else: - msg = _("Added {num} tracks from the {playlist_name} playlist.").format( - num=track_count, playlist_name=v2_playlist_name - ) - embed3 = discord.Embed( - colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg - ) - await playlist_msg.edit(embed=embed3) - else: - await ctx.invoke(self._playlist_save, v2_playlist_name, v2_playlist_url) - async def _playlist_check(self, ctx): - dj_enabled = await self.config.guild(ctx.guild).dj_enabled() - jukebox_price = await self.config.guild(ctx.guild).jukebox_price() - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): - await self._embed_msg(ctx, _("You need the DJ role to use playlists.")) - return False + new_schema = uploaded_playlist.get("schema", 1) >= 2 + version = uploaded_playlist.get("version", "v2") + + if new_schema and version == "v3": + uploaded_playlist_url = uploaded_playlist.get("playlist_url", None) + track_list = uploaded_playlist.get("tracks", []) + else: + uploaded_playlist_url = uploaded_playlist.get("link", None) + track_list = uploaded_playlist.get("playlist", []) + + uploaded_playlist_name = uploaded_playlist.get( + "name", (file_url.split("/")[6]).split(".")[0] + ) + if ( + not uploaded_playlist_url + or not match_yt_playlist(uploaded_playlist_url) + or not ( + await self.music_cache.lavalink_query( + ctx, player, dataclasses.Query.process_input(uploaded_playlist_url) + ) + )[0].tracks + ): + if version == "v3": + return await self._load_v3_playlist( + ctx, + scope, + uploaded_playlist_name, + uploaded_playlist_url, + track_list, + author, + guild, + ) + return await self._load_v2_playlist( + ctx, + track_list, + player, + uploaded_playlist_url, + uploaded_playlist_name, + scope, + author, + guild, + ) + return await ctx.invoke( + self._playlist_save, + playlist_name=uploaded_playlist_name, + playlist_url=uploaded_playlist_url, + scope_data=(scope, author, guild, specified_user), + ) + + @playlist.command(name="rename", usage=" [args]") + async def _playlist_rename( + self, + ctx: commands.Context, + playlist_matches: PlaylistConverter, + new_name: str, + *, + scope_data: ScopeParser = None, + ): + """Rename an existing playlist. + + **Usage**: + ​ ​ ​ ​ [p]playlist rename playlist_name_OR_id new_name args + + **Args**: + ​ ​ ​ ​ The following are all optional: + ​ ​ ​ ​ ​ ​ ​ ​ --scope + ​ ​ ​ ​ ​ ​ ​ ​ --author [user] + ​ ​ ​ ​ ​ ​ ​ ​ --guild [guild] **Only the bot owner can use this** + + Scope is one of the following: + ​ ​ ​ ​ Global + ​ ​ ​ ​ Guild + ​ ​ ​ ​ User + + Author can be one of the following: + ​ ​ ​ ​ User ID + ​ ​ ​ ​ User Mention + ​ ​ ​ ​ User Name#123 + + Guild can be one of the following: + ​ ​ ​ ​ Guild ID + ​ ​ ​ ​ Exact guild name + + Example use: + ​ ​ ​ ​ [p]playlist rename MyGuildPlaylist RenamedGuildPlaylist + ​ ​ ​ ​ [p]playlist rename MyGlobalPlaylist RenamedGlobalPlaylist --scope Global + ​ ​ ​ ​ [p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User + """ + if scope_data is None: + scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False] + scope, author, guild, specified_user = scope_data + + new_name = new_name.split(" ")[0].strip('"')[:32] + if new_name.isnumeric(): + return await self._embed_msg( + ctx, + _( + "Playlist names must be a single word (up to 32 " + "characters) and not numbers only." + ), + ) + + try: + 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)) + if playlist_id is None: + return await self._embed_msg( + ctx, _("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( + 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.") + ) + + if not await self.can_manage_playlist(scope, playlist, ctx, author, guild): + return + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + old_name = playlist.name + update = {"name": new_name} + await playlist.edit(update) + 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) + + async def _load_v3_playlist( + self, + ctx: commands.Context, + scope: str, + uploaded_playlist_name: str, + uploaded_playlist_url: str, + track_list, + 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) + track_count = len(track_list) + uploaded_track_count = len(track_list) + await asyncio.sleep(1) + embed2 = discord.Embed( + colour=await ctx.embed_colour(), + title=_("Loading track {num}/{total}...").format( + num=track_count, total=uploaded_track_count + ), + ) + await playlist_msg.edit(embed=embed2) + playlist = await create_playlist( + ctx, scope, uploaded_playlist_name, uploaded_playlist_url, track_list, author, guild + ) + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if not track_count: + msg = _("Empty playlist {name} (`{id}`) [**{scope}**] created.").format( + name=playlist.name, id=playlist.id, scope=scope_name + ) + elif uploaded_track_count != track_count: + bad_tracks = uploaded_track_count - track_count + msg = _( + "Added {num} tracks from the {playlist_name} playlist. {num_bad} track(s) " + "could not be loaded." + ).format(num=track_count, playlist_name=playlist.name, num_bad=bad_tracks) + else: + msg = _("Added {num} tracks from the {playlist_name} playlist.").format( + num=track_count, playlist_name=playlist.name + ) + embed3 = discord.Embed( + colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg + ) + await playlist_msg.edit(embed=embed3) + database_entries = [] + time_now = str(datetime.datetime.now(datetime.timezone.utc)) + for t in track_list: + uri = t.get("info", {}).get("uri") + if uri: + t = {"loadType": "V2_COMPAT", "tracks": [t], "query": uri} + database_entries.append( + { + "query": uri, + "data": json.dumps(t), + "last_updated": time_now, + "last_fetched": time_now, + } + ) + if database_entries and HAS_SQL: + asyncio.ensure_future(self.music_cache.insert("lavalink", database_entries)) + + async def _load_v2_playlist( + self, + ctx: commands.Context, + uploaded_track_list, + player: lavalink.player_manager.Player, + playlist_url: str, + uploaded_playlist_name: str, + scope: str, + author: Union[discord.User, discord.Member], + guild: Union[discord.Guild], + ): + track_list = [] + track_count = 0 + successfull_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) + 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, dataclasses.Query.process_input(song_url) + ) + track = result.tracks + except Exception: + continue + try: + track_obj = track_creator(player, other_track=track[0]) + track_list.append(track_obj) + successfull_count += 1 + except Exception: + continue + if (track_count % 2 == 0) or (track_count == len(uploaded_track_list)): + await notifier.notify_user( + current=track_count, total=len(uploaded_track_list), key="playlist" + ) + + playlist = await create_playlist( + ctx, scope, uploaded_playlist_name, playlist_url, track_list, author, guild + ) + scope_name = humanize_scope( + scope, ctx=guild if scope == PlaylistScope.GUILD.value else author + ) + if not successfull_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 + 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) + else: + msg = _("Added {num} tracks from the {playlist_name} playlist.").format( + num=successfull_count, playlist_name=playlist.name + ) + embed3 = discord.Embed( + colour=await ctx.embed_colour(), title=_("Playlist Saved"), description=msg + ) + await playlist_msg.edit(embed=embed3) + + async def _maybe_update_playlist( + self, ctx: commands.Context, player: lavalink.player_manager.Player, playlist: Playlist + ) -> Tuple[List[lavalink.Track], List[lavalink.Track], Playlist]: + if playlist.url is None: + return [], [], playlist + results = {} + updated_tracks = await self._playlist_tracks( + ctx, player, dataclasses.Query.process_input(playlist.url) + ) + if not updated_tracks: + # No Tracks available on url Lets set it to none to avoid repeated calls here + results["url"] = None + if updated_tracks: # Tracks have been updated + results["tracks"] = updated_tracks + + old_tracks = playlist.tracks_obj + new_tracks = [lavalink.Track(data=track) for track in updated_tracks] + removed = list(set(old_tracks) - set(new_tracks)) + added = list(set(new_tracks) - set(old_tracks)) + if removed or added: + await playlist.edit(results) + + return added, removed, playlist + + async def _playlist_check(self, ctx: commands.Context): if not self._player_check(ctx): if self._connection_aborted: msg = _("Connection to Lavalink has failed.") @@ -2520,7 +5080,7 @@ class Audio(commands.Cog): if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect or not ctx.author.voice.channel.permissions_for(ctx.me).move_members - and self._userlimit(ctx.author.voice.channel) + and userlimit(ctx.author.voice.channel) ): await self._embed_msg( ctx, _("I don't have permission to connect to your channel.") @@ -2548,22 +5108,20 @@ class Audio(commands.Cog): ctx, _("You must be in the voice channel to use the playlist command.") ) return False - if not await self._currency_check(ctx, jukebox_price): - return False await self._eq_check(ctx, player) await self._data_check(ctx) return True - async def _playlist_tracks(self, ctx, player, query): - search = False + async def _playlist_tracks( + self, + ctx: commands.Context, + player: lavalink.player_manager.Player, + query: dataclasses.Query, + ): + search = query.is_search tracklist = [] - if type(query) is tuple: - query = " ".join(query) - if "open.spotify.com" in query: - query = "spotify:{}".format( - re.sub("(http[s]?:\/\/)?(open.spotify.com)\/", "", query).replace("/", ":") - ) - if query.startswith("spotify:"): + + if query.is_spotify: try: if self.play_lock[ctx.message.guild.id]: return await self._embed_msg( @@ -2575,36 +5133,36 @@ class Audio(commands.Cog): if not tracks: return await self._embed_msg(ctx, _("Nothing found.")) for track in tracks: - track_obj = self._track_creator(player, other_track=track) + track_obj = track_creator(player, other_track=track) tracklist.append(track_obj) self._play_lock(ctx, False) - elif not query.startswith("http"): - query = "ytsearch:{}".format(query) - search = True - tracks = await player.get_tracks(query) + elif query.is_search: + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + tracks = result.tracks if not tracks: return await self._embed_msg(ctx, _("Nothing found.")) else: - tracks = await player.get_tracks(query) + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + tracks = result.tracks + if not search and len(tracklist) == 0: for track in tracks: - track_obj = self._track_creator(player, other_track=track) + track_obj = track_creator(player, other_track=track) tracklist.append(track_obj) elif len(tracklist) == 0: - track_obj = self._track_creator(player, other_track=tracks[0]) + track_obj = track_creator(player, other_track=tracks[0]) tracklist.append(track_obj) return tracklist @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def prev(self, ctx): + 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() player = lavalink.get_player(ctx.guild.id) - shuffle = await self.config.guild(ctx.guild).shuffle() if dj_enabled: if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( ctx, ctx.author @@ -2616,22 +5174,26 @@ class Audio(commands.Cog): return await self._embed_msg( ctx, _("You must be in the voice channel to skip the music.") ) - if shuffle: - return await self._embed_msg(ctx, _("Turn shuffle off to use this command.")) if player.fetch("prev_song") is None: return await self._embed_msg(ctx, _("No previous track.")) else: - last_track = await player.get_tracks(player.fetch("prev_song")) - player.add(player.fetch("prev_requester"), last_track[0]) + track = player.fetch("prev_song") + player.add(player.fetch("prev_requester"), track) + self.bot.dispatch("red_audio_track_enqueue", player.channel.guild, track, ctx.author) queue_len = len(player.queue) bump_song = player.queue[-1] player.queue.insert(0, bump_song) player.queue.pop(queue_len) await player.skip() - if "localtracks/" in player.current.uri: - description = "**{}**\n{}".format( - player.current.title, player.current.uri.replace("localtracks/", "") - ) + if any( + x in player.current.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): + query = 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 = f"**[{player.current.title}]({player.current.uri})**" embed = discord.Embed( @@ -2644,7 +5206,7 @@ class Audio(commands.Cog): @commands.group(invoke_without_command=True) @commands.guild_only() @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def queue(self, ctx, *, page: int = 1): + async def queue(self, ctx: commands.Context, *, page: int = 1): """List the songs in the queue.""" async def _queue_menu( @@ -2658,34 +5220,104 @@ class Audio(commands.Cog): ): if message: await ctx.send_help(self.queue) - await message.delete() + with contextlib.suppress(discord.HTTPException): + await message.delete() return None - QUEUE_CONTROLS = {"⬅": prev_page, "❌": close_menu, "➡": next_page, "ℹ": _queue_menu} + queue_controls = {"⬅": prev_page, "❌": close_menu, "➡": next_page, "ℹ": _queue_menu} if not self._player_check(ctx): return await self._embed_msg(ctx, _("There's nothing in the queue.")) player = lavalink.get_player(ctx.guild.id) if not player.queue: - return await self._embed_msg(ctx, _("There's nothing in the queue.")) - len_queue_pages = math.ceil(len(player.queue) / 10) - queue_page_list = [] - for page_num in range(1, len_queue_pages + 1): - embed = await self._build_queue_page(ctx, player, page_num) - queue_page_list.append(embed) - if page > len_queue_pages: - page = len_queue_pages - await menu(ctx, queue_page_list, QUEUE_CONTROLS, page=(page - 1)) + if player.current: + arrow = await draw_time(ctx) + pos = lavalink.utils.format_time(player.position) + if player.current.is_stream: + dur = "LIVE" + else: + dur = lavalink.utils.format_time(player.current.length) + if any( + x in player.current.uri + for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): + 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 += "\n\n{arrow}`{pos}`/`{dur}`" + song = song.format( + track=player.current, + uri=dataclasses.LocalPath(player.current.uri).to_string_hidden() + if any( + x in player.current.uri + for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ) + else player.current.uri, + arrow=arrow, + pos=pos, + dur=dur, + ) - async def _build_queue_page(self, ctx, player, page_num): + embed = discord.Embed( + colour=await ctx.embed_colour(), 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 + text = "" + text += ( + _("Auto-Play") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}") + ) + text += ( + (" | " if text else "") + + _("Shuffle") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}") + ) + text += ( + (" | " if text else "") + + _("Repeat") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}") + ) + embed.set_footer(text=text) + + return await ctx.send(embed=embed) + return await self._embed_msg(ctx, _("There's nothing in the queue.")) + + async with ctx.typing(): + len_queue_pages = math.ceil(len(player.queue) / 10) + queue_page_list = [] + for page_num in range(1, len_queue_pages + 1): + embed = await self._build_queue_page(ctx, player, page_num) + queue_page_list.append(embed) + if page > len_queue_pages: + page = len_queue_pages + return await menu(ctx, queue_page_list, queue_controls, page=(page - 1)) + + async def _build_queue_page( + self, ctx: commands.Context, player: lavalink.player_manager.Player, page_num + ): 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 + queue_num_pages = math.ceil(len(player.queue) / 10) queue_idx_start = (page_num - 1) * 10 queue_idx_end = queue_idx_start + 10 queue_list = "" try: - arrow = await self._draw_time(ctx) + arrow = await draw_time(ctx) except AttributeError: return await self._embed_msg(ctx, _("There's nothing in the queue.")) pos = lavalink.utils.format_time(player.position) @@ -2696,15 +5328,20 @@ class Audio(commands.Cog): dur = lavalink.utils.format_time(player.current.length) if player.current.is_stream: - queue_list += _("**Currently livestreaming:**") + queue_list += _("**Currently livestreaming:**\n") + queue_list += "**[{current.title}]({current.uri})**\n".format(current=player.current) + queue_list += _("Requested by: **{user}**").format(user=player.current.requester) + queue_list += f"\n\n{arrow}`{pos}`/`{dur}`\n\n" - elif "localtracks" in player.current.uri: - if not player.current.title == "Unknown title": + elif any( + x in player.current.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): + if player.current.title != "Unknown title": queue_list += "\n".join( ( _("Playing: ") + "**{current.author} - {current.title}**".format(current=player.current), - player.current.uri.replace("localtracks/", ""), + dataclasses.LocalPath(player.current.uri).to_string_hidden(), _("Requested by: **{user}**\n").format(user=player.current.requester), f"{arrow}`{pos}`/`{dur}`\n\n", ) @@ -2712,7 +5349,8 @@ class Audio(commands.Cog): else: queue_list += "\n".join( ( - _("Playing: ") + player.current.uri.replace("localtracks/", ""), + _("Playing: ") + + dataclasses.LocalPath(player.current.uri).to_string_hidden(), _("Requested by: **{user}**\n").format(user=player.current.requester), f"{arrow}`{pos}`/`{dur}`\n\n", ) @@ -2733,11 +5371,11 @@ class Audio(commands.Cog): track_title = track.title req_user = track.requester track_idx = i + 1 - if "localtracks" in track.uri: + if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): if track.title == "Unknown title": queue_list += f"`{track_idx}.` " + ", ".join( ( - bold(track.uri.replace("localtracks/", "")), + bold(dataclasses.LocalPath(track.uri).to_string_hidden()), _("requested by **{user}**\n").format(user=req_user), ) ) @@ -2756,31 +5394,47 @@ class Audio(commands.Cog): ) if await self.config.guild(ctx.guild).thumbnail() and player.current.thumbnail: embed.set_thumbnail(url=player.current.thumbnail) - queue_duration = await self._queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_duration) + 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" + "Page {page_num}/{total_pages} | {num_tracks} " + "tracks, {num_remaining} remaining | \n\n" ).format( page_num=page_num, total_pages=queue_num_pages, num_tracks=len(player.queue) + 1, num_remaining=queue_total_duration, ) - if repeat: - text += " | " + _("Repeat") + ": \N{WHITE HEAVY CHECK MARK}" - if shuffle: - text += " | " + _("Shuffle") + ": \N{WHITE HEAVY CHECK MARK}" + text += ( + _("Auto-Play") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if autoplay else "\N{CROSS MARK}") + ) + text += ( + (" | " if text else "") + + _("Shuffle") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if shuffle else "\N{CROSS MARK}") + ) + text += ( + (" | " if text else "") + + _("Repeat") + + ": " + + ("\N{WHITE HEAVY CHECK MARK}" if repeat else "\N{CROSS MARK}") + ) embed.set_footer(text=text) return embed - async def _build_queue_search_list(self, queue_list, search_words): + @staticmethod + async def _build_queue_search_list(queue_list, search_words): track_list = [] queue_idx = 0 for track in queue_list: queue_idx = queue_idx + 1 - if not self._match_url(track.uri): + if not match_url(track.uri): + query = dataclasses.Query.process_input(track) if track.title == "Unknown title": - track_title = track.uri.split("/")[2] + track_title = query.track.to_string_hidden() else: track_title = "{} - {}".format(track.author, track.title) else: @@ -2796,20 +5450,18 @@ class Audio(commands.Cog): search_list.append([queue_position, title]) return search_list - async def _build_queue_search_page(self, ctx, page_num, search_list): + @staticmethod + async def _build_queue_search_page(ctx: commands.Context, page_num, search_list): search_num_pages = math.ceil(len(search_list) / 10) search_idx_start = (page_num - 1) * 10 search_idx_end = search_idx_start + 10 track_match = "" - command = ctx.invoked_with for i, track in enumerate( search_list[search_idx_start:search_idx_end], start=search_idx_start ): - track_idx = i + 1 if type(track) is str: - local_path = await self.config.localpath() - track_location = track.replace("localtrack:{}/localtracks/".format(local_path), "") + track_location = dataclasses.LocalPath(track).to_string_hidden() track_match += "`{}.` **{}**\n".format(track_idx, track_location) else: track_match += "`{}.` **{}**\n".format(track[0], track[1]) @@ -2825,7 +5477,7 @@ class Audio(commands.Cog): @queue.command(name="clear") @commands.guild_only() - async def _queue_clear(self, ctx): + async def _queue_clear(self, ctx: commands.Context): """Clears the queue.""" try: player = lavalink.get_player(ctx.guild.id) @@ -2844,7 +5496,7 @@ class Audio(commands.Cog): @queue.command(name="clean") @commands.guild_only() - async def _queue_clean(self, ctx): + async def _queue_clean(self, ctx: commands.Context): """Removes songs from the queue if the requester is not in the voice channel.""" try: player = lavalink.get_player(ctx.guild.id) @@ -2873,13 +5525,44 @@ class Audio(commands.Cog): await self._embed_msg( ctx, _( - "Removed {removed_tracks} tracks queued by members outside of the voice channel." + "Removed {removed_tracks} tracks queued by members o" + "utside of the voice channel." ).format(removed_tracks=removed_tracks), ) + @queue.command(name="cleanself") + @commands.guild_only() + async def _queue_cleanself(self, ctx: commands.Context): + """Removes all tracks you requested from the queue.""" + + try: + player = lavalink.get_player(ctx.guild.id) + except KeyError: + return await self._embed_msg(ctx, _("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.")) + + clean_tracks = [] + removed_tracks = 0 + for track in player.queue: + if track.requester != ctx.author: + clean_tracks.append(track) + else: + removed_tracks += 1 + player.queue = clean_tracks + if removed_tracks == 0: + await self._embed_msg(ctx, _("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 + ), + ) + @queue.command(name="search") @commands.guild_only() - async def _queue_search(self, ctx, *, search_words: str): + async def _queue_search(self, ctx: commands.Context, *, search_words: str): """Search the queue.""" try: player = lavalink.get_player(ctx.guild.id) @@ -2899,10 +5582,49 @@ class Audio(commands.Cog): search_page_list.append(embed) await menu(ctx, search_page_list, DEFAULT_CONTROLS) + @queue.command(name="shuffle") + @commands.guild_only() + async def _queue_shuffle(self, ctx: commands.Context): + """Shuffles the queue.""" + dj_enabled = 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, ctx.author + ): + return await self._embed_msg(ctx, _("You need the DJ role to clean the queue.")) + if not self._player_check(ctx): + return await self._embed_msg(ctx, _("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) + ): + return await self._embed_msg( + ctx, _("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: + return await self._embed_msg( + ctx, _("Connection to Lavalink has not yet been established.") + ) + except KeyError: + return await self._embed_msg(ctx, _("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.")) + + player.force_shuffle(0) + return await self._embed_msg(ctx, _("Queue has been shuffled.")) + @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def repeat(self, ctx): + async def repeat(self, ctx: commands.Context): """Toggle repeat.""" dj_enabled = await self.config.guild(ctx.guild).dj_enabled() if dj_enabled: @@ -2920,18 +5642,28 @@ class Audio(commands.Cog): ctx, _("You must be in the voice channel to toggle repeat.") ) + autoplay = await self.config.guild(ctx.guild).auto_play() repeat = await self.config.guild(ctx.guild).repeat() - await self.config.guild(ctx.guild).repeat.set(not repeat) - await self._embed_msg( - ctx, _("Repeat tracks: {true_or_false}.").format(true_or_false=not repeat) + msg = "" + msg += _("Repeat tracks: {true_or_false}.").format( + true_or_false=_("Enabled") if not repeat else _("Disabled") ) + await self.config.guild(ctx.guild).repeat.set(not repeat) + if repeat is not True and autoplay is True: + msg += _("\nAuto-play has been disabled.") + await self.config.guild(ctx.guild).auto_play.set(False) + + embed = discord.Embed( + title=_("Repeat settings changed"), description=msg, colour=await ctx.embed_colour() + ) + await ctx.send(embed=embed) if self._player_check(ctx): await self._data_check(ctx) @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def remove(self, ctx, index: int): + 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() if not self._player_check(ctx): @@ -2954,11 +5686,12 @@ class Audio(commands.Cog): ) index -= 1 removed = player.queue.pop(index) - if "localtracks/" in removed.uri: + if any(x in removed.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): + local_path = dataclasses.LocalPath(removed.uri).to_string_hidden() if removed.title == "Unknown title": - removed_title = removed.uri.replace("localtracks/", "") + removed_title = local_path else: - removed_title = "{} - {}".format(removed.author, removed.title) + removed_title = "{} - {}\n{}".format(removed.author, removed.title, local_path) else: removed_title = removed.title await self._embed_msg( @@ -2968,7 +5701,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True, add_reactions=True) - async def search(self, ctx, *, query): + async def search(self, ctx: commands.Context, *, query: str): """Pick a track with a search. Use `[p]search list ` to queue all tracks found @@ -2987,10 +5720,11 @@ class Audio(commands.Cog): ): if message: await self._search_button_action(ctx, tracks, emoji, page) - await message.delete() + with contextlib.suppress(discord.HTTPException): + await message.delete() return None - SEARCH_CONTROLS = { + search_controls = { "1⃣": _search_menu, "2⃣": _search_menu, "3⃣": _search_menu, @@ -3011,7 +5745,7 @@ class Audio(commands.Cog): if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect or not ctx.author.voice.channel.permissions_for(ctx.me).move_members - and self._userlimit(ctx.author.voice.channel) + and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( ctx, _("I don't have permission to connect to your channel.") @@ -3039,31 +5773,56 @@ class Audio(commands.Cog): await self._data_check(ctx) if not isinstance(query, list): - query = query.strip("<>") - if query.startswith("list ") or query.startswith("folder:"): - if query.startswith("list "): - query = "ytsearch:{}".format(query.replace("list ", "")) - tracks = await player.get_tracks(query) + query = dataclasses.Query.process_input(query) + 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) + tracks = result.tracks else: - query = query.replace("folder:", "") tracks = await self._folder_tracks(ctx, player, query) if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) - - queue_duration = await self._queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_duration) + embed = discord.Embed( + title=_("Nothing found."), colour=await ctx.embed_colour() + ) + 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." + ) + return await ctx.send(embed=embed) + queue_dur = await queue_duration(ctx) + queue_total_duration = lavalink.utils.format_time(queue_dur) track_len = 0 + empty_queue = not player.queue for track in tracks: - if guild_data["maxlength"] > 0: - if self._track_limit(ctx, track, guild_data["maxlength"]): + if not await is_allowed( + ctx.guild, + ( + f"{track.title} {track.author} {track.uri} " + f"{str(dataclasses.Query.process_input(track))}" + ), + ): + log.debug(f"Query is not allowed in {ctx.guild} ({ctx.guild.id})") + continue + elif guild_data["maxlength"] > 0: + if track_limit(track, guild_data["maxlength"]): track_len += 1 player.add(ctx.author, track) + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, track, ctx.author + ) else: track_len += 1 player.add(ctx.author, track) + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, track, ctx.author + ) if not player.current: await player.play() + player.maybe_shuffle(0 if empty_queue else 1) if len(tracks) > track_len: maxlength_msg = " {bad_tracks} tracks cannot be queued.".format( bad_tracks=(len(tracks) - track_len) @@ -3076,33 +5835,33 @@ class Audio(commands.Cog): num=track_len, maxlength_msg=maxlength_msg ), ) - if not guild_data["shuffle"] and queue_duration > 0: + 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) ) return await ctx.send(embed=songembed) - elif query.startswith("sc "): - query = "scsearch:{}".format(query.replace("sc ", "")) - tracks = await player.get_tracks(query) - elif ":localtrack:" in query: - track_location = query.split(":")[2] - tracks = await self._folder_list(ctx, track_location) - elif query.startswith("localfolder:") and ":localtrack:" not in query: - folder = query.split(":")[1] + elif query.is_local and query.single_track: + tracks = await self._folder_list(ctx, query) + elif query.is_local and query.is_album: if ctx.invoked_with == "folder": - localfolder = query.replace("localfolder:", "") - return await self._local_play_all(ctx, localfolder) + return await self._local_play_all(ctx, query, from_search=True) else: - tracks = await self._folder_list(ctx, folder) - elif not self._match_url(query): - query = "ytsearch:{}".format(query) - tracks = await player.get_tracks(query) + tracks = await self._folder_list(ctx, query) else: - tracks = await player.get_tracks(query) + result, called_api = await self.music_cache.lavalink_query(ctx, player, query) + tracks = result.tracks if not tracks: - return await self._embed_msg(ctx, _("Nothing found.")) + embed = discord.Embed(title=_("Nothing found."), colour=await ctx.embed_colour()) + 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." + ) + return await ctx.send(embed=embed) else: tracks = query @@ -3117,9 +5876,9 @@ class Audio(commands.Cog): if 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) + await menu(ctx, search_page_list, search_controls) - async def _search_button_action(self, ctx, tracks, emoji, page): + async def _search_button_action(self, ctx: commands.Context, tracks, emoji, page): if not self._player_check(ctx): if self._connection_aborted: msg = _("Connection to Lavalink has failed.") @@ -3138,81 +5897,98 @@ class Audio(commands.Cog): ) player = lavalink.get_player(ctx.guild.id) guild_data = await self.config.guild(ctx.guild).all() - command = ctx.invoked_with if not await self._currency_check(ctx, guild_data["jukebox_price"]): return try: if emoji == "1⃣": search_choice = tracks[0 + (page * 5)] - if emoji == "2⃣": + elif emoji == "2⃣": search_choice = tracks[1 + (page * 5)] - if emoji == "3⃣": + elif emoji == "3⃣": search_choice = tracks[2 + (page * 5)] - if emoji == "4⃣": + elif emoji == "4⃣": search_choice = tracks[3 + (page * 5)] - if emoji == "5⃣": + elif emoji == "5⃣": 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: - if "localtracks" in search_choice.uri: - if search_choice.title == "Unknown title": + if any( + x in search_choice.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"] + ): + + localtrack = dataclasses.LocalPath(search_choice.uri) + if search_choice.title != "Unknown title": description = "**{} - {}**\n{}".format( - search_choice.author, - search_choice.title, - search_choice.uri.replace("localtracks/", ""), + search_choice.author, search_choice.title, localtrack.to_string_hidden() ) else: - description = "{}".format(search_choice.uri.replace("localtracks/", "")) + description = localtrack.to_string_hidden() else: description = "**[{}]({})**".format(search_choice.title, search_choice.uri) except AttributeError: - if command == "search": - # [p]local search - return await ctx.invoke(self.play, query=("localtracks/{}".format(search_choice))) - search_choice = search_choice.replace("localtrack:", "") - local_path = await self.config.localpath() - if not search_choice.startswith(local_path): - # folder display for [p]local play - return await ctx.invoke( - self.search, query=("localfolder:{}".format(search_choice)) - ) - else: - # file display for a chosen folder in the [p]local play menus - return await ctx.invoke(self.play, query=("localtrack:{}".format(search_choice))) + search_choice = 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) + elif search_choice.track.exists() and search_choice.track.is_file(): + 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 ) - queue_duration = await self._queue_duration(ctx) - queue_total_duration = lavalink.utils.format_time(queue_duration) - if not guild_data["shuffle"] and queue_duration > 0: + queue_dur = await queue_duration(ctx) + queue_total_duration = lavalink.utils.format_time(queue_dur) + if not await is_allowed( + ctx.guild, + ( + f"{search_choice.title} {search_choice.author} {search_choice.uri} " + f"{str(dataclasses.Query.process_input(search_choice))}" + ), + ): + 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.")) + elif guild_data["maxlength"] > 0: + + if track_limit(search_choice.length, guild_data["maxlength"]): + player.add(ctx.author, search_choice) + player.maybe_shuffle() + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author + ) + else: + return await self._embed_msg(ctx, _("Track exceeds maximum length.")) + else: + player.add(ctx.author, search_choice) + player.maybe_shuffle() + self.bot.dispatch( + "red_audio_track_enqueue", player.channel.guild, search_choice, ctx.author + ) + + if not guild_data["shuffle"] and queue_dur > 0: embed.set_footer( text=_("{time} until track playback: #{position} in queue").format( time=queue_total_duration, position=len(player.queue) + 1 ) ) - elif queue_duration > 0: - embed.set_footer(text=_("#{position} in queue").format(position=len(player.queue) + 1)) - if guild_data["maxlength"] > 0: - if self._track_limit(ctx, search_choice.length, guild_data["maxlength"]): - player.add(ctx.author, search_choice) - else: - return await self._embed_msg(ctx, _("Track exceeds maximum length.")) - else: - player.add(ctx.author, search_choice) if not player.current: await player.play() await ctx.send(embed=embed) - async def _build_search_page(self, ctx, tracks, page_num): + @staticmethod + async def _build_search_page(ctx: commands.Context, tracks, page_num): search_num_pages = math.ceil(len(tracks) / 5) search_idx_start = (page_num - 1) * 5 search_idx_end = search_idx_start + 5 search_list = "" command = ctx.invoked_with + folder = False for i, track in enumerate(tracks[search_idx_start:search_idx_end], start=search_idx_start): search_track_num = i + 1 if search_track_num > 5: @@ -3220,39 +5996,44 @@ class Audio(commands.Cog): if search_track_num == 0: search_track_num = 5 try: - if "localtracks" in track.uri: + if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): search_list += "`{0}.` **{1}**\n[{2}]\n".format( - search_track_num, track.title, track.uri.replace("localtracks/", "") + search_track_num, + track.title, + dataclasses.LocalPath(track.uri).to_string_hidden(), ) else: search_list += "`{0}.` **[{1}]({2})**\n".format( search_track_num, track.title, track.uri ) except AttributeError: - if "localtrack:" not in track and command != "search": - search_list += "`{}.` **{}**\n".format(search_track_num, track) + # query = Query.process_input(track) + track = dataclasses.Query.process_input(track) + if ( + any(x in str(track) for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]) + and command != "search" + ): + search_list += "`{}.` **{}**\n".format( + search_track_num, track.to_string_user() + ) folder = True elif command == "search": - search_list += "`{}.` **{}**\n".format(search_track_num, track) - folder = False - else: - local_path = await self.config.localpath() search_list += "`{}.` **{}**\n".format( - search_track_num, - track.replace("localtrack:{}/localtracks/".format(local_path), ""), + search_track_num, track.to_string_user() ) - folder = False - try: - title_check = tracks[0].uri + else: + search_list += "`{}.` **{}**\n".format( + search_track_num, track.to_string_user() + ) + if hasattr(tracks[0], "uri"): title = _("Tracks Found:") footer = _("search results") - except AttributeError: - if folder: - title = _("Folders Found:") - footer = _("local folders") - else: - title = _("Files Found:") - footer = _("local tracks") + elif folder: + title = _("Folders Found:") + footer = _("local folders") + else: + title = _("Files Found:") + footer = _("local tracks") embed = discord.Embed( colour=await ctx.embed_colour(), title=title, description=search_list ) @@ -3269,31 +6050,32 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def seek(self, ctx, seconds): + 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() vote_enabled = await self.config.guild(ctx.guild).vote_enabled() + is_alone = await self._is_alone(ctx, ctx.author) + 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.")) 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): + 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.")) - if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( - ctx, ctx.author - ): - return await self._embed_msg(ctx, _("You need the DJ role to use seek.")) - if vote_enabled: - if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone( - ctx, ctx.author - ): - return await self._embed_msg( - ctx, _("There are other people listening - vote to skip instead.") - ) + + 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.") + ) + + 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.") + ) + if player.current: if player.current.is_stream: return await self._embed_msg(ctx, _("Can't seek on a stream.")) @@ -3303,7 +6085,7 @@ class Audio(commands.Cog): abs_position = False except ValueError: abs_position = True - seconds = int(await self._time_convert(seconds) / 1000) + seconds = time_convert(seconds) if seconds == 0: return await self._embed_msg(ctx, _("Invalid input for the time to seek.")) if not abs_position: @@ -3335,7 +6117,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def shuffle(self, ctx): + async def shuffle(self, ctx: commands.Context): """Toggle shuffle.""" dj_enabled = await self.config.guild(ctx.guild).dj_enabled() if dj_enabled: @@ -3354,7 +6136,10 @@ class Audio(commands.Cog): shuffle = await self.config.guild(ctx.guild).shuffle() await self.config.guild(ctx.guild).shuffle.set(not shuffle) await self._embed_msg( - ctx, _("Shuffle tracks: {true_or_false}.").format(true_or_false=not shuffle) + ctx, + _("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) @@ -3362,7 +6147,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def sing(self, ctx): + async def sing(self, ctx: commands.Context): """Make Red sing one of her songs.""" ids = ( "zGTkAVsrfg8", @@ -3378,7 +6163,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def skip(self, ctx, skip_to_track: int = None): + 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.")) @@ -3389,13 +6174,29 @@ class Audio(commands.Cog): return await self._embed_msg( ctx, _("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() vote_enabled = await self.config.guild(ctx.guild).vote_enabled() - if dj_enabled and not vote_enabled and not await self._can_instaskip(ctx, ctx.author): - if not await self._is_alone(ctx, ctx.author): - return await self._embed_msg(ctx, _("You need the DJ role to skip tracks.")) + is_alone = await self._is_alone(ctx, ctx.author) + is_requester = await self.is_requester(ctx, ctx.author) + can_skip = await self._can_instaskip(ctx, ctx.author) + + 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.") + ) + if ( + is_requester + and not can_skip + and isinstance(skip_to_track, int) + and skip_to_track > 1 + ): + return await self._embed_msg(ctx, _("You can only skip the current track.")) + if vote_enabled: - if not await self._can_instaskip(ctx, ctx.author): + 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.") @@ -3436,7 +6237,7 @@ class Audio(commands.Cog): else: return await self._skip_action(ctx, skip_to_track) - async def _can_instaskip(self, ctx, member): + async def _can_instaskip(self, ctx: commands.Context, member: discord.Member): dj_enabled = await self.config.guild(ctx.guild).dj_enabled() @@ -3461,7 +6262,7 @@ class Audio(commands.Cog): return False - async def _is_alone(self, ctx, member): + async def _is_alone(self, ctx: commands.Context, member: discord.Member): try: user_voice = ctx.guild.get_member(member.id).voice bot_voice = ctx.guild.get_member(self.bot.user.id).voice @@ -3481,16 +6282,28 @@ class Audio(commands.Cog): nonbots = 0 return nonbots <= 1 - async def _has_dj_role(self, ctx, member): + 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()) if dj_role_obj in ctx.guild.get_member(member.id).roles: return True - else: - return False + return False - async def _skip_action(self, ctx, skip_to_track: int = None): + @staticmethod + async def is_requester(ctx: commands.Context, member: discord.Member): + try: + player = lavalink.get_player(ctx.guild.id) + log.debug(f"Current requester is {player.current}") + if player.current.requester.id == member.id: + return True + return False + except Exception as e: + log.error(e) + return False + + async def _skip_action(self, ctx: commands.Context, skip_to_track: int = None): player = lavalink.get_player(ctx.guild.id) - if not player.queue: + autoplay = await self.config.guild(player.channel.guild).auto_play() or self.owns_autoplay + if not player.current or (not player.queue and not autoplay): try: pos, dur = player.position, player.current.length except AttributeError: @@ -3513,6 +6326,15 @@ class Audio(commands.Cog): ) ) return await ctx.send(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), + ) + await ctx.send(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: @@ -3528,11 +6350,6 @@ class Audio(commands.Cog): ) ), ) - elif player.shuffle: - return await self._embed_msg( - ctx, _("Can't skip to a track while shuffle is enabled.") - ) - nexttrack = player.queue[min(skip_to_track - 1, len(player.queue) - 1)] embed = discord.Embed( colour=await ctx.embed_colour(), title=_("{skip_to_track} Tracks Skipped".format(skip_to_track=skip_to_track)), @@ -3547,28 +6364,17 @@ class Audio(commands.Cog): embed = discord.Embed( colour=await ctx.embed_colour(), title=_("Track Skipped"), - description=await self._get_description(player.current), + description=await get_description(player.current), ) await ctx.send(embed=embed) - + self.bot.dispatch("red_audio_skip_track", player.channel.guild, player.current, ctx.author) await player.play() player.queue += queue_to_append - async def _get_description(self, track): - if "localtracks" in track.uri: - if not track.title == "Unknown title": - return "**{} - {}**\n{}".format( - track.author, track.title, track.uri.replace("localtracks/", "") - ) - else: - return "{}".format(track.uri.replace("localtracks/", "")) - else: - return "**[{}]({})**".format(track.title, track.uri) - @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def stop(self, ctx): + async def stop(self, ctx: commands.Context): """Stop playback and clear the queue.""" dj_enabled = await self.config.guild(ctx.guild).dj_enabled() vote_enabled = await self.config.guild(ctx.guild).vote_enabled() @@ -3591,22 +6397,28 @@ class Audio(commands.Cog): 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.")) - if (player.is_playing) or (not player.is_playing and player.paused): - await self._embed_msg(ctx, _("Stopping...")) - await player.stop() + if ( + player.is_playing + or (not player.is_playing and player.paused) + or player.queue + or getattr(player.current, "extras", {}).get("autoplay") + ): eq = player.fetch("eq") if eq: await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands.set(eq.bands) + player.queue = [] + player.store("playing_song", None) player.store("prev_requester", None) player.store("prev_song", None) - player.store("playing_song", None) player.store("requester", None) + await player.stop() + await self._embed_msg(ctx, _("Stopping...")) @commands.command() @commands.guild_only() - @commands.cooldown(1, 15, discord.ext.commands.BucketType.guild) + @commands.cooldown(1, 15, commands.BucketType.guild) @commands.bot_has_permissions(embed_links=True) - async def summon(self, ctx): + 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: @@ -3616,7 +6428,7 @@ class Audio(commands.Cog): if ( not ctx.author.voice.channel.permissions_for(ctx.me).connect or not ctx.author.voice.channel.permissions_for(ctx.me).move_members - and self._userlimit(ctx.author.voice.channel) + and userlimit(ctx.author.voice.channel) ): return await self._embed_msg( ctx, _("I don't have permission to connect to your channel.") @@ -3640,7 +6452,7 @@ class Audio(commands.Cog): @commands.command() @commands.guild_only() @commands.bot_has_permissions(embed_links=True) - async def volume(self, ctx, vol: int = None): + 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() if not vol: @@ -3688,12 +6500,12 @@ class Audio(commands.Cog): @commands.guild_only() @commands.bot_has_permissions(embed_links=True) @checks.is_owner() - async def llsetup(self, ctx): + async def llsetup(self, ctx: commands.Context): """Lavalink server configuration options.""" pass @llsetup.command() - async def external(self, ctx): + async def external(self, ctx: commands.Context): """Toggle using external lavalink servers.""" external = await self.config.use_external_lavalink() await self.config.use_external_lavalink.set(not external) @@ -3702,7 +6514,7 @@ class Audio(commands.Cog): embed = discord.Embed( colour=await ctx.embed_colour(), title=_("External lavalink server: {true_or_false}.").format( - true_or_false=not external + true_or_false=_("Enabled") if not external else _("Disabled") ), ) await ctx.send(embed=embed) @@ -3711,13 +6523,15 @@ class Audio(commands.Cog): await self._manager.shutdown() await self._embed_msg( ctx, - _("External lavalink server: {true_or_false}.").format(true_or_false=not external), + _("External lavalink server: {true_or_false}.").format( + true_or_false=_("Enabled") if not external else _("Disabled") + ), ) self._restart_connect() @llsetup.command() - async def host(self, ctx, host): + async def host(self, ctx: commands.Context, host: str): """Set the lavalink server host.""" await self.config.host.set(host) if await self._check_external(): @@ -3732,7 +6546,7 @@ class Audio(commands.Cog): self._restart_connect() @llsetup.command() - async def password(self, ctx, password): + async def password(self, ctx: commands.Context, password: str): """Set the lavalink server password.""" await self.config.password.set(str(password)) if await self._check_external(): @@ -3750,7 +6564,7 @@ class Audio(commands.Cog): self._restart_connect() @llsetup.command() - async def restport(self, ctx, rest_port: int): + async def restport(self, ctx: commands.Context, rest_port: int): """Set the lavalink REST server port.""" await self.config.rest_port.set(rest_port) if await self._check_external(): @@ -3766,7 +6580,7 @@ class Audio(commands.Cog): self._restart_connect() @llsetup.command() - async def wsport(self, ctx, ws_port: int): + async def wsport(self, ctx: commands.Context, ws_port: int): """Set the lavalink websocket server port.""" await self.config.ws_port.set(ws_port) if await self._check_external(): @@ -3781,7 +6595,8 @@ class Audio(commands.Cog): self._restart_connect() - async def _apply_gain(self, guild_id, band, gain): + @staticmethod + async def _apply_gain(guild_id: int, band, gain): const = { "op": "equalizer", "guildId": str(guild_id), @@ -3793,7 +6608,8 @@ class Audio(commands.Cog): except (KeyError, IndexError): pass - async def _apply_gains(self, guild_id, gains): + @staticmethod + async def _apply_gains(guild_id: int, gains): const = { "op": "equalizer", "guildId": str(guild_id), @@ -3805,7 +6621,7 @@ class Audio(commands.Cog): except (KeyError, IndexError): pass - async def _channel_check(self, ctx): + async def _channel_check(self, ctx: commands.Context): try: player = lavalink.get_player(ctx.guild.id) except KeyError: @@ -3853,19 +6669,11 @@ class Audio(commands.Cog): else: return False - async def _clear_react(self, message, emoji: dict = None): - try: - await message.clear_reactions() - except discord.Forbidden: - if not emoji: - return - for key in emoji.values(): - await asyncio.sleep(0.2) - await message.remove_reaction(key, self.bot.user) - except (discord.HTTPException, discord.NotFound): - return + async def _clear_react(self, message: discord.Message, emoji: dict = 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, jukebox_price: int): + 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: @@ -3883,91 +6691,71 @@ class Audio(commands.Cog): else: return True - async def _data_check(self, ctx): + async def _data_check(self, ctx: commands.Context): player = lavalink.get_player(ctx.guild.id) shuffle = await self.config.guild(ctx.guild).shuffle() repeat = await self.config.guild(ctx.guild).repeat() volume = await self.config.guild(ctx.guild).volume() - if player.repeat != repeat: - player.repeat = repeat - if player.shuffle != shuffle: - player.shuffle = shuffle + player.repeat = repeat + player.shuffle = shuffle if player.volume != volume: await player.set_volume(volume) async def disconnect_timer(self): stop_times = {} - + pause_times = {} while True: for p in lavalink.all_players(): server = p.channel.guild if [self.bot.user] == p.channel.members: - stop_times.setdefault(server.id, int(time.time())) + stop_times.setdefault(server.id, time.time()) + pause_times.setdefault(server.id, time.time()) else: stop_times.pop(server.id, None) - - for sid in stop_times.copy(): + if p.paused and server.id in pause_times: + try: + await p.pause(False) + except Exception: + log.error( + "Exception raised in Audio's emptypause_timer.", exc_info=True + ) + finally: + pause_times.pop(server.id, None) + else: + pause_times.pop(server.id, None) + servers = stop_times.copy() + servers.update(pause_times) + for sid in servers: server_obj = self.bot.get_guild(sid) - if await self.config.guild(server_obj).emptydc_enabled(): + if sid in stop_times and await self.config.guild(server_obj).emptydc_enabled(): emptydc_timer = await self.config.guild(server_obj).emptydc_timer() - if (int(time.time()) - stop_times[sid]) >= emptydc_timer: + if (time.time() - stop_times[sid]) >= emptydc_timer: stop_times.pop(sid) try: await lavalink.get_player(sid).disconnect() except Exception: - log.error( - "Exception raised in Audio's disconnect_timer.", exc_info=True - ) + log.error("Exception raised in Audio's emptydc_timer.", exc_info=True) pass - + elif ( + sid in pause_times and await self.config.guild(server_obj).emptypause_enabled() + ): + emptypause_timer = await self.config.guild(server_obj).emptypause_timer() + if (time.time() - pause_times.get(sid)) >= emptypause_timer: + try: + await lavalink.get_player(sid).pause() + except Exception: + log.error( + "Exception raised in Audio's emptypause_timer.", exc_info=True + ) await asyncio.sleep(5) @staticmethod - async def _draw_time(ctx): - player = lavalink.get_player(ctx.guild.id) - paused = player.paused - pos = player.position - dur = player.current.length - sections = 12 - loc_time = round((pos / dur) * sections) - bar = "\N{BOX DRAWINGS HEAVY HORIZONTAL}" - seek = "\N{RADIO BUTTON}" - if paused: - msg = "\N{DOUBLE VERTICAL BAR}" - else: - msg = "\N{BLACK RIGHT-POINTING TRIANGLE}" - for i in range(sections): - if i == loc_time: - msg += seek - else: - msg += bar - return msg - - @staticmethod - def _dynamic_time(time): - m, s = divmod(time, 60) - h, m = divmod(m, 60) - d, h = divmod(h, 24) - - if d > 0: - msg = "{0}d {1}h" - elif d == 0 and h > 0: - msg = "{1}h {2}m" - elif d == 0 and h == 0 and m > 0: - msg = "{2}m {3}s" - elif d == 0 and h == 0 and m == 0 and s > 0: - msg = "{3}s" - else: - msg = "" - return msg.format(d, h, m, s) - - @staticmethod - async def _embed_msg(ctx, title): + 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 _eq_check(self, ctx, player): + async def _eq_check(self, ctx: commands.Context, player: lavalink.Player): eq = player.fetch("eq", Equalizer()) config_bands = await self.config.custom("EQUALIZER", ctx.guild.id).eq_bands() @@ -3986,7 +6774,9 @@ class Audio(commands.Cog): player.store("eq", eq) await self._apply_gains(ctx.guild.id, config_bands) - async def _eq_interact(self, ctx, player, eq, message, selected): + async def _eq_interact( + self, ctx: commands.Context, player: lavalink.Player, eq, message, selected + ): player.store("eq", eq) emoji = { "far_left": "◀", @@ -4015,72 +6805,70 @@ class Audio(commands.Cog): await self._clear_react(message, emoji) if react_emoji == "⬅": - await self._remove_react(message, react_emoji, react_user) + await remove_react(message, react_emoji, react_user) await self._eq_interact(ctx, player, eq, message, max(selected - 1, 0)) if react_emoji == "➡": - await self._remove_react(message, react_emoji, react_user) + await remove_react(message, react_emoji, react_user) await self._eq_interact(ctx, player, eq, message, min(selected + 1, 14)) if react_emoji == "🔼": - await self._remove_react(message, react_emoji, react_user) + 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 == "🔽": - await self._remove_react(message, react_emoji, react_user) + 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 == "⏫": - await self._remove_react(message, react_emoji, react_user) + 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 == "⏬": - await self._remove_react(message, react_emoji, react_user) + 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 == "◀": - await self._remove_react(message, react_emoji, react_user) + await remove_react(message, react_emoji, react_user) selected = 0 await self._eq_interact(ctx, player, eq, message, selected) if react_emoji == "▶": - await self._remove_react(message, react_emoji, react_user) + await remove_react(message, react_emoji, react_user) selected = 14 await self._eq_interact(ctx, player, eq, message, selected) if react_emoji == "⏺": - await self._remove_react(message, react_emoji, react_user) + 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 == "ℹ": - await self._remove_react(message, react_emoji, react_user) + await remove_react(message, react_emoji, react_user) await ctx.send_help(self.eq) await self._eq_interact(ctx, player, eq, message, selected) @staticmethod - async def _eq_msg_clear(eq_message): + async def _eq_msg_clear(eq_message: discord.Message): if eq_message is not None: - try: + with contextlib.suppress(discord.HTTPException): await eq_message.delete() - except discord.errors.NotFound: - pass - async def _get_eq_reaction(self, ctx, message, emoji): + async def _get_eq_reaction(self, ctx: commands.Context, message: discord.Message, emoji): try: reaction, user = await self.bot.wait_for( "reaction_add", @@ -4095,38 +6883,7 @@ class Audio(commands.Cog): else: return reaction.emoji, user - async def _localtracks_folders(self, ctx): - if not await self._localtracks_check(ctx): - return - localtracks_folders = sorted( - ( - f - for f in os.listdir(os.getcwd() + "/localtracks/") - if not os.path.isfile(os.getcwd() + "/localtracks/" + f) - ), - key=lambda s: s.casefold(), - ) - return localtracks_folders - - @staticmethod - def _match_url(url): - try: - query_url = urlparse(url) - return all([query_url.scheme, query_url.netloc, query_url.path]) - except Exception: - return False - - @staticmethod - def _match_yt_playlist(url): - yt_list_playlist = re.compile( - r"^(https?\:\/\/)?(www\.)?(youtube\.com|youtu\.?be)" - r"(\/playlist\?).*(list=)(.*)(&|$)" - ) - if yt_list_playlist.match(url): - return True - return False - - def _play_lock(self, ctx, tf): + def _play_lock(self, ctx: commands.Context, tf): if tf: self.play_lock[ctx.message.guild.id] = True else: @@ -4143,192 +6900,10 @@ class Audio(commands.Cog): except KeyError: return False - @staticmethod - async def _queue_duration(ctx): - player = lavalink.get_player(ctx.guild.id) - duration = [] - for i in range(len(player.queue)): - if not player.queue[i].is_stream: - duration.append(player.queue[i].length) - queue_duration = sum(duration) - if not player.queue: - queue_duration = 0 - try: - if not player.current.is_stream: - remain = player.current.length - player.position - else: - remain = 0 - except AttributeError: - remain = 0 - queue_total_duration = remain + queue_duration - return queue_total_duration - - @staticmethod - async def _remove_react(message, react_emoji, react_user): - try: - await message.remove_reaction(react_emoji, react_user) - except (discord.Forbidden, discord.HTTPException, discord.NotFound): - pass - - @staticmethod - def _to_json(ctx, playlist_url, tracklist): - playlist = {"author": ctx.author.id, "playlist_url": playlist_url, "tracks": tracklist} - return playlist - - @staticmethod - def _track_creator(player, position=None, other_track=None): - 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 - track_info = {} - for k, v in zip(track_keys, track_values): - track_info[k] = v - keys = ["track", "info"] - values = [track_id, track_info] - track_obj = {} - for key, value in zip(keys, values): - track_obj[key] = value - return track_obj - - @staticmethod - def _track_limit(ctx, track, maxlength): - try: - length = round(track.length / 1000) - except AttributeError: - length = round(track / 1000) - if length > 900000000000000: # livestreams return 9223372036854775807ms - return True - elif length >= maxlength: - return False - else: - return True - - async def _time_convert(self, length): - match = re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])").match(length) - if match is not None: - hr = int(match.group(1)) if match.group(1) else 0 - mn = int(match.group(2)) if match.group(2) else 0 - sec = int(match.group(3)) if match.group(3) else 0 - pos = sec + (mn * 60) + (hr * 3600) - return pos * 1000 - else: - try: - return int(length) * 1000 - except ValueError: - return 0 - - @staticmethod - def _url_check(url): - valid_tld = [ - "youtube.com", - "youtu.be", - "soundcloud.com", - "bandcamp.com", - "vimeo.com", - "mixer.com", - "twitch.tv", - "spotify.com", - "localtracks", - ] - query_url = urlparse(url) - url_domain = ".".join(query_url.netloc.split(".")[-2:]) - if not query_url.netloc: - url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:]) - return True if url_domain in valid_tld else False - - @staticmethod - def _userlimit(channel): - if channel.user_limit == 0: - return False - if channel.user_limit < len(channel.members) + 1: - return True - else: - return False - - async def _youtube_api_search(self, yt_key, query): - params = {"q": query, "part": "id", "key": yt_key, "maxResults": 1, "type": "video"} - yt_url = "https://www.googleapis.com/youtube/v3/search" - try: - async with self.session.request("GET", yt_url, params=params) as r: - if r.status == 400: - return None - else: - search_response = await r.json() - except RuntimeError: - return None - for search_result in search_response.get("items", []): - if search_result["id"]["kind"] == "youtube#video": - return "https://www.youtube.com/watch?v={}".format(search_result["id"]["videoId"]) - - # Spotify-related methods below are originally from: https://github.com/Just-Some-Bots/MusicBot/blob/master/musicbot/spotify.py - - async def _check_token(self, token): - now = int(time.time()) - return token["expires_at"] - now < 60 - - async def _get_spotify_token(self): - if self.spotify_token and not await self._check_token(self.spotify_token): - return self.spotify_token["access_token"] - token = await self._request_token() - if token is None: - log.debug("Requested a token from Spotify, did not end up getting one.") - try: - token["expires_at"] = int(time.time()) + token["expires_in"] - except KeyError: - return - self.spotify_token = token - log.debug("Created a new access token for Spotify: {0}".format(token)) - return self.spotify_token["access_token"] - - async def _make_get(self, url, headers=None): - async with self.session.request("GET", url, headers=headers) as r: - if r.status != 200: - log.debug( - "Issue making GET request to {0}: [{1.status}] {2}".format( - url, r, await r.json() - ) - ) - return await r.json() - - async def _make_post(self, url, payload, headers=None): - async with self.session.post(url, data=payload, headers=headers) as r: - if r.status != 200: - log.debug( - "Issue making POST request to {0}: [{1.status}] {2}".format( - url, r, await r.json() - ) - ) - return await r.json() - - async def _make_spotify_req(self, url): - token = await self._get_spotify_token() - return await self._make_get(url, headers={"Authorization": "Bearer {0}".format(token)}) - - def _make_token_auth(self, client_id, client_secret): - auth_header = base64.b64encode((client_id + ":" + client_secret).encode("ascii")) - return {"Authorization": "Basic %s" % auth_header.decode("ascii")} - - async def _request_token(self): - tokens = await self.bot.get_shared_api_tokens("spotify") - self.client_id = tokens.get("client_id", "") - self.client_secret = tokens.get("client_secret", "") - payload = {"grant_type": "client_credentials"} - headers = self._make_token_auth( - self.client_id["client_id"], self.client_secret["client_secret"] - ) - r = await self._make_post( - "https://accounts.spotify.com/api/token", payload=payload, headers=headers - ) - return r - @commands.Cog.listener() - async def on_voice_state_update(self, member, before, after): + async def on_voice_state_update( + self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState + ): if after.channel != before.channel: try: self.skip_votes[before.channel.guild].remove(member.id) @@ -4337,8 +6912,9 @@ class Audio(commands.Cog): def cog_unload(self): if not self._cleaned_up: - self.bot.loop.create_task(self.session.close()) - + self.bot.dispatch("red_audio_unload", self) + self.session.detach() + self.bot.loop.create_task(self._close_database()) if self._disconnect_task: self._disconnect_task.cancel() @@ -4349,6 +6925,60 @@ class Audio(commands.Cog): self.bot.loop.create_task(lavalink.close()) if self._manager is not None: self.bot.loop.create_task(self._manager.shutdown()) + self._cleaned_up = True + @bump.error + @disconnect.error + @genre.error + @local_folder.error + @local_play.error + @local_search.error + @play.error + @prev.error + @search.error + @_playlist_append.error + @_playlist_save.error + @_playlist_update.error + @_playlist_upload.error + async def _clear_lock_on_error(self, ctx: commands.Context, error): + # TODO: Change this in a future PR + # FIXME: This seems to be consuming tracebacks and not adding them to last traceback + # which is handled by on_command_error + # Make it so that this can be used to show user friendly errors + if not isinstance( + getattr(error, "original", error), + ( + commands.CheckFailure, + commands.UserInputError, + commands.DisabledCommand, + commands.CommandOnCooldown, + ), + ): + self._play_lock(ctx, False) + await self.music_cache.run_tasks(ctx) + message = "Error in command '{}'. Check your console or logs for details.".format( + ctx.command.qualified_name + ) + await ctx.send(inline(message)) + exception_log = "Exception in command '{}'\n" "".format(ctx.command.qualified_name) + exception_log += "".join( + traceback.format_exception(type(error), error, error.__traceback__) + ) + self.bot._last_exception = exception_log + + await ctx.bot.on_command_error( + ctx, getattr(error, "original", error), unhandled_by_cog=True + ) + + async def cog_after_invoke(self, ctx: commands.Context): + await self._process_db(ctx) + + async def _process_db(self, ctx: commands.Context): + await self.music_cache.run_tasks(ctx) + + async def _close_database(self): + await self.music_cache.run_all_pending_tasks() + await self.music_cache.close() + __del__ = cog_unload diff --git a/redbot/cogs/audio/checks.py b/redbot/cogs/audio/checks.py new file mode 100644 index 000000000..d6f9e6315 --- /dev/null +++ b/redbot/cogs/audio/checks.py @@ -0,0 +1,37 @@ +from redbot.core import Config, commands + +from .apis import HAS_SQL + +_config = None + + +def _pass_config_to_checks(config: Config): + global _config + if _config is None: + _config = config + + +def roomlocked(): + """Deny the command if the bot has been room locked.""" + + async def predicate(ctx: commands.Context): + if ctx.guild is None: + return False + if await ctx.bot.is_mod(member=ctx.author): + return True + + room_id = await _config.guild(ctx.guild).room_lock() + if room_id is None or ctx.channel.id == room_id: + return True + return False + + return commands.check(predicate) + + +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/converters.py b/redbot/cogs/audio/converters.py new file mode 100644 index 000000000..05736cf8b --- /dev/null +++ b/redbot/cogs/audio/converters.py @@ -0,0 +1,495 @@ +import argparse +import functools +from typing import Optional, Tuple, Union + +import discord + +from redbot.core import Config, commands +from redbot.core.bot import Red +from redbot.core.i18n import Translator + +from .playlists import PlaylistScope, standardize_scope + +_ = Translator("Audio", __file__) + +__all__ = [ + "ComplexScopeParser", + "PlaylistConverter", + "ScopeParser", + "LazyGreedyConverter", + "standardize_scope", + "get_lazy_converter", + "get_playlist_converter", +] + +_config = None +_bot = None + +_SCOPE_HELP = """ +Scope must be a valid version of one of the following: +​ ​ ​ ​ Global +​ ​ ​ ​ Guild +​ ​ ​ ​ User +""" +_USER_HELP = """ +Author must be a valid version of one of the following: +​ ​ ​ ​ User ID +​ ​ ​ ​ User Mention +​ ​ ​ ​ User Name#123 +""" +_GUILD_HELP = """ +Guild must be a valid version of one of the following: +​ ​ ​ ​ Guild ID +​ ​ ​ ​ Exact guild name +""" + + +def _pass_config_to_converters(config: Config, bot: Red): + global _config, _bot + if _config is None: + _config = config + if _bot is None: + _bot = bot + + +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() + ] + if not user_matches and not guild_matches and not global_matches: + raise commands.BadArgument(_("Could not match '{}' to a playlist.").format(arg)) + + return { + PlaylistScope.GLOBAL.value: global_matches, + PlaylistScope.GUILD.value: guild_matches, + PlaylistScope.USER.value: user_matches, + "arg": arg, + } + + +class NoExitParser(argparse.ArgumentParser): + def error(self, message): + raise commands.BadArgument() + + +class ScopeParser(commands.Converter): + async def convert( + self, ctx: commands.Context, argument: str + ) -> Tuple[str, discord.User, Optional[discord.Guild], bool]: + target_scope: str = PlaylistScope.GUILD.value + target_user: Optional[Union[discord.Member, discord.User]] = ctx.author + target_guild: Optional[discord.Guild] = ctx.guild + specified_user = False + + argument = argument.replace("—", "--") + + command, *arguments = argument.split(" -- ") + if arguments: + argument = " -- ".join(arguments) + else: + command = None + + parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False) + + parser.add_argument("--scope", nargs="*", dest="scope", default=[]) + parser.add_argument("--guild", nargs="*", dest="guild", default=[]) + parser.add_argument("--server", nargs="*", dest="guild", default=[]) + parser.add_argument("--author", nargs="*", dest="author", default=[]) + parser.add_argument("--user", nargs="*", dest="author", default=[]) + parser.add_argument("--member", nargs="*", dest="author", default=[]) + + if not command: + parser.add_argument("command", nargs="*") + + try: + vals = vars(parser.parse_args(argument.split())) + except Exception as exc: + raise commands.BadArgument() from exc + + if vals["scope"]: + scope_raw = " ".join(vals["scope"]).strip() + scope = scope_raw.upper().strip() + valid_scopes = PlaylistScope.list() + [ + "GLOBAL", + "GUILD", + "AUTHOR", + "USER", + "SERVER", + "MEMBER", + "BOT", + ] + if scope not in valid_scopes: + raise commands.ArgParserFailure("--scope", scope_raw, custom_help=_SCOPE_HELP) + target_scope = standardize_scope(scope) + elif "--scope" in argument and not vals["scope"]: + raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_SCOPE_HELP) + + is_owner = await ctx.bot.is_owner(ctx.author) + guild = vals.get("guild", None) or vals.get("server", None) + if is_owner and guild: + target_guild = None + guild_raw = " ".join(guild).strip() + if guild_raw.isnumeric(): + guild_raw = int(guild_raw) + try: + target_guild = ctx.bot.get_guild(guild_raw) + except Exception: + target_guild = None + guild_raw = str(guild_raw) + if target_guild is None: + try: + target_guild = await commands.GuildConverter.convert(ctx, guild_raw) + except Exception: + target_guild = None + if target_guild is None: + try: + target_guild = await ctx.bot.fetch_guild(guild_raw) + except Exception: + target_guild = None + if target_guild is None: + raise commands.ArgParserFailure("--guild", guild_raw, custom_help=_GUILD_HELP) + elif not is_owner and (guild or any(x in argument for x in ["--guild", "--server"])): + raise commands.BadArgument("You cannot use `--guild`") + elif any(x in argument for x in ["--guild", "--server"]): + raise commands.ArgParserFailure("--guild", "Nothing", custom_help=_GUILD_HELP) + + author = vals.get("author", None) or vals.get("user", None) or vals.get("member", None) + if author: + target_user = None + user_raw = " ".join(author).strip() + if user_raw.isnumeric(): + user_raw = int(user_raw) + try: + target_user = ctx.bot.get_user(user_raw) + except Exception: + target_user = None + user_raw = str(user_raw) + if target_user is None: + member_converter = commands.MemberConverter() + user_converter = commands.UserConverter() + try: + target_user = await member_converter.convert(ctx, user_raw) + except Exception: + try: + target_user = await user_converter.convert(ctx, user_raw) + except Exception: + target_user = None + if target_user is None: + try: + target_user = await ctx.bot.fetch_user(user_raw) + except Exception: + target_user = None + if target_user is None: + raise commands.ArgParserFailure("--author", user_raw, custom_help=_USER_HELP) + else: + specified_user = True + elif any(x in argument for x in ["--author", "--user", "--member"]): + raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP) + + return target_scope, target_user, target_guild, specified_user + + +class ComplexScopeParser(commands.Converter): + async def convert( + self, ctx: commands.Context, argument: str + ) -> Tuple[ + str, + discord.User, + Optional[discord.Guild], + bool, + str, + discord.User, + Optional[discord.Guild], + bool, + ]: + + target_scope: str = PlaylistScope.GUILD.value + target_user: Optional[Union[discord.Member, discord.User]] = ctx.author + target_guild: Optional[discord.Guild] = ctx.guild + specified_target_user = False + + source_scope: str = PlaylistScope.GUILD.value + source_user: Optional[Union[discord.Member, discord.User]] = ctx.author + source_guild: Optional[discord.Guild] = ctx.guild + specified_source_user = False + + argument = argument.replace("—", "--") + + command, *arguments = argument.split(" -- ") + if arguments: + argument = " -- ".join(arguments) + else: + command = None + + parser = NoExitParser(description="Playlist Scope Parsing.", add_help=False) + + parser.add_argument("--to-scope", nargs="*", dest="to_scope", default=[]) + parser.add_argument("--to-guild", nargs="*", dest="to_guild", default=[]) + parser.add_argument("--to-server", nargs="*", dest="to_server", default=[]) + parser.add_argument("--to-author", nargs="*", dest="to_author", default=[]) + parser.add_argument("--to-user", nargs="*", dest="to_user", default=[]) + parser.add_argument("--to-member", nargs="*", dest="to_member", default=[]) + + parser.add_argument("--from-scope", nargs="*", dest="from_scope", default=[]) + parser.add_argument("--from-guild", nargs="*", dest="from_guild", default=[]) + parser.add_argument("--from-server", nargs="*", dest="from_server", default=[]) + parser.add_argument("--from-author", nargs="*", dest="from_author", default=[]) + parser.add_argument("--from-user", nargs="*", dest="from_user", default=[]) + parser.add_argument("--from-member", nargs="*", dest="from_member", default=[]) + + if not command: + parser.add_argument("command", nargs="*") + + try: + vals = vars(parser.parse_args(argument.split())) + except Exception as exc: + raise commands.BadArgument() from exc + + is_owner = await ctx.bot.is_owner(ctx.author) + valid_scopes = PlaylistScope.list() + [ + "GLOBAL", + "GUILD", + "AUTHOR", + "USER", + "SERVER", + "MEMBER", + "BOT", + ] + + if vals["to_scope"]: + to_scope_raw = " ".join(vals["to_scope"]).strip() + to_scope = to_scope_raw.upper().strip() + if to_scope not in valid_scopes: + raise commands.ArgParserFailure( + "--to-scope", to_scope_raw, custom_help=_SCOPE_HELP + ) + target_scope = standardize_scope(to_scope) + elif "--to-scope" in argument and not vals["to_scope"]: + raise commands.ArgParserFailure("--to-scope", "Nothing", custom_help=_SCOPE_HELP) + + if vals["from_scope"]: + from_scope_raw = " ".join(vals["from_scope"]).strip() + from_scope = from_scope_raw.upper().strip() + + if from_scope not in valid_scopes: + raise commands.ArgParserFailure( + "--from-scope", from_scope_raw, custom_help=_SCOPE_HELP + ) + source_scope = standardize_scope(from_scope) + elif "--from-scope" in argument and not vals["to_scope"]: + raise commands.ArgParserFailure("--to-scope", "Nothing", custom_help=_SCOPE_HELP) + + to_guild = vals.get("to_guild", None) or vals.get("to_server", None) + if is_owner and to_guild: + target_guild = None + to_guild_raw = " ".join(to_guild).strip() + if to_guild_raw.isnumeric(): + to_guild_raw = int(to_guild_raw) + try: + target_guild = ctx.bot.get_guild(to_guild_raw) + except Exception: + target_guild = None + to_guild_raw = str(to_guild_raw) + if target_guild is None: + try: + target_guild = await commands.GuildConverter.convert(ctx, to_guild_raw) + except Exception: + target_guild = None + if target_guild is None: + try: + target_guild = await ctx.bot.fetch_guild(to_guild_raw) + except Exception: + target_guild = None + if target_guild is None: + raise commands.ArgParserFailure( + "--to-guild", to_guild_raw, custom_help=_GUILD_HELP + ) + elif not is_owner and ( + to_guild or any(x in argument for x in ["--to-guild", "--to-server"]) + ): + raise commands.BadArgument("You cannot use `--to-server`") + elif any(x in argument for x in ["--to-guild", "--to-server"]): + raise commands.ArgParserFailure("--to-server", "Nothing", custom_help=_GUILD_HELP) + + from_guild = vals.get("from_guild", None) or vals.get("from_server", None) + if is_owner and from_guild: + source_guild = None + from_guild_raw = " ".join(from_guild).strip() + if from_guild_raw.isnumeric(): + from_guild_raw = int(from_guild_raw) + try: + source_guild = ctx.bot.get_guild(from_guild_raw) + except Exception: + source_guild = None + from_guild_raw = str(from_guild_raw) + if source_guild is None: + try: + source_guild = await commands.GuildConverter.convert(ctx, from_guild_raw) + except Exception: + source_guild = None + if source_guild is None: + try: + source_guild = await ctx.bot.fetch_guild(from_guild_raw) + except Exception: + source_guild = None + if source_guild is None: + raise commands.ArgParserFailure( + "--from-guild", from_guild_raw, custom_help=_GUILD_HELP + ) + elif not is_owner and ( + from_guild or any(x in argument for x in ["--from-guild", "--from-server"]) + ): + raise commands.BadArgument("You cannot use `--from-server`") + elif any(x in argument for x in ["--from-guild", "--from-server"]): + raise commands.ArgParserFailure("--from-server", "Nothing", custom_help=_GUILD_HELP) + + to_author = ( + vals.get("to_author", None) or vals.get("to_user", None) or vals.get("to_member", None) + ) + if to_author: + target_user = None + to_user_raw = " ".join(to_author).strip() + if to_user_raw.isnumeric(): + to_user_raw = int(to_user_raw) + try: + source_user = ctx.bot.get_user(to_user_raw) + except Exception: + source_user = None + to_user_raw = str(to_user_raw) + if target_user is None: + member_converter = commands.MemberConverter() + user_converter = commands.UserConverter() + try: + target_user = await member_converter.convert(ctx, to_user_raw) + except Exception: + try: + target_user = await user_converter.convert(ctx, to_user_raw) + except Exception: + target_user = None + if target_user is None: + try: + target_user = await ctx.bot.fetch_user(to_user_raw) + except Exception: + target_user = None + if target_user is None: + raise commands.ArgParserFailure("--to-author", to_user_raw, custom_help=_USER_HELP) + else: + specified_target_user = True + elif any(x in argument for x in ["--to-author", "--to-user", "--to-member"]): + raise commands.ArgParserFailure("--to-user", "Nothing", custom_help=_USER_HELP) + + from_author = ( + vals.get("from_author", None) + or vals.get("from_user", None) + or vals.get("from_member", None) + ) + if from_author: + source_user = None + from_user_raw = " ".join(from_author).strip() + if from_user_raw.isnumeric(): + from_user_raw = int(from_user_raw) + try: + target_user = ctx.bot.get_user(from_user_raw) + except Exception: + source_user = None + from_user_raw = str(from_user_raw) + if source_user is None: + member_converter = commands.MemberConverter() + user_converter = commands.UserConverter() + try: + source_user = await member_converter.convert(ctx, from_user_raw) + except Exception: + try: + source_user = await user_converter.convert(ctx, from_user_raw) + except Exception: + source_user = None + if source_user is None: + try: + source_user = await ctx.bot.fetch_user(from_user_raw) + except Exception: + source_user = None + if source_user is None: + raise commands.ArgParserFailure( + "--from-author", from_user_raw, custom_help=_USER_HELP + ) + else: + specified_source_user = True + elif any(x in argument for x in ["--from-author", "--from-user", "--from-member"]): + raise commands.ArgParserFailure("--from-user", "Nothing", custom_help=_USER_HELP) + + return ( + source_scope, + source_user, + source_guild, + specified_source_user, + target_scope, + target_user, + target_guild, + specified_target_user, + ) + + +class LazyGreedyConverter(commands.Converter): + def __init__(self, splitter: str): + self.splitter_Value = splitter + + async def convert(self, ctx: commands.Context, argument: str) -> str: + full_message = ctx.message.content.partition(f" {argument} ") + if len(full_message) == 1: + full_message = ( + (argument if argument not in full_message else "") + " " + full_message[0] + ) + elif len(full_message) > 1: + full_message = ( + (argument if argument not in full_message else "") + " " + full_message[-1] + ) + greedy_output = (" " + full_message.replace("—", "--")).partition( + f" {self.splitter_Value}" + )[0] + return f"{greedy_output}".strip() + + +def get_lazy_converter(splitter: str) -> type: + """ + Returns a typechecking safe `LazyGreedyConverter` suitable for use with discord.py. + """ + + class PartialMeta(type(LazyGreedyConverter)): + __call__ = functools.partialmethod(type(LazyGreedyConverter).__call__, splitter) + + class ValidatedConverter(LazyGreedyConverter, metaclass=PartialMeta): + pass + + return ValidatedConverter + + +def get_playlist_converter() -> type: + """ + Returns a typechecking safe `PlaylistConverter` suitable for use with discord.py. + """ + + class PartialMeta(type(PlaylistConverter)): + __call__ = functools.partialmethod(type(PlaylistConverter).__call__) + + class ValidatedConverter(PlaylistConverter, metaclass=PartialMeta): + pass + + return ValidatedConverter diff --git a/redbot/cogs/audio/dataclasses.py b/redbot/cogs/audio/dataclasses.py new file mode 100644 index 000000000..eee695aa5 --- /dev/null +++ b/redbot/cogs/audio/dataclasses.py @@ -0,0 +1,486 @@ +import os +import re +from pathlib import Path, PosixPath, WindowsPath +from typing import List, Optional, Union +from urllib.parse import urlparse + +import lavalink + +from redbot.core import Config +from redbot.core.bot import Red +from redbot.core.i18n import Translator + +_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") + + +def _pass_config_to_dataclasses(config: Config, bot: Red, folder: str): + global _config, _bot, _localtrack_folder + if _config is None: + _config = config + if _bot is None: + _bot = bot + _localtrack_folder = folder + + +class ChdirClean(object): + def __init__(self, directory): + self.old_dir = os.getcwd() + self.new_dir = directory + self.cwd = None + + 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`. + """ + + _supported_music_ext = (".mp3", ".flac", ".ogg") + + def __init__(self, path, **kwargs): + self._path = path + if isinstance(path, (Path, WindowsPath, PosixPath, LocalPath)): + path = str(path.absolute()) + elif path is not None: + path = str(path) + + self.cwd = Path.cwd() + _lt_folder = Path(_localtrack_folder) if _localtrack_folder else self.cwd + _path = Path(path) if path else self.cwd + + if _lt_folder.parts[-1].lower() == "localtracks" and not kwargs.get("forced"): + self.localtrack_folder = _lt_folder + elif kwargs.get("forced"): + if _path.parts[-1].lower() == "localtracks": + self.localtrack_folder = _path + else: + self.localtrack_folder = _path / "localtracks" + else: + self.localtrack_folder = _lt_folder / "localtracks" + + try: + _path = Path(path) + _path.relative_to(self.localtrack_folder) + self.path = _path + except (ValueError, TypeError): + if path and path.startswith("localtracks//"): + path = path.replace("localtracks//", "", 1) + elif path and path.startswith("localtracks/"): + path = path.replace("localtracks/", "", 1) + self.path = self.localtrack_folder.joinpath(path) if path else self.localtrack_folder + + try: + if self.path.is_file(): + parent = self.path.parent + else: + parent = self.path + 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) + + def is_dir(self): + try: + return self.path.is_dir() + except OSError: + return False + + def exists(self): + try: + return self.path.exists() + except OSError: + return False + + def is_file(self): + try: + return self.path.is_file() + except OSError: + return False + + def absolute(self): + try: + return self.path.absolute() + except OSError: + return self._path + + @classmethod + def joinpath(cls, *args): + modified = cls(None) + modified.path = modified.path.joinpath(*args) + return modified + + def multiglob(self, *patterns): + paths = [] + for p in patterns: + paths.extend(list(self.path.glob(p))) + for p in self._filtered(paths): + yield p + + def multirglob(self, *patterns): + paths = [] + for p in patterns: + paths.extend(list(self.path.rglob(p))) + + for p in self._filtered(paths): + yield p + + def _filtered(self, paths: List[Path]): + for p in paths: + if p.suffix in self._supported_music_ext: + yield p + + def __str__(self): + return str(self.path.absolute()) + + def to_string(self): + try: + return str(self.path.absolute()) + except OSError: + return str(self._path) + + def to_string_hidden(self, arg: str = None): + string = str(self.absolute()).replace( + (str(self.localtrack_folder.absolute()) + os.sep) if arg is None else arg, "" + ) + chunked = False + while len(string) > 145 and os.sep in string: + string = string.split(os.sep, 1)[-1] + chunked = True + + if chunked: + string = f"...{os.sep}{string}" + return string + + def tracks_in_tree(self): + tracks = [] + for track in self.multirglob(*[f"*{ext}" for ext in self._supported_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 + + def subfolders_in_tree(self): + files = list(self.multirglob(*[f"*{ext}" for ext in self._supported_music_ext])) + folders = [] + for f in files: + if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder: + folders.append(f.parent) + return_folders = [] + for folder in folders: + if folder.exists() and folder.is_dir(): + return_folders.append(LocalPath(str(folder.absolute()))) + return return_folders + + def tracks_in_folder(self): + tracks = [] + for track in self.multiglob(*[f"*{ext}" for ext in self._supported_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 + + def subfolders(self): + files = list(self.multiglob(*[f"*{ext}" for ext in self._supported_music_ext])) + folders = [] + for f in files: + if f.exists() and f.parent not in folders and f.parent != self.localtrack_folder: + folders.append(f.parent) + return_folders = [] + for folder in folders: + if folder.exists() and folder.is_dir(): + return_folders.append(LocalPath(str(folder.absolute()))) + return return_folders + + +class Query: + """ + Query data class. + Use: Query.process_input(query) to generate the Query object. + """ + + def __init__(self, query: Union[LocalPath, str], **kwargs): + query = kwargs.get("queryforced", query) + self._raw: Union[LocalPath, str] = query + + _localtrack: LocalPath = LocalPath(query) + + self.track: Union[LocalPath, str] = _localtrack if ( + (_localtrack.is_file() or _localtrack.is_dir()) and _localtrack.exists() + ) else query + + self.valid: bool = query != "InvalidQueryPlaceHolderName" + self.is_local: bool = kwargs.get("local", False) + self.is_spotify: bool = kwargs.get("spotify", False) + self.is_youtube: bool = kwargs.get("youtube", False) + self.is_soundcloud: bool = kwargs.get("soundcloud", False) + self.is_bandcamp: bool = kwargs.get("bandcamp", False) + self.is_vimeo: bool = kwargs.get("vimeo", False) + self.is_mixer: bool = kwargs.get("mixer", False) + self.is_twitch: bool = kwargs.get("twitch", False) + self.is_other: bool = kwargs.get("other", False) + self.is_playlist: bool = kwargs.get("playlist", False) + self.is_album: bool = kwargs.get("album", False) + self.is_search: bool = kwargs.get("search", False) + self.is_stream: bool = kwargs.get("stream", False) + self.single_track: bool = kwargs.get("single", False) + self.id: Optional[str] = kwargs.get("id", None) + self.invoked_from: Optional[str] = kwargs.get("invoked_from", None) + self.local_name: Optional[str] = kwargs.get("name", None) + self.search_subfolders: bool = kwargs.get("search_subfolders", False) + self.spotify_uri: Optional[str] = kwargs.get("uri", None) + + self.start_time: int = kwargs.get("start_time", 0) + self.track_index: Optional[int] = kwargs.get("track_index", None) + + if self.invoked_from == "sc search": + self.is_youtube = False + self.is_soundcloud = True + + self.lavalink_query: str = self._get_query() + + if self.is_playlist or self.is_album: + self.single_track = False + + 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. + + Parameters + ---------- + query : Union[Query, LocalPath, lavalink.Track, str] + The query string or LocalPath object. + Returns + ------- + Query + Returns a parsed Query object. + """ + if not query: + query = "InvalidQueryPlaceHolderName" + possible_values = dict() + + if isinstance(query, str): + query = query.strip("<>") + + elif isinstance(query, Query): + for key, val in kwargs.items(): + setattr(query, key, val) + return query + elif isinstance(query, lavalink.Track): + possible_values["stream"] = query.is_stream + query = query.uri + + possible_values.update(dict(**kwargs)) + possible_values.update(cls._parse(query, **kwargs)) + return cls(query, **possible_values) + + @staticmethod + def _parse(track, **kwargs): + returning = {} + if ( + type(track) == type(LocalPath) + and (track.is_file() or track.is_dir()) + and track.exists() + ): + returning["local"] = True + returning["name"] = track.name + if track.is_file(): + returning["single"] = True + elif track.is_dir(): + returning["album"] = True + else: + track = str(track) + if track.startswith("spotify:"): + returning["spotify"] = True + if ":playlist:" in track: + returning["playlist"] = True + elif ":album:" in track: + returning["album"] = True + elif ":track:" in track: + returning["single"] = True + _id = track.split(":", 2)[-1] + _id = _id.split("?")[0] + returning["id"] = _id + if "#" in _id: + match = re.search(_re_spotify_timestamp, track) + if match: + returning["start_time"] = (int(match.group(1)) * 60) + int(match.group(2)) + returning["uri"] = track + return returning + if track.startswith("sc ") or track.startswith("list "): + if track.startswith("sc "): + returning["invoked_from"] = "sc search" + returning["soundcloud"] = True + elif track.startswith("list "): + returning["invoked_from"] = "search list" + track = _remove_start.sub("", track, 1) + returning["queryforced"] = track + + _localtrack = LocalPath(track) + if _localtrack.exists(): + if _localtrack.is_file(): + returning["local"] = True + returning["single"] = True + returning["name"] = _localtrack.name + return returning + elif _localtrack.is_dir(): + returning["album"] = True + returning["local"] = True + returning["name"] = _localtrack.name + return returning + try: + query_url = urlparse(track) + if all([query_url.scheme, query_url.netloc, query_url.path]): + url_domain = ".".join(query_url.netloc.split(".")[-2:]) + if not query_url.netloc: + url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:]) + if url_domain in ["youtube.com", "youtu.be"]: + returning["youtube"] = True + _has_index = "&index=" in track + if "&t=" in track: + match = re.search(_re_youtube_timestamp, track) + if match: + returning["start_time"] = int(match.group(1)) + if _has_index: + match = re.search(_re_youtube_index, track) + if match: + returning["track_index"] = int(match.group(1)) - 1 + + if all(k in track for k in ["&list=", "watch?"]): + returning["track_index"] = 0 + returning["playlist"] = True + returning["single"] = False + elif all(x in track for x in ["playlist?"]): + returning["playlist"] = True if not _has_index else False + returning["single"] = True if _has_index else False + else: + returning["single"] = True + elif url_domain == "spotify.com": + returning["spotify"] = True + if "/playlist/" in track: + returning["playlist"] = True + elif "/album/" in track: + returning["album"] = True + elif "/track/" in track: + returning["single"] = True + val = re.sub(_re_spotify_url, "", track).replace("/", ":") + if "user:" in val: + val = val.split(":", 2)[-1] + _id = val.split(":", 1)[-1] + _id = _id.split("?")[0] + + if "#" in _id: + _id = _id.split("#")[0] + match = re.search(_re_spotify_timestamp, track) + if match: + returning["start_time"] = (int(match.group(1)) * 60) + int( + match.group(2) + ) + + returning["id"] = _id + returning["uri"] = f"spotify:{val}" + elif url_domain == "soundcloud.com": + returning["soundcloud"] = True + if "#t=" in track: + match = re.search(_re_soundcloud_timestamp, track) + if match: + returning["start_time"] = (int(match.group(1)) * 60) + int( + match.group(2) + ) + if "/sets/" in track: + if "?in=" in track: + returning["single"] = True + else: + returning["playlist"] = True + else: + returning["single"] = True + elif url_domain == "bandcamp.com": + returning["bandcamp"] = True + if "/album/" in track: + returning["album"] = True + else: + returning["single"] = True + elif url_domain == "vimeo.com": + returning["vimeo"] = True + elif url_domain in ["mixer.com", "beam.pro"]: + returning["mixer"] = True + elif url_domain == "twitch.tv": + returning["twitch"] = True + if "?t=" in track: + match = re.search(_re_twitch_timestamp, track) + if match: + returning["start_time"] = ( + (int(match.group(1)) * 60 * 60) + + (int(match.group(2)) * 60) + + int(match.group(3)) + ) + + if not any(x in track for x in ["/clip/", "/videos/"]): + returning["stream"] = True + else: + returning["other"] = True + returning["single"] = True + else: + if kwargs.get("soundcloud", False): + returning["soundcloud"] = True + else: + returning["youtube"] = True + returning["search"] = True + returning["single"] = True + except Exception: + returning["search"] = True + returning["youtube"] = True + returning["single"] = True + return returning + + def _get_query(self): + if self.is_local: + return self.track.to_string() + elif self.is_spotify: + return self.spotify_uri + elif self.is_search and self.is_youtube: + return f"ytsearch:{self.track}" + elif self.is_search and self.is_soundcloud: + return f"scsearch:{self.track}" + return self.track + + def to_string_user(self): + if self.is_local: + return str(self.track.to_string_hidden()) + return str(self._raw) diff --git a/redbot/cogs/audio/equalizer.py b/redbot/cogs/audio/equalizer.py index ecb98c6ab..ee3cb62fb 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 x in range(self._band_count)] + self.bands = [0.0 for _ in range(self._band_count)] def set_gain(self, band: int, gain: float): if band < 0 or band >= self._band_count: @@ -27,14 +27,11 @@ class Equalizer: gains = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0, -0.1, -0.2, -0.25] for gain in gains: - prefix = " " - + prefix = "" if gain > 0: prefix = "+" elif gain == 0: prefix = " " - else: - prefix = "" block += f"{prefix}{gain:.2f} | " diff --git a/redbot/cogs/audio/errors.py b/redbot/cogs/audio/errors.py index 9785a9b82..6fa72bb96 100644 --- a/redbot/cogs/audio/errors.py +++ b/redbot/cogs/audio/errors.py @@ -31,3 +31,67 @@ class LavalinkDownloadFailed(AudioError, RuntimeError): def _response_repr(self) -> str: return f"[{self.response.status} {self.response.reason}]" + + +class PlayListError(AudioError): + """Base exception for errors related to playlists.""" + + +class InvalidPlaylistScope(PlayListError): + """Provided playlist scope is not valid.""" + + +class MissingGuild(PlayListError): + """Trying to access the Guild scope without a guild.""" + + +class MissingAuthor(PlayListError): + """Trying to access the User scope without an user id.""" + + +class TooManyMatches(PlayListError): + """Too many playlist match user input.""" + + +class NotAllowed(PlayListError): + """Too many playlist match user input.""" + + +class ApiError(AudioError): + """Base exception for API errors in the Audio cog.""" + + +class SpotifyApiError(ApiError): + """Base exception for Spotify API errors.""" + + +class SpotifyFetchError(SpotifyApiError): + """Fetching Spotify data failed.""" + + def __init__(self, message, *args): + self.message = message + super().__init__(*args) + + +class YouTubeApiError(ApiError): + """Base exception for YouTube Data API errors.""" + + +class DatabaseError(AudioError): + """Base exception for database errors in the Audio cog.""" + + +class InvalidTableError(DatabaseError): + """Provided table to query is not a valid table.""" + + +class LocalTrackError(AudioError): + """Base exception for local track errors.""" + + +class InvalidLocalTrack(LocalTrackError): + """Base exception for local track errors.""" + + +class InvalidLocalTrackFolder(LocalTrackError): + """Base exception for local track errors.""" diff --git a/redbot/cogs/audio/manager.py b/redbot/cogs/audio/manager.py index 4d1b22e4d..6829240f0 100644 --- a/redbot/cogs/audio/manager.py +++ b/redbot/cogs/audio/manager.py @@ -1,14 +1,15 @@ -import itertools -import pathlib -import platform -import shutil import asyncio import asyncio.subprocess # disables for # https://github.com/PyCQA/pylint/issues/1469 +import itertools import logging +import pathlib +import platform import re +import shutil import sys import tempfile -from typing import Optional, Tuple, ClassVar, List +import time +from typing import ClassVar, List, Optional, Tuple import aiohttp from tqdm import tqdm @@ -39,7 +40,6 @@ 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 = [] def __init__(self) -> None: @@ -154,12 +154,15 @@ class ServerManager: async def _wait_for_launcher(self) -> None: log.debug("Waiting for Lavalink server to be ready") + lastmessage = 0 for i in itertools.cycle(range(50)): line = await self._proc.stdout.readline() if READY_LINE_RE.search(line): self.ready.set() break - if self._proc.returncode is not None: + if self._proc.returncode is not None and lastmessage + 2 < time.time(): + # Avoid Console spam only print once every 2 seconds + lastmessage = time.time() log.critical("Internal lavalink server exited early") if i == 49: # Sleep after 50 lines to prevent busylooping diff --git a/redbot/cogs/audio/playlists.py b/redbot/cogs/audio/playlists.py new file mode 100644 index 000000000..1ac62b7fd --- /dev/null +++ b/redbot/cogs/audio/playlists.py @@ -0,0 +1,428 @@ +from collections import namedtuple +from enum import Enum, unique +from typing import List, Optional, Union + +import discord +import lavalink + +from redbot.core import Config, commands +from redbot.core.bot import Red +from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import humanize_list +from .errors import InvalidPlaylistScope, MissingAuthor, MissingGuild, NotAllowed + +_config = None +_bot = None + +__all__ = [ + "Playlist", + "PlaylistScope", + "get_playlist", + "get_all_playlist", + "create_playlist", + "reset_playlist", + "delete_playlist", + "humanize_scope", + "standardize_scope", + "FakePlaylist", +] + +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 + if _config is None: + _config = config + if _bot is None: + _bot = bot + + +def standardize_scope(scope) -> str: + scope = scope.upper() + valid_scopes = ["GLOBAL", "GUILD", "AUTHOR", "USER", "SERVER", "MEMBER", "BOT"] + + if scope in PlaylistScope.list(): + return scope + elif scope not in valid_scopes: + raise InvalidPlaylistScope( + f'"{scope}" is not a valid playlist scope.' + f" Scope needs to be one of the following: {humanize_list(valid_scopes)}" + ) + + if scope in ["GLOBAL", "BOT"]: + scope = PlaylistScope.GLOBAL.value + elif scope in ["GUILD", "SERVER"]: + scope = PlaylistScope.GUILD.value + elif scope in ["USER", "MEMBER", "AUTHOR"]: + scope = PlaylistScope.USER.value + + return scope + + +def humanize_scope(scope, ctx=None, the=None): + + 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" + elif scope == PlaylistScope.USER.value: + return str(ctx) if ctx else _("the ") if the else "" + "User" + + +def _prepare_config_scope( + scope, author: Union[discord.abc.User, int] = None, guild: discord.Guild = None +): + scope = standardize_scope(scope) + + if scope == PlaylistScope.GLOBAL.value: + config_scope = [PlaylistScope.GLOBAL.value] + elif scope == PlaylistScope.USER.value: + if author is None: + raise MissingAuthor("Invalid author for user scope.") + config_scope = [PlaylistScope.USER.value, str(getattr(author, "id", author))] + else: + if guild is None: + raise MissingGuild("Invalid guild for guild scope.") + config_scope = [PlaylistScope.GUILD.value, str(getattr(guild, "id", guild))] + return config_scope + + +class Playlist: + """A single playlist.""" + + def __init__( + self, + bot: Red, + scope: str, + author: int, + playlist_id: int, + name: str, + playlist_url: Optional[str] = None, + tracks: Optional[List[dict]] = 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.author = author + self.guild_id = ( + getattr(guild, "id", guild) if self.scope == PlaylistScope.GLOBAL.value else None + ) + self.id = playlist_id + self.name = name + self.url = playlist_url + self.tracks = tracks or [] + self.tracks_obj = [lavalink.Track(data=track) for track in self.tracks] + + async def edit(self, data: dict): + """ + Edits a Playlist. + Parameters + ---------- + data: dict + The attributes to change. + """ + # Disallow ID editing + if "id" in data: + raise NotAllowed("Playlist ID cannot be edited.") + + for item in list(data.keys()): + setattr(self, item, data[item]) + + await _config.custom(*self.config_scope, str(self.id)).set(self.to_json()) + + def to_json(self) -> dict: + """Transform the object to a dict. + Returns + ------- + dict + The playlist in the form of a dict. + """ + data = dict( + id=self.id, + author=self.author, + guild=self.guild_id, + name=self.name, + playlist_url=self.url, + tracks=self.tracks, + ) + + return data + + @classmethod + async def from_json(cls, bot: Red, scope: str, playlist_number: int, data: dict, **kwargs): + """Get a Playlist object from the provided information. + Parameters + ---------- + bot: Red + The bot's instance. Needed to get the target user. + 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 = 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", []) + + return cls( + bot=bot, + guild=guild, + scope=scope, + author=author, + playlist_id=playlist_id, + name=name, + playlist_url=playlist_url, + tracks=tracks, + ) + + +async def get_playlist( + playlist_number: int, + scope: str, + bot: Red, + guild: Union[discord.Guild, int] = None, + author: Union[discord.abc.User, int] = None, +) -> Playlist: + """ + Gets the playlist with the associated playlist number. + Parameters + ---------- + playlist_number: int + The playlist number for the playlist to get. + scope: str + The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'. + guild: discord.Guild + The guild to get the playlist from if scope is GUILDPLAYLIST. + author: int + The ID of the user to get the playlist from if scope is USERPLAYLIST. + bot: Red + The bot's instance. + Returns + ------- + Playlist + The playlist associated with the playlist number. + Raises + ------ + `RuntimeError` + If there is no playlist for the specified number. + `InvalidPlaylistScope` + Passing a scope that is not supported. + `MissingGuild` + Trying to access the Guild scope without a guild. + `MissingAuthor` + Trying to access the User scope without an user id. + """ + playlist_data = await _config.custom( + *_prepare_config_scope(scope, author, guild), str(playlist_number) + ).all() + if not playlist_data["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 + ) + + +async def get_all_playlist( + scope: str, + bot: Red, + guild: Union[discord.Guild, int] = None, + author: Union[discord.abc.User, int] = None, + specified_user: bool = False, +) -> List[Playlist]: + """ + Gets all playlist for the specified scope. + Parameters + ---------- + scope: str + The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'. + guild: discord.Guild + The guild to get the playlist from if scope is GUILDPLAYLIST. + author: int + The ID of the user to get the playlist from if scope is USERPLAYLIST. + bot: Red + The bot's instance + specified_user:bool + Whether or not user ID was passed as an argparse. + Returns + ------- + list + A list of all playlists for the specified scope + Raises + ------ + `InvalidPlaylistScope` + Passing a scope that is not supported. + `MissingGuild` + Trying to access the Guild scope without a guild. + `MissingAuthor` + Trying to access the User scope without an user id. + """ + playlists = await _config.custom(*_prepare_config_scope(scope, author, guild)).all() + 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") + ] + else: + return [ + await Playlist.from_json( + bot, scope, playlist_number, playlist_data, guild=guild, author=author + ) + for playlist_number, playlist_data in playlists.items() + ] + + +async def create_playlist( + ctx: commands.Context, + scope: str, + playlist_name: str, + playlist_url: Optional[str] = None, + tracks: Optional[List[dict]] = None, + author: Optional[discord.User] = None, + guild: Optional[discord.Guild] = None, +) -> Optional[Playlist]: + """ + Creates a new Playlist. + + Parameters + ---------- + ctx: commands.Context + The context in which the play list is being created. + scope: str + The custom config scope. One of 'GLOBALPLAYLIST', 'GUILDPLAYLIST' or 'USERPLAYLIST'. + playlist_name: str + The name of the new playlist. + playlist_url:str + the url of the new playlist. + tracks: List[dict] + A list of tracks to add to the playlist. + author: discord.User + The Author of the playlist. + If provided it will create a playlist under this user. + This is only required when creating a playlist in User scope. + guild: discord.Guild + The guild to create this playlist under. + This is only used when creating a playlist in the Guild scope + + Raises + ------ + `InvalidPlaylistScope` + Passing a scope that is not supported. + `MissingGuild` + Trying to access the Guild scope without a guild. + `MissingAuthor` + Trying to access the User scope without an user id. + """ + + playlist = Playlist( + ctx.bot, 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() + ) + return playlist + + +async def reset_playlist( + scope: str, + guild: Union[discord.Guild, int] = None, + author: Union[discord.abc.User, int] = None, +) -> None: + """ + Wipes all playlists 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. + + 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)).clear() + + +async def delete_playlist( + scope: str, + playlist_id: Union[str, int], + 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. + """ + await _config.custom(*_prepare_config_scope(scope, author, guild), str(playlist_id)).clear() diff --git a/redbot/cogs/audio/utils.py b/redbot/cogs/audio/utils.py new file mode 100644 index 000000000..3f0d9972a --- /dev/null +++ b/redbot/cogs/audio/utils.py @@ -0,0 +1,425 @@ +import asyncio +import contextlib +import os +import re +import time +from typing import NoReturn +from urllib.parse import urlparse + +import discord +import lavalink + +from redbot.core import Config, commands +from redbot.core.bot import Red +from . import dataclasses + +from .converters import _pass_config_to_converters + +from .playlists import _pass_config_to_playlist + +__all__ = [ + "pass_config_to_dependencies", + "track_limit", + "queue_duration", + "draw_time", + "dynamic_time", + "match_url", + "clear_react", + "match_yt_playlist", + "remove_react", + "get_description", + "track_creator", + "time_convert", + "url_check", + "userlimit", + "is_allowed", + "CacheLevel", + "Notifier", +] +_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 + + +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) + dataclasses._pass_config_to_dataclasses(config, bot, localtracks_folder) + + +def track_limit(track, maxlength): + try: + length = round(track.length / 1000) + except AttributeError: + length = round(track / 1000) + + if maxlength < length <= 900000000000000: # livestreams return 9223372036854775807ms + return False + return True + + +async def is_allowed(guild: discord.Guild, query: str): + query = 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) + + +async def queue_duration(ctx): + player = lavalink.get_player(ctx.guild.id) + duration = [] + for i in range(len(player.queue)): + if not player.queue[i].is_stream: + duration.append(player.queue[i].length) + queue_dur = sum(duration) + if not player.queue: + queue_dur = 0 + try: + if not player.current.is_stream: + remain = player.current.length - player.position + else: + remain = 0 + except AttributeError: + remain = 0 + queue_total_duration = remain + queue_dur + return queue_total_duration + + +async def draw_time(ctx): + player = lavalink.get_player(ctx.guild.id) + paused = player.paused + pos = player.position + dur = player.current.length + sections = 12 + loc_time = round((pos / dur) * sections) + bar = "\N{BOX DRAWINGS HEAVY HORIZONTAL}" + seek = "\N{RADIO BUTTON}" + if paused: + msg = "\N{DOUBLE VERTICAL BAR}" + else: + msg = "\N{BLACK RIGHT-POINTING TRIANGLE}" + for i in range(sections): + if i == loc_time: + msg += seek + else: + msg += bar + return msg + + +def dynamic_time(seconds): + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + + if d > 0: + msg = "{0}d {1}h" + elif d == 0 and h > 0: + msg = "{1}h {2}m" + elif d == 0 and h == 0 and m > 0: + msg = "{2}m {3}s" + elif d == 0 and h == 0 and m == 0 and s > 0: + msg = "{3}s" + else: + msg = "" + return msg.format(d, h, m, s) + + +def match_url(url): + try: + query_url = urlparse(url) + return all([query_url.scheme, query_url.netloc, query_url.path]) + except Exception: + return False + + +def match_yt_playlist(url): + if re_yt_list_playlist.match(url): + return True + return False + + +async def remove_react(message, react_emoji, react_user): + 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): + try: + await message.clear_reactions() + except discord.Forbidden: + if not emoji: + return + with contextlib.suppress(discord.HTTPException): + for key in emoji.values(): + await asyncio.sleep(0.2) + await message.remove_reaction(key, bot.user) + except discord.HTTPException: + return + + +async def get_description(track): + if any(x in track.uri for x in [f"{os.sep}localtracks", f"localtracks{os.sep}"]): + local_track = dataclasses.LocalPath(track.uri) + if track.title != "Unknown title": + return "**{} - {}**\n{}".format( + track.author, track.title, local_track.to_string_hidden() + ) + else: + return local_track.to_string_hidden() + else: + return "**[{}]({})**".format(track.title, track.uri) + + +def track_creator(player, position=None, other_track=None): + 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 + track_info = {} + for k, v in zip(track_keys, track_values): + track_info[k] = v + keys = ["track", "info"] + values = [track_id, track_info] + track_obj = {} + for key, value in zip(keys, values): + track_obj[key] = value + return track_obj + + +def time_convert(length): + match = re.compile(_re_time_converter).match(length) + if match is not None: + hr = int(match.group(1)) if match.group(1) else 0 + mn = int(match.group(2)) if match.group(2) else 0 + sec = int(match.group(3)) if match.group(3) else 0 + pos = sec + (mn * 60) + (hr * 3600) + return pos + else: + try: + return int(length) + except ValueError: + return 0 + + +def url_check(url): + valid_tld = [ + "youtube.com", + "youtu.be", + "soundcloud.com", + "bandcamp.com", + "vimeo.com", + "beam.pro", + "mixer.com", + "twitch.tv", + "spotify.com", + "localtracks", + ] + query_url = urlparse(url) + url_domain = ".".join(query_url.netloc.split(".")[-2:]) + if not query_url.netloc: + url_domain = ".".join(query_url.path.split("/")[0].split(".")[-2:]) + return True if url_domain in valid_tld else False + + +def userlimit(channel): + if channel.user_limit == 0 or channel.user_limit > len(channel.members) + 1: + return False + return True + + +class CacheLevel: + __slots__ = ("value",) + + def __init__(self, level=0): + if not isinstance(level, int): + raise TypeError( + f"Expected int parameter, received {level.__class__.__name__} instead." + ) + elif level < 0: + level = 0 + elif level > 0b11111: + level = 0b11111 + + self.value = level + + def __eq__(self, other): + return isinstance(other, CacheLevel) and self.value == other.value + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.value) + + def __add__(self, other): + return CacheLevel(self.value + other.value) + + def __radd__(self, other): + return CacheLevel(other.value + self.value) + + def __sub__(self, other): + return CacheLevel(self.value - other.value) + + def __rsub__(self, other): + return CacheLevel(other.value - self.value) + + def __str__(self): + return "{0:b}".format(self.value) + + def __format__(self, format_spec): + return "{r:{f}}".format(r=self.value, f=format_spec) + + def __repr__(self): + return f"" + + def is_subset(self, other): + """Returns ``True`` if self has the same or fewer caching levels as other.""" + return (self.value & other.value) == self.value + + def is_superset(self, other): + """Returns ``True`` if self has the same or more caching levels as other.""" + return (self.value | other.value) == self.value + + def is_strict_subset(self, other): + """Returns ``True`` if the caching level on other are a strict subset of those on self.""" + return self.is_subset(other) and self != other + + def is_strict_superset(self, other): + """Returns ``True`` if the caching level on + other are a strict superset of those on self.""" + return self.is_superset(other) and self != other + + __le__ = is_subset + __ge__ = is_superset + __lt__ = is_strict_subset + __gt__ = is_strict_superset + + @classmethod + def all(cls): + """A factory method that creates a :class:`CacheLevel` with max caching level.""" + return cls(0b11111) + + @classmethod + def none(cls): + """A factory method that creates a :class:`CacheLevel` with no caching.""" + return cls(0) + + @classmethod + def set_spotify(cls): + """A factory method that creates a :class:`CacheLevel` with Spotify caching level.""" + return cls(0b00011) + + @classmethod + def set_youtube(cls): + """A factory method that creates a :class:`CacheLevel` with YouTube caching level.""" + return cls(0b00100) + + @classmethod + def set_lavalink(cls): + """A factory method that creates a :class:`CacheLevel` with lavalink caching level.""" + return cls(0b11000) + + def _bit(self, index): + return bool((self.value >> index) & 1) + + def _set(self, index, value): + if value is True: + self.value |= 1 << index + elif value is False: + self.value &= ~(1 << index) + else: + raise TypeError("Value to set for CacheLevel must be a bool.") + + @property + def lavalink(self): + """:class:`bool`: Returns ``True`` if a user can deafen other users.""" + return self._bit(4) + + @lavalink.setter + def lavalink(self, value): + self._set(4, value) + + @property + def youtube(self): + """:class:`bool`: Returns ``True`` if a user can move users between other voice + channels.""" + return self._bit(2) + + @youtube.setter + def youtube(self, value): + self._set(2, value) + + @property + def spotify(self): + """:class:`bool`: Returns ``True`` if a user can use voice activation in voice channels.""" + return self._bit(1) + + @spotify.setter + def spotify(self, value): + self._set(1, value) + + +class Notifier: + def __init__(self, ctx: commands.Context, message: discord.Message, updates: dict, **kwargs): + self.context = ctx + self.message = message + self.updates = updates + self.color = None + self.last_msg_time = 0 + self.cooldown = 5 + + async def notify_user( + self, + current: int = None, + total: int = None, + key: str = None, + seconds_key: str = None, + seconds: str = None, + ) -> NoReturn: + """ + 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: + return + if self.color is None: + self.color = await self.context.embed_colour() + embed2 = discord.Embed( + colour=self.color, + title=self.updates.get(key).format(num=current, total=total, seconds=seconds), + ) + if seconds and seconds_key: + embed2.set_footer(text=self.updates.get(seconds_key).format(seconds=seconds)) + try: + await self.message.edit(embed=embed2) + self.last_msg_time = time.time() + except discord.errors.NotFound: + pass + + async def update_text(self, text: str) -> NoReturn: + embed2 = discord.Embed(colour=self.color, title=text) + try: + await self.message.edit(embed=embed2) + except discord.errors.NotFound: + pass + + async def update_embed(self, embed: discord.Embed) -> NoReturn: + try: + await self.message.edit(embed=embed) + self.last_msg_time = time.time() + except discord.errors.NotFound: + pass diff --git a/redbot/core/commands/errors.py b/redbot/core/commands/errors.py index 5c264c83e..63f7d0b5e 100644 --- a/redbot/core/commands/errors.py +++ b/redbot/core/commands/errors.py @@ -3,7 +3,12 @@ import inspect import discord from discord.ext import commands -__all__ = ["ConversionFailure", "BotMissingPermissions", "UserFeedbackCheckFailure"] +__all__ = [ + "ConversionFailure", + "BotMissingPermissions", + "UserFeedbackCheckFailure", + "ArgParserFailure", +] class ConversionFailure(commands.BadArgument): @@ -30,3 +35,16 @@ class UserFeedbackCheckFailure(commands.CheckFailure): def __init__(self, message=None, *args): self.message = message super().__init__(message, *args) + + +class ArgParserFailure(UserFeedbackCheckFailure): + """Raised when parsing an argument fails.""" + + def __init__( + self, cmd: str, user_input: str, custom_help: str = None, ctx_send_help: bool = False + ): + self.cmd = cmd + self.user_input = user_input + self.send_cmd_help = ctx_send_help + self.custom_help_msg = custom_help + super().__init__() diff --git a/redbot/core/events.py b/redbot/core/events.py index 10521c557..77509dd83 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -175,6 +175,13 @@ def init_events(bot, cli_flags): if isinstance(error, commands.MissingRequiredArgument): await ctx.send_help() + elif isinstance(error, commands.ArgParserFailure): + msg = f"`{error.user_input}` is not a valid value for `{error.cmd}`" + if error.custom_help_msg: + msg += f"\n{error.custom_help_msg}" + await ctx.send(msg) + if error.send_cmd_help: + await ctx.send_help() elif isinstance(error, commands.ConversionFailure): if error.args: await ctx.send(error.args[0]) diff --git a/setup.cfg b/setup.cfg index 8b653ed99..d3a812af9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ python_requires = >=3.7 install_requires = aiohttp==3.5.4 aiohttp-json-rpc==0.12.1 + aiosqlite==0.10.0 appdirs==1.4.3 async-timeout==3.0.1 attrs==19.1.0 @@ -36,6 +37,7 @@ install_requires = Click==7.0 colorama==0.4.1 contextlib2==0.5.5 + databases[sqlite]==0.2.5 discord.py==1.2.3 distro==1.4.0; sys_platform == "linux" fuzzywuzzy==0.17.0 @@ -44,7 +46,7 @@ install_requires = python-Levenshtein-wheels==0.13.1 pytz==2019.2 PyYAML==5.1.2 - Red-Lavalink==0.3.0 + Red-Lavalink==0.4.0 schema==0.7.0 tqdm==4.35.0 uvloop==0.13.0; sys_platform != "win32" and platform_python_implementation == "CPython" diff --git a/tools/primary_deps.ini b/tools/primary_deps.ini index 02cb62010..a91aa2188 100644 --- a/tools/primary_deps.ini +++ b/tools/primary_deps.ini @@ -8,10 +8,12 @@ install_requires = aiohttp aiohttp-json-rpc + aiosqlite appdirs babel click colorama + databases[sqlite] discord.py distro; sys_platform == "linux" fuzzywuzzy