diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7a302d3f..91d4c1c1f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: - id: check-json - id: check-symlinks - repo: https://github.com/PyCQA/autoflake - rev: v2.0.1 + rev: v2.0.2 hooks: - id: autoflake args: [--remove-all-unused-imports, --recursive, --in-place, --remove-unused-variables, --ignore-init-module-imports, --remove-duplicate-keys] @@ -61,12 +61,12 @@ repos: - id: upgrade-type-hints args: [--futures=true] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.21.0 + rev: 0.22.0 hooks: - id: check-github-actions - id: check-dependabot - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.7.0 + rev: v2.8.0 hooks: - id: pretty-format-yaml args: [--autofix, --indent, '4'] @@ -74,11 +74,6 @@ repos: rev: 1.13.0 hooks: - id: blacken-docs -- repo: https://github.com/sourcery-ai/sourcery - rev: v1.0.5 - hooks: - - id: sourcery - args: [--diff=git diff HEAD, --in-place, --no-summary] - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: @@ -96,7 +91,7 @@ repos: - repo: https://github.com/psf/black # The `refs/tags/:refs/tags/` is needed for black's required-version to work: # https://github.com/psf/black/issues/2493#issuecomment-1081987650 - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black - repo: https://github.com/Pierre-Sassoulas/black-disable-checker diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..925bf487e --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.DEFAULT_GOAL := help +PYTHON ?= python3 +POETRY ?= poetry +PRECOMMIT ?= pre-commit + +ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) + + +ifneq ($(wildcard $(ROOT_DIR)/venv/.),) + VENV_PYTHON = $(ROOT_DIR)/venv/bin/python + VENV_POETRY = $(ROOT_DIR)/venv/bin/poetry + VENV_PRECOMMIT = $(ROOT_DIR)/venv/bin/pre-commit +else + VENV_PYTHON = $(PYTHON) + VENV_POETRY = $(POETRY) + VENV_PRECOMMIT = $(PRECOMMIT) +endif + + +define HELP_BODY +Usage: + make + +Commands: + reformat Reformat all staged files being tracked by git. + full-reformat Reformat all files being tracked by git. + + bumpdeps Run's Poetry up + bump Bump the packages version + syncenv Sync this project's virtual environment to Red's latest dependencies. + lock Update the Poetry.lock file + plugins Install all necesarry Poetry Plugins + +endef +export HELP_BODY + +# Python Code Style +reformat: + $(VENV_PRECOMMIT) run +full-reformat: + $(VENV_PRECOMMIT) run --all + +# Poetry +bumpdeps: + $(VENV_POETRY) up --latest +syncenv: + $(VENV_POETRY) install +lock: + $(VENV_POETRY) lock +bump: + $(VENV_POETRY) dynamic-versioning +plugins: + $(VENV_POETRY) self add "poetry-dynamic-versioning[plugin]" + $(VENV_POETRY) self add "poetry-plugin-up" diff --git a/README.md b/README.md index 9d083e3a0..ac2707f4b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Official [PyLav](https://github.com/PyLav/Py-Lav) Cogs for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot) -[![Crowdin](https://badges.crowdin.net/pylav/localized.svg)](https://crowdin.com/project/pylav)[![GitHub license](https://img.shields.io/github/license/PyLav/Py-Lav.svg)](https://github.com/PyLav/Py-Lav/blob/master/LICENSE) +# Official [PyLav](https://github.com/PyLav/PyLav) Cogs for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot) +[![Crowdin](https://badges.crowdin.net/pylav/localized.svg)](https://crowdin.com/project/pylav)[![GitHub license](https://img.shields.io/github/license/PyLav/PyLav.svg)](https://github.com/PyLav/Py-Lav/blob/develop/LICENSE) [![Support Server](https://img.shields.io/discord/970987707834720266)](https://discord.com/invite/vnmcXqtgeY) About Cogs @@ -14,7 +14,7 @@ About Cogs | [PyLavNotifier](./plnotifier) | 1.0.0 | plnotifier |
Load with `[p]load plnotifier`

A simple Cog which allows you enable/disable notify events from PyLav.

This Cog allows you to use granularity when disabling/enabling events so that they are sent to the specified channel in your Discord server, useful for server owners who wish to see when a user takes a certain action in PyLav such as enqueueing tracks.
| No (1 text-only command) | No | [Draper](https://github.com/Drapersniper) | | [PyLavPlaylists](./plplaylists) | 1.0.0 | plplaylists |
Load with `[p]load plplaylists`

A Cog which allows you to add, manage, remove and share playlists in the User scope.

Playlists created using this Cog can be shared across servers and support all inputs supported by PyLav.
| Yes (1 root level slash command) | No | [Draper](https://github.com/Drapersniper) | | [PyLavUtils](./plutils) | 1.0.0 | plutils |
Load with `[p]load plutils`

A handful of commands for Bot Owners to help them see information about the current track and info about the track cache PyLav uses.
| No (1 text-only group command) | No | [Draper](https://github.com/Drapersniper) | -| [PyLavLocalFiles](./pllocal) | 1.0.0 | pllocal |
Load with `[p]load pllocal`

Commands to interact with local media files in the local file folder specified by PyLav.
The local file folder is configured using the PyLavConfigurator Cog, this allows you to play a plethora local files assuming Lavalink supports both the file and codecs, a list of all fully and partially supported files can be seen [here](https://github.com/PyLav/PyLav/blob/master/pylav/localfiles/__init__.py#L12).
| Yes (1 root level slash command and 1 text-only command) | No | [Draper](https://github.com/Drapersniper) | +| [PyLavLocalFiles](./pllocal) | 1.0.0 | pllocal |
Load with `[p]load pllocal`

Commands to interact with local media files in the local file folder specified by PyLav.
The local file folder is configured using the PyLavConfigurator Cog, this allows you to play a plethora local files assuming Lavalink supports both the file and codecs, a list of all fully and partially supported files can be seen [here](https://github.com/PyLav/PyLav/blob/develop/pylav/localfiles/__init__.py#L12).
| Yes (1 root level slash command and 1 text-only command) | No | [Draper](https://github.com/Drapersniper) | | [PyLavEffects](./pleffects) | 1.0.0 | pleffects |
Load with `[p]load pleffects`

Slash commands to apply filters to the player.
Effects supported are Channel Mix, Distortion, Karaoke, LowPass, Rotation, Timescale, Tremolo, Vibrato and Equalizer using these effects in conjunction, allows you to achieve some really cool effects such as Nightcore and Vaporwave.
| Yes (1 root level slash command and 1 text-only command) | No | [Draper](https://github.com/Drapersniper) | | [PyLavManagedNode](./plmanagednode) | 1.0.0 | plmanagednode |
Load with `[p]load plmanagednode`

Commands to configure Pylav's managed node.
This cog will allow you to enable/disable functionality of PyLav's managed node, the node can be disabled using the PyLavConfigurator Cog
| No (1 text-only command) | No | [Draper](https://github.com/Drapersniper) | | [PyLavRadio](./plradio) | 1.0.0 | plradio |
Load with `[p]load plradio`

Play radio stations.
This cog allows you to interact with 30,000+ radio stations.
| Yes (1 root level slash command) | No | [Draper](https://github.com/Drapersniper) | @@ -24,19 +24,13 @@ About Cogs * Cogs with version 1.0.0rc0 are considered finished and stable bar feature requests. * Cogs under version 1.0.0 are considered under development and may change without notice. -Installation ---------------------------- -To add the cogs to your Red instance run: -- `[p]repo add PyLav https://github.com/PyLav/Red-Cogs`. -- `[p]cog install PyLav ` -- `[p]load ` - Documentation --------------------------- Getting Started ------------------------------------- -Follow [PyLav Setup](https://github.com/PyLav/PyLav/blob/master/SETUP.md) +> **Warning** +> Make sure to follow [PyLav Setup](https://github.com/PyLav/PyLav/blob/develop/SETUP.md) If you already have a Red instance with PyLav setup then you can do the following @@ -46,29 +40,7 @@ If you already have a Red instance with PyLav setup then you can do the followin [p]cog install PyLav audio [p]load audio ``` ------------------------------------- -System Requirements ------------------------------------- -With a locally hosted Postgres server and locally hosted/managed lavalink node (**recommended - Best performance**): -- CPU: 3 cores or more -- RAM: 4GB or more -- Disk Space: 10GB or more (NVME Ideally, SSD OK) -With a locally hosted Postgres server and externally hosted lavalink node (Okay performance): -- CPU: 2 cores or more -- RAM: 3GB or more -- Disk Space: 10GB or more (NVME Ideally, SSD OK) - -With an externally hosted Postgres server and locally hosted/managed lavalink node (Poor performance): -- CPU: 2 cores or more -- RAM: 2GB or more -- Disk Space: 10GB or more (SSD) - -With an externally hosted Postgres server and externally hosted lavalink node (Worst performance): -- CPU: 1 cores or more -- RAM: 1GB or more -- Disk Space: 10GB or more (SSD) ------------------------------------- Translations ------------------------------------ You can help translating the project into your language here: diff --git a/audio/cog.py b/audio/cog.py index a1e35eb37..60add7a85 100644 --- a/audio/cog.py +++ b/audio/cog.py @@ -46,10 +46,16 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args: Any, **kwargs: Any): enable_context=False, ) self.context_user_play = discord.app_commands.ContextMenu( - name=_("Play from activity"), callback=self._context_user_play, type=AppCommandType.user + name=_("Play from activity"), + callback=self._context_user_play, + type=AppCommandType.user, + extras={"red_force_enable": True}, ) self.context_message_play = discord.app_commands.ContextMenu( - name=_("Play from message"), callback=self._context_message_play, type=AppCommandType.message + name=_("Play from message"), + callback=self._context_message_play, + type=AppCommandType.message, + extras={"red_force_enable": True}, ) self.bot.tree.add_command(self.context_user_play) self.bot.tree.add_command(self.context_message_play) diff --git a/audio/context_menus.py b/audio/context_menus.py index 31090ea00..9b9ff2afb 100644 --- a/audio/context_menus.py +++ b/audio/context_menus.py @@ -225,7 +225,16 @@ async def _context_user_play( await Query.from_string(search_string), player=interaction.client.pylav.get_player(interaction.guild.id), ) - if not response.tracks: + match response.loadType: + case "track": + tracks = [response.data] + case "search": + tracks = response.data + case "playlist": + tracks = response.data.tracks + case __: + tracks = [] + if not tracks: await interaction.followup.send( embed=await self.pylav.construct_embed( description=_("I could not find any tracks matching {query_variable_do_not_translate}.").format( @@ -240,7 +249,7 @@ async def _context_user_play( await self.command_play.callback( self, interaction, - query=response.tracks[0], + query=tracks[0], ) else: await interaction.followup.send( diff --git a/audio/hybrid_commands.py b/audio/hybrid_commands.py index 7b1758406..932f4ce4a 100644 --- a/audio/hybrid_commands.py +++ b/audio/hybrid_commands.py @@ -9,6 +9,7 @@ from discord import app_commands from redbot.core import commands from redbot.core.i18n import Translator +from redbot.core.utils.chat_formatting import humanize_list from pylav.core.context import PyLavContext from pylav.extension.red.ui.menus.queue import QueueMenu @@ -18,7 +19,7 @@ from pylav.extension.red.utils.validators import valid_query_attachment from pylav.helpers.format.strings import format_time_dd_hh_mm_ss, shorten_string from pylav.logging import getLogger -from pylav.nodes.api.responses.track import Track as TrackResponse +from pylav.nodes.api.responses.track import Track as Track_namespace_conflict from pylav.players.query.obj import Query from pylav.players.tracks.obj import Track from pylav.type_hints.bot import DISCORD_COG_TYPE_MIXIN @@ -35,6 +36,7 @@ class HybridCommands(DISCORD_COG_TYPE_MIXIN): name="play", description=shorten_string(max_length=100, string=_("Enqueue the specified query to be played.")), aliases=["p"], + extras={"red_force_enable": True}, ) @app_commands.describe( query=shorten_string(max_length=100, string=_("This argument is the query to play, a link or a search query.")) @@ -113,7 +115,7 @@ async def command_play(self, context: PyLavContext, *, query: str = None): # so ) return player = await self.pylav.connect_player(channel=channel, requester=context.author) - if isinstance(query, (Track, TrackResponse)): + if isinstance(query, (Track, Track_namespace_conflict)): track = await Track.build_track( node=player.node, data=query, @@ -126,7 +128,9 @@ async def command_play(self, context: PyLavContext, *, query: str = None): # so await player.add(track=track, requester=context.author.id) if not (player.is_active or player.queue.empty()): await player.next(requester=context.author) - await self._process_play_message(context, track, 1) + query = await track.query() + queries = [] if query is None else [query] + await self._process_play_message(context, track, 1, queries) return queries = [await Query.from_string(qf) for q in query.split("\n") if (qf := q.strip("<>").strip())] total_tracks_enqueue = 0 @@ -138,22 +142,63 @@ async def command_play(self, context: PyLavContext, *, query: str = None): # so if not (player.is_active or player.queue.empty()): await player.next(requester=context.author) - await self._process_play_message(context, single_track, total_tracks_enqueue) + await self._process_play_message(context, single_track, total_tracks_enqueue, queries) - async def _process_play_message(self, context, single_track, total_tracks_enqueue): + async def _process_play_message(self, context, single_track, total_tracks_enqueue, queries): artwork = None + file = None match total_tracks_enqueue: case 1: - description = _("{track_name_variable_do_not_translate} enqueued.").format( - track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True) - ) + if len(queries) == 1: + description = _( + "{track_name_variable_do_not_translate} enqueued for {service_variable_do_not_translate}." + ).format( + service_variable_do_not_translate=queries[0].source, + track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True), + ) + elif len(queries) > 1: + description = _( + "{track_name_variable_do_not_translate} enqueued for {services_variable_do_not_translate}." + ).format( + services_variable_do_not_translate=humanize_list([q.source for q in queries]), + track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True), + ) + else: + description = _("{track_name_variable_do_not_translate} enqueued.").format( + track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True) + ) artwork = await single_track.artworkUrl() + file = await single_track.get_embedded_artwork() case 0: - description = _("No tracks were found for your query.") + if len(queries) == 1: + description = _( + "No tracks were found for your query on {service_variable_do_not_translate}." + ).format(service_variable_do_not_translate=queries[0].source) + elif len(queries) > 1: + description = _( + "No tracks were found for your queries on {services_variable_do_not_translate}." + ).format(services_variable_do_not_translate=humanize_list([q.source for q in queries])) + else: + description = _("No tracks were found for your query.") case __: - description = _("{number_of_tracks_variable_do_not_translate} tracks enqueued.").format( - number_of_tracks_variable_do_not_translate=total_tracks_enqueue - ) + if len(queries) == 1: + description = _( + "{number_of_tracks_variable_do_not_translate} tracks enqueued for {service_variable_do_not_translate}." + ).format( + service_variable_do_not_translate=queries[0].source, + number_of_tracks_variable_do_not_translate=total_tracks_enqueue, + ) + elif len(queries) > 1: + description = _( + "{number_of_tracks_variable_do_not_translate} tracks enqueued for {services_variable_do_not_translate}." + ).format( + services_variable_do_not_translate=humanize_list([q.source for q in queries]), + number_of_tracks_variable_do_not_translate=total_tracks_enqueue, + ) + else: + description = _("{number_of_tracks_variable_do_not_translate} tracks enqueued.").format( + number_of_tracks_variable_do_not_translate=total_tracks_enqueue + ) await context.send( embed=await self.pylav.construct_embed( description=description, @@ -161,6 +206,7 @@ async def _process_play_message(self, context, single_track, total_tracks_enqueu messageable=context, ), ephemeral=True, + file=file, ) async def _process_play_queries(self, context, queries, player, single_track, total_tracks_enqueue): @@ -172,7 +218,7 @@ async def _process_play_queries(self, context, queries, player, single_track, to total_tracks_enqueue += count if count: if count == 1: - await player.add(requester=context.author.id, track=successful[0]) + await player.add(requester=context.author.id, track=single_track) else: await player.bulk_add(requester=context.author.id, tracks_and_queries=successful) return single_track, total_tracks_enqueue @@ -198,6 +244,7 @@ async def _process_play_search_queries(context, player, search_queries, single_t description=shorten_string( max_length=100, string=_("Request that I connect to the specified channel or your current channel.") ), + extras={"red_force_enable": True}, ) @app_commands.describe(channel=shorten_string(max_length=100, string=_("The voice channel to connect to."))) @commands.guild_only() @@ -290,6 +337,7 @@ async def command_connect(self, context: PyLavContext, *, channel: discord.Voice max_length=100, string=_("Shows which track is currently being played on this server.") ), aliases=["now"], + extras={"red_force_enable": True}, ) @commands.guild_only() @requires_player() @@ -307,12 +355,13 @@ async def command_now(self, context: PyLavContext): ephemeral=True, ) return - current_embed = await context.player.get_currently_playing_message(messageable=context) - await context.send(embed=current_embed, ephemeral=True) + kwargs = await context.player.get_currently_playing_message(messageable=context) + await context.send(ephemeral=True, **kwargs) @commands.hybrid_command( name="skip", description=shorten_string(max_length=100, string=_("Skips the current track.")), + extras={"red_force_enable": True}, ) @commands.guild_only() @requires_player() @@ -344,12 +393,14 @@ async def command_skip(self, context: PyLavContext): messageable=context, ), ephemeral=True, + file=await context.player.current.get_embedded_artwork(), ) await context.player.skip(requester=context.author) @commands.hybrid_command( name="stop", description=shorten_string(max_length=100, string=_("Stops the player and clears the queue.")), + extras={"red_force_enable": True}, ) @commands.guild_only() @requires_player() @@ -360,7 +411,21 @@ async def command_stop(self, context: PyLavContext): context = await self.bot.get_context(context) if context.interaction and not context.interaction.response.is_done(): await context.defer(ephemeral=True) - if not context.player or not context.player.current: + if not context.player: + if player_state := await self.pylav.player_manager.client.player_state_db_manager.fetch_player( + context.guild.id + ): + await player_state.delete() + await context.send( + embed=await context.pylav.construct_embed( + description=_( + "I am not currently playing anything on this server, but I've gone ahead and wiped the saved player state." + ), + messageable=context, + ), + ephemeral=True, + ) + return await context.send( embed=await context.pylav.construct_embed( description=_("I am not currently playing anything on this server."), messageable=context @@ -382,6 +447,7 @@ async def command_stop(self, context: PyLavContext): max_length=100, string=_("Request that I disconnect from the current voice channel.") ), aliases=["disconnect"], + extras={"red_force_enable": True}, ) @requires_player() @invoker_is_dj() @@ -415,6 +481,7 @@ async def command_disconnect(self, context: PyLavContext): name="queue", description=shorten_string(max_length=100, string=_("Shows the current queue for this server.")), aliases=["q"], + extras={"red_force_enable": True}, ) @commands.guild_only() @requires_player() @@ -440,7 +507,9 @@ async def command_queue(self, context: PyLavContext): ).start(ctx=context) @commands.hybrid_command( - name="shuffle", description=shorten_string(max_length=100, string=_("Shuffles the current queue.")) + name="shuffle", + description=shorten_string(max_length=100, string=_("Shuffles the current queue.")), + extras={"red_force_enable": True}, ) @commands.guild_only() @requires_player() @@ -489,6 +558,7 @@ async def command_shuffle(self, context: PyLavContext): @commands.hybrid_command( name="repeat", description=shorten_string(max_length=100, string=_("Set whether to repeat the current song or queue.")), + extras={"red_force_enable": True}, ) @app_commands.describe(queue=shorten_string(max_length=100, string=_("Should the whole queue be repeated?"))) @commands.guild_only() @@ -532,7 +602,11 @@ async def command_repeat(self, context: PyLavContext, queue: bool | None = None) embed=await context.pylav.construct_embed(description=msg, messageable=context), ephemeral=True ) - @commands.hybrid_command(name="pause", description=shorten_string(max_length=100, string=_("Pause the player"))) + @commands.hybrid_command( + name="pause", + description=shorten_string(max_length=100, string=_("Pause the player")), + extras={"red_force_enable": True}, + ) @commands.guild_only() @requires_player() @invoker_is_dj() @@ -570,7 +644,11 @@ async def command_pause(self, context: PyLavContext): ephemeral=True, ) - @commands.hybrid_command(name="resume", description=shorten_string(max_length=100, string=_("Resume the player"))) + @commands.hybrid_command( + name="resume", + description=shorten_string(max_length=100, string=_("Resume the player")), + extras={"red_force_enable": True}, + ) @commands.guild_only() @requires_player() @invoker_is_dj() @@ -608,7 +686,9 @@ async def command_resume(self, context: PyLavContext): ephemeral=True, ) - @commands.hybrid_command(name="volume", description=_("Set the current volume for the player.")) + @commands.hybrid_command( + name="volume", description=_("Set the current volume for the player."), extras={"red_force_enable": True} + ) @app_commands.describe(volume=_("The volume to set")) @commands.guild_only() @requires_player() @@ -669,7 +749,7 @@ async def command_volume(self, context: PyLavContext, volume: int): ephemeral=True, ) - @commands.hybrid_command(name="seek", description=_("Seek the current track.")) + @commands.hybrid_command(name="seek", description=_("Seek the current track."), extras={"red_force_enable": True}) @app_commands.describe(seek=_("The player position to seek to")) @commands.guild_only() @requires_player() @@ -747,7 +827,7 @@ async def command_seek(self, context: PyLavContext, seek: str): # sourcery skip if seek == 0: seek_ms = 0 else: - seek_ms = (await context.player.fetch_position()) + seek * 1000 + seek_ms = (await context.player.position()) + seek * 1000 except ValueError: if seek[-1] == "%": try: @@ -783,7 +863,7 @@ async def command_seek(self, context: PyLavContext, seek: str): # sourcery skip ) return seek_ms = await context.player.current.duration() * (seek / 100) - seek = -round(((await context.player.fetch_position()) - seek_ms) / 1000) + seek = -round(((await context.player.position()) - seek_ms) / 1000) # Taken from https://github.com/Cog-Creators/Red-DiscordBot/blob/ec55622418810731e1ee2ede1569f81f9bddeeec/redbot/cogs/audio/core/utilities/miscellaneous.py#L28 elif (match := _RE_TIME_CONVERTER.match(seek)) is not None: hr = int(match.group(1)) if match.group(1) else 0 @@ -827,7 +907,12 @@ async def command_seek(self, context: PyLavContext, seek: str): # sourcery skip await context.player.seek(seek_ms, context.author, False) - @commands.hybrid_command(name="prev", description=_("Play previously played tracks."), aliases=["previous"]) + @commands.hybrid_command( + name="prev", + description=_("Play previously played tracks."), + aliases=["previous"], + extras={"red_force_enable": True}, + ) @commands.guild_only() @requires_player() @invoker_is_dj() @@ -870,4 +955,5 @@ async def command_previous(self, context: PyLavContext): messageable=context, ), ephemeral=True, + file=await context.player.current.get_embedded_artwork(), ) diff --git a/audio/info.json b/audio/info.json index 14be29742..c9db13169 100644 --- a/audio/info.json +++ b/audio/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "A media player cog", "tags": [ diff --git a/audio/player_commands.py b/audio/player_commands.py index 3d1dca8a7..c857da8b3 100644 --- a/audio/player_commands.py +++ b/audio/player_commands.py @@ -96,9 +96,7 @@ async def command_bump(self, context: PyLavContext, queue_number: int, after_cur with_url=True ), estimated_time_variable_do_not_translate=discord.utils.format_dt( - datetime.timedelta( - milliseconds=await player.current.duration() - await player.fetch_position() - ) + datetime.timedelta(milliseconds=await player.current.duration() - await player.position()) + get_now_utc(), style="R", ), @@ -221,6 +219,7 @@ async def _send_play_next_message( messageable=context, ), ephemeral=True, + file=await single_track.get_embedded_artwork(), ) else: await context.send( diff --git a/audio/slash_commands.py b/audio/slash_commands.py index 0926eebd9..20cc10f86 100644 --- a/audio/slash_commands.py +++ b/audio/slash_commands.py @@ -30,6 +30,7 @@ class SlashCommands(DISCORD_COG_TYPE_MIXIN): @app_commands.command( name="search", description=shorten_string(max_length=100, string=_("Search for a track, then play the selected response.")), + extras={"red_force_enable": True}, ) @app_commands.describe( source=shorten_string(max_length=100, string=_("Where to search in")), @@ -133,7 +134,16 @@ async def slash_search_autocomplete_query( value="FqgqQW21tQ@#1g2fasf2", ) ] - tracks = response.tracks[:25] + match response.loadType: + case "track": + tracks = [response.data] + case "search": + tracks = response.data + case "playlist": + tracks = response.data.tracks + case __: + tracks = [] + tracks = tracks[:25] if not tracks: return [ Choice( @@ -150,10 +160,11 @@ async def slash_search_autocomplete_query( node = interaction.client.pylav.get_my_node() if node is None: node = await interaction.client.pylav.node_manager.find_best_node(feature=feature) + player = interaction.client.pylav.get_player(interaction.guild.id) for track in tracks: track = await Track.build_track( - node=node, data=track, query=original_query, requester=interaction.user.id, player_instance=None + node=node, data=track, query=original_query, requester=interaction.user.id, player_instance=player ) if track is None: continue @@ -166,7 +177,3 @@ async def slash_search_autocomplete_query( ) ) return choices - - @slash_search.error - async def slash_search_error(self, interaction: DISCORD_INTERACTION_TYPE, error: Exception): - pass diff --git a/info.json b/info.json index 545865699..23a6c2958 100644 --- a/info.json +++ b/info.json @@ -2,7 +2,7 @@ "author": [ "Draper" ], - "description": "A collection of official cogs using the PyLav framework.\nHelp translate the project to your language by contributing to ; Translations will be made available as an update shortly after they are translated on Crowdin.\nFor issues, bugs, or suggestions contact me in the discord support server here: https://discord.com/invite/vnmcXqtgeY\nP.S. Make sure to have read the setup instructions for () to ensure you have set it up correctly before loading any cogs.", + "description": "A collection of official cogs using the PyLav framework.\nHelp translate the project to your language by contributing to ; Translations will be made available as an update shortly after they are translated on Crowdin.\nFor issues, bugs, or suggestions contact me in the discord support server here: https://discord.com/invite/vnmcXqtgeY\nP.S. Make sure to have read the setup instructions for () to ensure you have set it up correctly before loading any cogs.", "install_msg": "Thanks for checking out my cogs!\nFor issues, bugs, or suggestions contact me in the discord support server here: https://discord.com/invite/vnmcXqtgeY\nFor more information on my cogs go to my github here: ", "name": "PyLav-Cogs", "short": "Official cogs for PyLav.", diff --git a/plconfig/info.json b/plconfig/info.json index cf2517dae..ad345b9f8 100644 --- a/plconfig/info.json +++ b/plconfig/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Configure PyLav's global settings.", "tags": [ diff --git a/plconfig/view.py b/plconfig/view.py index 7d7784340..4f926c9db 100644 --- a/plconfig/view.py +++ b/plconfig/view.py @@ -8,9 +8,9 @@ from redbot.core.utils.chat_formatting import box from tabulate import tabulate +from pylav._internals.functions import get_true_path from pylav.core.context import PyLavContext from pylav.extension.bundled_node import LAVALINK_DOWNLOAD_DIR -from pylav.extension.bundled_node.utils import get_true_path from pylav.helpers.format.ascii import EightBitANSI from pylav.helpers.format.strings import shorten_string from pylav.type_hints.bot import DISCORD_COG_TYPE, DISCORD_INTERACTION_TYPE diff --git a/plcontroller/cog.py b/plcontroller/cog.py index 2ab19bef4..5f58a9304 100644 --- a/plcontroller/cog.py +++ b/plcontroller/cog.py @@ -44,7 +44,7 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.bot = bot self._config = Config.get_conf(self, identifier=208903205982044161) - self.__lock = defaultdict(asyncio.Lock) + self.__lock: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock) self._config.register_guild( channel=None, list_for_requests=False, @@ -54,10 +54,10 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args: Any, **kwargs: Any): use_slow_mode=True, ) self._channel_cache: dict[int, int] = {} - self._list_for_search_cache: dict[int, bool] = {} - self._list_for_command_cache: dict[int, bool] = {} - self._enable_antispam_cache: dict[int, bool] = {} - self._use_slow_mode_cache: dict[int, bool] = {} + self._list_for_search_cache: dict[int, bool] = defaultdict(lambda: self._config.defaults["list_for_searches"]) + self._list_for_command_cache: dict[int, bool] = defaultdict(lambda: self._config.defaults["list_for_requests"]) + self._enable_antispam_cache: dict[int, bool] = defaultdict(lambda: self._config.defaults["enable_antispam"]) + self._use_slow_mode_cache: dict[int, bool] = defaultdict(lambda: self._config.defaults["use_slow_mode"]) self._view_cache: dict[int, PersistentControllerView] = {} self.__failed_messages_to_delete: dict[int, set[discord.Message]] = defaultdict(set) self.__success_messages_to_delete: dict[int, set[discord.Message]] = defaultdict(set) @@ -69,6 +69,9 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args: Any, **kwargs: Any): self.antispam: dict[int, dict[int, AntiSpam]] = defaultdict(lambda: defaultdict(partial(AntiSpam, intervals))) + async def cog_check(self, context: PyLavContext) -> bool: + return self.__ready.is_set() + async def cog_unload(self) -> None: for view in self._view_cache.values(): view.stop() @@ -176,10 +179,8 @@ async def command_plcontrollerset_channel( @command_plcontrollerset.command(name="acceptrequests", aliases=["ar", "listen"]) async def command_plcontrollerset_acceptrequests(self, context: PyLavContext): """Toggle whether the controller should listen for requests.""" - if ( - context.guild.id not in self._channel_cache - or (channel_id := self._channel_cache[context.guild.id]) is None - or channel_id not in self._view_cache + if context.guild.id not in self._channel_cache or ( + (channel_id := self._channel_cache[context.guild.id]) is None or channel_id not in self._view_cache ): await context.send( embed=await context.construct_embed( @@ -476,6 +477,7 @@ async def skip(self, context: PyLavContext): messageable=context, ), ephemeral=True, + file=await context.player.current.get_embedded_artwork(), ) await context.player.skip(requester=context.author) @@ -600,6 +602,7 @@ async def previous(self, context: PyLavContext): messageable=context, ), ephemeral=True, + file=await context.player.current.get_embedded_artwork(), ) async def prepare_channel(self, channel: discord.TextChannel | discord.Thread | discord.VoiceChannel): diff --git a/plcontroller/info.json b/plcontroller/info.json index 72e296dd5..f66ca6e2a 100644 --- a/plcontroller/info.json +++ b/plcontroller/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "A media player cog", "tags": [ diff --git a/plcontroller/view.py b/plcontroller/view.py index 0e30d14ca..0989b0ccf 100644 --- a/plcontroller/view.py +++ b/plcontroller/view.py @@ -388,10 +388,10 @@ async def disable_slow_mode(self) -> None: return await self.channel.edit(slowmode_delay=0) - async def set_permissions(self) -> bool: + async def set_permissions(self): if isinstance(self.channel, discord.Thread): # Threads don't have permissions, so we can't set them - # We don't want o edit the permissions of the parent channel + # We don't want to edit the permissions of the parent channel # as that would affect the entire channel and all its threads. return permissions = self.channel.permissions_for(self.channel.guild.me) @@ -557,7 +557,7 @@ async def get_player(self, message: discord.Message) -> Player | None: player = await self.cog.pylav.player_manager.create(channel=channel) return player - async def get_now_playing_embed(self, forced: bool = False) -> discord.Embed: + async def get_now_playing_embed(self, forced: bool = False) -> dict[str, discord.Embed | str | discord.File]: await asyncio.sleep(1) player = self.cog.pylav.get_player(self.guild.id) if player is None or player.current is None or forced: @@ -587,11 +587,13 @@ async def get_now_playing_embed(self, forced: bool = False) -> discord.Embed: else: footer_text = None - return await self.cog.pylav.construct_embed( - description=_("I am not currently playing anything on this server."), - messageable=self.channel, - footer=footer_text, - ) + return { + "embed": await self.cog.pylav.construct_embed( + description=_("I am not currently playing anything on this server."), + messageable=self.channel, + footer=footer_text, + ) + } return await player.get_currently_playing_message( embed=True, messageable=self.channel, progress=False, show_help=self.__show_help ) @@ -599,8 +601,15 @@ async def get_now_playing_embed(self, forced: bool = False) -> discord.Embed: async def update_view(self, forced: bool = False): async with self.__update_view_lock: await self.prepare() - embed = await self.get_now_playing_embed(forced) - await self.message.edit(view=self, embed=embed) + kwargs = await self.get_now_playing_embed(forced) + attachments = [] + if "file" in kwargs: + attachments = [kwargs.pop("file")] + elif "files" in kwargs: + attachments = kwargs.pop("files") + if attachments: + kwargs["attachments"] = attachments + await self.message.edit(view=self, **kwargs) async def interaction_check(self, interaction: DISCORD_INTERACTION_TYPE, /) -> bool: if not interaction.response.is_done(): diff --git a/pleffects/cog.py b/pleffects/cog.py index e564ba0cd..e48771194 100644 --- a/pleffects/cog.py +++ b/pleffects/cog.py @@ -20,7 +20,7 @@ from pylav.helpers.format.strings import shorten_string from pylav.logging import getLogger from pylav.players.filters import Equalizer -from pylav.storage.models.equilizer import Equalizer as EqualizerModel +from pylav.storage.models.equilizer import Equalizer as Equalizer_namespace_conflict from pylav.type_hints.bot import DISCORD_BOT_TYPE, DISCORD_COG_TYPE_MIXIN, DISCORD_INTERACTION_TYPE from pylav.type_hints.dict_typing import JSON_DICT_TYPE @@ -35,7 +35,7 @@ class PyLavEffects(DISCORD_COG_TYPE_MIXIN): __version__ = "1.0.0" - slash_fx = app_commands.Group(name="fx", description="Apply or remove filters") + slash_fx = app_commands.Group(name="fx", description="Apply or remove filters", extras={"red_force_enable": True}) def __init__(self, bot: DISCORD_BOT_TYPE, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1198,7 +1198,7 @@ async def slash_fx_custom( await interaction.response.defer(ephemeral=True) context = await self.bot.get_context(interaction) - eq_model = EqualizerModel( + eq_model = Equalizer_namespace_conflict( name=name, description=description, author=context.author.id, @@ -1269,7 +1269,7 @@ async def slash_fx_save(self, interaction: DISCORD_INTERACTION_TYPE, name: str, data = context.player.equalizer.to_dict() data["name"] = name eq = context.player.equalizer.from_dict(data) - eq_model = EqualizerModel.from_filter( + eq_model = Equalizer_namespace_conflict.from_filter( equalizer=eq, context=context, scope=context.guild.id, description=description ) await eq_model.save() diff --git a/pleffects/info.json b/pleffects/info.json index fe3970262..bb2167ec3 100644 --- a/pleffects/info.json +++ b/pleffects/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Commands to manage and apply effects to the players.", "tags": [ diff --git a/pllocal/cog.py b/pllocal/cog.py index cde47226e..e7b51ebf4 100644 --- a/pllocal/cog.py +++ b/pllocal/cog.py @@ -9,7 +9,7 @@ import discord from discord import app_commands -from discord.app_commands import Choice +from discord.app_commands import AppCommandError, Choice, CommandOnCooldown, Cooldown from rapidfuzz import fuzz from redbot.core import commands from redbot.core.i18n import Translator, cog_i18n @@ -33,11 +33,15 @@ async def cache_filled(interaction: DISCORD_INTERACTION_TYPE) -> bool: - if not interaction.response.is_done(): - await interaction.response.defer(ephemeral=True) context = await interaction.client.get_context(interaction) cog: PyLavLocalFiles = context.bot.get_cog("PyLavLocalFiles") # type: ignore - return cog.pylav.local_tracks_cache.is_ready + if not cog: + return False + if not (cache := rgetattr(cog, "pylav.local_tracks_cache", None)): + return False + if not cache.is_ready: + raise CommandOnCooldown(Cooldown(1, 1), 60) + return cache.is_ready @cog_i18n(_) @@ -51,7 +55,9 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args, **kwargs): self.bot = bot async def cog_check(self, ctx: PyLavContext): - return self.pylav.local_tracks_cache.is_ready + if not (cache := rgetattr(self, "pylav.local_tracks_cache", None)): + return False + return cache.is_ready @commands.group(name="pllocalset") async def command_pllocalset(self, ctx: PyLavContext): @@ -112,6 +118,7 @@ async def command_pllocalset_update(self, context: PyLavContext) -> None: @app_commands.command( name="local", description=shorten_string(max_length=100, string=_("Play a local file or folder, supports partial searching")), + extras={"red_force_enable": True}, ) @app_commands.describe( entry=shorten_string(max_length=100, string=_("The local file or folder to play")), @@ -189,7 +196,8 @@ async def slash_local( single_track = successful[0] if successful else None if not (player.is_active or player.queue.empty()): await player.next(requester=author) - + file = discord.utils.MISSING + thumbnail = discord.utils.MISSING match count: case 0: description = _("No tracks were found for your query.") @@ -197,6 +205,8 @@ async def slash_local( description = _("{track_name_variable_do_not_translate} enqueued.").format( track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True) ) + file = await single_track.get_embedded_artwork() + thumbnail = await single_track.artworkUrl() or discord.utils.MISSING case __: description = _("I have enqueued {track_count_variable_do_not_translate} tracks.").format( track_count_variable_do_not_translate=count @@ -205,8 +215,10 @@ async def slash_local( embed=await self.pylav.construct_embed( description=description, messageable=interaction, + thumbnail=thumbnail, ), ephemeral=True, + file=file, ) @slash_local.autocomplete("entry") @@ -243,3 +255,14 @@ def _filter_partial_ratio(x: tuple[str, Query]): ) ) return entries + + @slash_local.error + async def slash_local_error(self, interaction: DISCORD_INTERACTION_TYPE, error: AppCommandError): + if isinstance(error, CommandOnCooldown): + cache = rgetattr(self.bot, "pylav.local_tracks_cache", None) + if cache and not getattr(cache, "is_ready", False): + await self.bot.tree._send_from_interaction( + interaction, _("The local track cache is currently being built, try again later.") + ) + return + await self.bot.tree.on_error(error, interaction) diff --git a/pllocal/info.json b/pllocal/info.json index 6df6dbac3..1aac54204 100644 --- a/pllocal/info.json +++ b/pllocal/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Commands to interact with local media files.", "tags": [ diff --git a/pllyrics/cog.py b/pllyrics/cog.py index fc3344e50..a0a3920fe 100644 --- a/pllyrics/cog.py +++ b/pllyrics/cog.py @@ -216,7 +216,7 @@ async def _send_full_lyrics( await channel.send( embed=await self.pylav.construct_embed( description=_( - "The Flowery API, which I use to find lyrics, has returned an error while looking for the lyrics for this song {error_variable_do_not_translate}." + "The Flowery API, which I use to find lyrics, has returned an error while looking for the lyrics for this song: {error_variable_do_not_translate}." ).format(error_variable_do_not_translate=response.error), messageable=channel, ), @@ -331,7 +331,7 @@ async def _send_lyrics_messages(self, channel, exact, lyrics, response, show_aut messageable=channel, ) ) - await channel.send(embeds=embed_list) + await channel.send(embeds=embed_list, file=await track.get_embedded_artwork()) else: translated_message = ( self._get_translated_message_contents(exact, show_author, None) @@ -351,7 +351,8 @@ async def _send_lyrics_messages(self, channel, exact, lyrics, response, show_aut provider_variable_do_not_translate=response.provider ), messageable=channel, - ) + ), + file=await track.get_embedded_artwork(), ) async def _send_timed_lyrics(self, track: Track, channel_id: int, guild: discord.Guild) -> None: @@ -384,7 +385,7 @@ async def _send_timed_lyrics(self, track: Track, channel_id: int, guild: discord sleep_duration = 0 message_content = "" start_point = chunk[0].start - if start_point < (player.estimated_position - 5000): + if start_point < (await player.position() - 5000): continue for lyric in chunk: message_content += f"{lyric.text}\n" diff --git a/pllyrics/info.json b/pllyrics/info.json index fe85fe7d1..2ba2ef5a4 100644 --- a/pllyrics/info.json +++ b/pllyrics/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Commands to interact with track lyrics.", "tags": [ diff --git a/plmanagednode/cog.py b/plmanagednode/cog.py index c29525d0e..4897ae845 100644 --- a/plmanagednode/cog.py +++ b/plmanagednode/cog.py @@ -349,11 +349,11 @@ async def command_plmanaged_config_plugins_disable( await context.defer(ephemeral=True) plugin_str = plugin.lower() plugins = [ - "lavasrc-plugin", - "skybot-lavalink-plugin", - "sponsorblock-plugin", - "lavalink-filter-plugin", - "lava-xm-plugin", + "lavasrc", + "skybot", + "sponsorblock", + "lavalink-filter", + "lava-xm", ] if plugin_str not in plugins: return await context.send( @@ -373,7 +373,7 @@ async def command_plmanaged_config_plugins_disable( folder = LAVALINK_DOWNLOAD_DIR / "plugins" for plugin in data["lavalink"]["plugins"].copy(): if plugin["dependency"].startswith("com.github.TopiSenpai.LavaSrc:lavasrc-plugin:"): - if plugin_str != "lavasrc-plugin": + if plugin_str != "lavasrc": new_plugins.append(plugin) else: filename = "lavasrc-plugin-" @@ -385,7 +385,7 @@ async def command_plmanaged_config_plugins_disable( ] ) elif plugin["dependency"].startswith("com.dunctebot:skybot-lavalink-plugin:"): - if plugin_str != "skybot-lavalink-plugin": + if plugin_str != "skybot": new_plugins.append(plugin) else: filename = "skybot-lavalink-plugin-" @@ -397,7 +397,7 @@ async def command_plmanaged_config_plugins_disable( ] ) elif plugin["dependency"].startswith("com.github.topisenpai:sponsorblock-plugin:"): - if plugin_str != "sponsorblock-plugin": + if plugin_str != "sponsorblock": new_plugins.append(plugin) else: filename = "sponsorblock-plugin-" @@ -409,7 +409,7 @@ async def command_plmanaged_config_plugins_disable( ] ) elif plugin["dependency"].startswith("com.github.esmBot:lava-xm-plugin:"): - if plugin_str != "lava-xm-plugin": + if plugin_str != "lava-xm": new_plugins.append(plugin) else: filename = "lava-xm-plugin-" @@ -421,7 +421,7 @@ async def command_plmanaged_config_plugins_disable( ] ) elif plugin["dependency"].startswith("me.rohank05:lavalink-filter-plugin:"): - if plugin_str != "lavalink-filter-plugin": + if plugin_str != "lavalink-filter": new_plugins.append(plugin) else: filename = "lavalink-filter-plugin-" @@ -460,11 +460,11 @@ async def command_plmanaged_config_plugins_enable(self, context: PyLavContext, * await context.defer(ephemeral=True) plugin_str = plugin.lower() plugins = [ - "lavasrc-plugin", - "skybot-lavalink-plugin", - "sponsorblock-plugin", - "lavalink-filter-plugin", - "lava-xm-plugin", + "lavasrc", + "skybot", + "sponsorblock", + "lavalink-filter", + "lava-xm", ] if plugin_str not in plugins: return await context.send( @@ -483,19 +483,19 @@ async def command_plmanaged_config_plugins_enable(self, context: PyLavContext, * for plugin in NODE_DEFAULT_SETTINGS["lavalink"]["plugins"]: if plugin["dependency"].startswith("com.github.TopiSenpai.LavaSrc:lavasrc-plugin:"): - if plugin_str == "lavasrc-plugin": + if plugin_str == "lavasrc": new_plugins.append(plugin) elif plugin["dependency"].startswith("com.dunctebot:skybot-lavalink-plugin:"): - if plugin_str == "skybot-lavalink-plugin": + if plugin_str == "skybot-lavalink": new_plugins.append(plugin) elif plugin["dependency"].startswith("com.github.topisenpai:sponsorblock-plugin:"): - if plugin_str == "sponsorblock-plugin": + if plugin_str == "sponsorblock": new_plugins.append(plugin) elif plugin["dependency"].startswith("com.github.esmBot:lava-xm-plugin:"): - if plugin_str == "lavalink-filter-plugin": + if plugin_str == "lavalink-filter": new_plugins.append(plugin) elif plugin["dependency"].startswith("me.rohank05:lavalink-filter-plugin:"): - if plugin_str == "lava-xm-plugin": + if plugin_str == "lava-xm": new_plugins.append(plugin) data["lavalink"]["plugins"] = new_plugins @@ -530,7 +530,7 @@ async def command_plmanaged_config_plugins_update(self, context: PyLavContext): if plugin["dependency"].startswith("com.github.TopiSenpai.LavaSrc:lavasrc-plugin:"): org = "TopiSenpai" repo = "LavaSrc" - repository = "https://jitpack.io" + repository = "https://maven.topi.wtf/releases" dependency += ":" elif plugin["dependency"].startswith("com.dunctebot:skybot-lavalink-plugin:"): org = "DuncteBot" @@ -831,7 +831,7 @@ async def command_plmanaged_config_server(self, context: PyLavContext, setting: if isinstance(possible_values[0], int): value = int(value) - if value not in range(possible_values[0], possible_values[0] + 1): + if value not in range(possible_values[0], possible_values[1] + 1): await context.send( embed=await context.pylav.construct_embed( description=_( diff --git a/plmanagednode/info.json b/plmanagednode/info.json index 85efa8c2b..eedb07d4b 100644 --- a/plmanagednode/info.json +++ b/plmanagednode/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Commands to configure Pylav's managed node.", "tags": [ diff --git a/plmigrator/cog.py b/plmigrator/cog.py index bc3b636fb..da5886109 100644 --- a/plmigrator/cog.py +++ b/plmigrator/cog.py @@ -236,7 +236,7 @@ async def _init_audio_cog_dependencies(self) -> tuple[Config, PlaylistWrapper]: emptypause_timer=0, # Supported in PyLav max_volume=150, # Supported in PyLav shuffle=None, # Supported in PyLav - volume=100, # Supported in PyLav + volume=25, # Supported in PyLav dj_enabled=False, # Supported in PyLav dj_role=None, # Supported in PyLav ) diff --git a/plmigrator/info.json b/plmigrator/info.json index f80a4209f..641aeae74 100644 --- a/plmigrator/info.json +++ b/plmigrator/info.json @@ -18,7 +18,7 @@ "permissions": [], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Convert your Audio settings to PyLav.", "tags": [ diff --git a/plnodes/info.json b/plnodes/info.json index b1c2ce0ee..29f9077ae 100644 --- a/plnodes/info.json +++ b/plnodes/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "A collection of node controller commands for PyLav.", "tags": [ diff --git a/plnotifier/cog.py b/plnotifier/cog.py index 5a4fe67a4..7c60dacf9 100644 --- a/plnotifier/cog.py +++ b/plnotifier/cog.py @@ -36,7 +36,7 @@ PlayerVolumeChangedEvent, ) from pylav.events.plugins import SegmentSkippedEvent -from pylav.events.queue import QueueEndEvent, QueueShuffledEvent, QueueTracksRemovedEvent +from pylav.events.queue import QueueEndEvent, QueueShuffledEvent, QueueTracksAddedEvent, QueueTracksRemovedEvent from pylav.events.track import ( TrackAutoPlayEvent, TrackEndEvent, @@ -45,13 +45,13 @@ TrackResumedEvent, TrackSeekEvent, TrackSkippedEvent, - TracksRequestedEvent, TrackStuckEvent, ) from pylav.events.track.track_start import ( TrackStartAppleMusicEvent, TrackStartBandcampEvent, TrackStartDeezerEvent, + TrackStartEvent, TrackStartGCTTSEvent, TrackStartGetYarnEvent, TrackStartHTTPEvent, @@ -100,6 +100,7 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args, **kwargs): track_stuck=dict(enabled=True, mention=True), track_exception=dict(enabled=True, mention=True), track_end=dict(enabled=True, mention=True), + track_start=dict(enabled=False, mention=True), track_start_youtube_music=dict(enabled=True, mention=True), track_start_spotify=dict(enabled=True, mention=True), track_start_apple_music=dict(enabled=True, mention=True), @@ -261,7 +262,10 @@ async def command_plnotify_version(self, context: PyLavContext) -> None: @command_plnotify.command(name="webhook") async def command_plnotify_webhook( - self, context: PyLavContext, *, channel: discord.TextChannel | discord.VoiceChannel | discord.Thread + self, + context: PyLavContext, + channel: discord.TextChannel | discord.VoiceChannel | discord.Thread, + use_thread: bool = True, ) -> None: # sourcery skip: low-code-quality """Set the notify channel for the player""" if isinstance(context, discord.Interaction): @@ -280,7 +284,7 @@ async def command_plnotify_webhook( ephemeral=True, ) return - if not ( + if use_thread and not ( (permission := channel.permissions_for(context.guild.me)).create_public_threads and permission.send_messages_in_threads ): @@ -300,26 +304,29 @@ async def command_plnotify_webhook( author_variable_do_not_translate=context.author ), ) - existing_thread = None - if isinstance(channel, discord.VoiceChannel): - existing_thread = channel + if not use_thread: + existing_thread = None + if isinstance(channel, discord.VoiceChannel): + existing_thread = channel + else: + for thread in channel.guild.threads: + if thread.parent.id == channel.id and thread.name.startswith("PyLavNotifier"): + existing_thread = thread + if not existing_thread: + message = await channel.send( + _("This thread will be used by PyLav to post notifications about the player.") + ) + existing_thread = await channel.create_thread( + invitable=False, + name=_("PyLavNotifier"), + message=message, + auto_archive_duration=10080, + reason=_("PyLav Notifier - Requested by {author_variable_do_not_translate}.").format( + author_variable_do_not_translate=context.author + ), + ) else: - for thread in channel.guild.threads: - if thread.parent.id == channel.id: - existing_thread = thread - if not existing_thread: - message = await channel.send( - _("This thread will be used by PyLav to post notifications about the player.") - ) - existing_thread = await channel.create_thread( - invitable=False, - name=_("PyLavNotifier"), - message=message, - auto_archive_duration=10080, - reason=_("PyLav Notifier - Requested by {author_variable_do_not_translate}.").format( - author_variable_do_not_translate=context.author - ), - ) + existing_thread = channel channel = existing_thread if old_url := await self._config.guild(context.guild).webhook_url(): with contextlib.suppress(discord.HTTPException): @@ -501,37 +508,37 @@ async def on_pylav_track_end_event(self, event: TrackEndEvent) -> None: if not notify: return match event.reason: - case "FINISHED": + case "finished": message = _( "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing because the player reached the end of the tracks runtime." ).format( track_variable_do_not_translate=await event.track.get_track_display_name(with_url=True), node_variable_do_not_translate=event.node.name, ) - case "REPLACED": + case "replaced": message = _( "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing because a new track started playing." ).format( track_variable_do_not_translate=await event.track.get_track_display_name(with_url=True), node_variable_do_not_translate=event.node.name, ) - case "LOAD_FAILED": + case "loadFailed": message = _( "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing because it failed to start." ).format( track_variable_do_not_translate=await event.track.get_track_display_name(with_url=True), node_variable_do_not_translate=event.node.name, ) - case "STOPPED": + case "stopped": message = _( - "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing becausethe player was stopped." + "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing because the player was stopped." ).format( track_variable_do_not_translate=await event.track.get_track_display_name(with_url=True), node_variable_do_not_translate=event.node.name, ) case __: message = _( - "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing becausethe node told it to stop." + "[Node={node_variable_do_not_translate}] {track_variable_do_not_translate} has finished playing because the node told it to stop." ).format( track_variable_do_not_translate=await event.track.get_track_display_name(with_url=True), node_variable_do_not_translate=event.node.name, @@ -545,6 +552,39 @@ async def on_pylav_track_end_event(self, event: TrackEndEvent) -> None: ) ) + @commands.Cog.listener() + async def on_pylav_track_start(self, event: TrackStartEvent) -> None: + player = event.player + await self.pylav.set_context_locale(player.guild) + channel = await player.notify_channel() + if channel is None: + return + data = await self._config.guild(guild=event.player.guild).get_raw( + "track_start", default={"enabled": False, "mention": True} + ) + notify, mention = data["enabled"], data["mention"] + if not notify: + return + if mention: + req = event.track.requester or self.bot.user + user = req.mention + else: + user = event.track.requester or self.bot.user + self._message_queue[channel].append( + await self.pylav.construct_embed( + title=_("Track Start Event"), + description=_( + "[Node={node_variable_do_not_translate}] Track: {track_variable_do_not_translate} has " + "started playing.\nRequested by: {requester_variable_do_not_translate}" + ).format( + track_variable_do_not_translate=await event.track.get_track_display_name(with_url=True), + requester_variable_do_not_translate=user, + node_variable_do_not_translate=event.node.name, + ), + messageable=channel, + ) + ) + @commands.Cog.listener() async def on_pylav_track_start_youtube_music_event(self, event: TrackStartYouTubeMusicEvent) -> None: player = event.player @@ -1391,7 +1431,7 @@ async def on_pylav_track_previous_requested_event(self, event: TrackPreviousRequ ) @commands.Cog.listener() - async def on_pylav_tracks_requested_event(self, event: TracksRequestedEvent) -> None: + async def on_pylav_queue_tracks_added_event(self, event: QueueTracksAddedEvent) -> None: player = event.player await self.pylav.set_context_locale(player.guild) channel = await player.notify_channel() diff --git a/plnotifier/info.json b/plnotifier/info.json index 9386ecdcb..2dfec9a7e 100644 --- a/plnotifier/info.json +++ b/plnotifier/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Configure PyLav's notifications.", "tags": [ diff --git a/plplaylists/cog.py b/plplaylists/cog.py index 4e66df55c..eacf53771 100644 --- a/plplaylists/cog.py +++ b/plplaylists/cog.py @@ -19,6 +19,7 @@ from pylav.constants.playlists import BUNDLED_PLAYLIST_IDS from pylav.core.client import Client from pylav.core.context import PyLavContext +from pylav.exceptions.client import PyLavInvalidArgumentsException from pylav.exceptions.playlist import InvalidPlaylistException from pylav.extension.red.ui.menus.generic import PaginatingMenu from pylav.extension.red.ui.menus.playlist import PlaylistCreationFlow, PlaylistManageFlow @@ -30,7 +31,7 @@ from pylav.helpers.discord.converters.queries import QueryPlaylistConverter from pylav.helpers.format.ascii import EightBitANSI from pylav.helpers.format.strings import shorten_string -from pylav.nodes.api.responses.track import Track as LavalinkTrack +from pylav.nodes.api.responses.track import Track as Track_namespace_conflict from pylav.players.query.obj import Query from pylav.players.tracks.obj import Track from pylav.storage.models.playlist import Playlist @@ -53,6 +54,7 @@ class PyLavPlaylists( slash_playlist = app_commands.Group( name="playlist", description=shorten_string(max_length=100, string=_("Control PyLav playlists")), + extras={"red_force_enable": True}, ) def __init__(self, bot: DISCORD_BOT_TYPE, *args, **kwargs): @@ -114,7 +116,7 @@ async def slash_playlist_version(self, interaction: DISCORD_INTERACTION_TYPE) -> @app_commands.guild_only() async def slash_playlist_create( self, interaction: DISCORD_INTERACTION_TYPE, url: QueryPlaylistConverter = None, *, name: str = None - ): + ): # sourcery skip: low-code-quality if not interaction.response.is_done(): await interaction.response.defer(ephemeral=True) context = await self.bot.get_context(interaction) @@ -145,12 +147,25 @@ async def slash_playlist_create( if url: add_queue = False url = await Query.from_string(url) + name = name or f"{context.message.id}" if url: tracks_response = await context.pylav.get_tracks(url, player=context.player) - tracks = list(tracks_response.tracks) + match tracks_response.loadType: + case "track": + tracks = [tracks_response.data] + artwork = tracks_response.data.pluginInfo.artworkUrl + case "search": + tracks = tracks_response.data + artwork = None + case "playlist": + tracks = tracks_response.data.tracks + artwork = tracks_response.data.pluginInfo.artworkUrl + name = name or tracks_response.data.info.name + case __: + artwork = None + tracks = [] + name = name or f"{context.message.id}" url = url.query_identifier - name = name or tracks_response.playlistInfo.name or f"{context.message.id}" - artwork = tracks_response.pluginInfo.artworkUrl else: artwork = None if add_queue and context.player: @@ -418,7 +433,16 @@ async def slash_playlist_manage( response = await self.pylav.get_tracks( *[await Query.from_string(at) for at in playlist_prompt.remove_tracks], player=context.player ) - tracks = typing.cast(collections.deque[LavalinkTrack], response.tracks) + match response.loadType: + case "track": + tracks = [response.data] + case "search": + tracks = response.data + case "playlist": + tracks = response.data.tracks + case __: + tracks = [] + tracks = typing.cast(collections.deque[Track_namespace_conflict], tracks) for t in tracks: b64 = t.encoded await playlist.remove_track(b64) @@ -429,7 +453,16 @@ async def slash_playlist_manage( *[await Query.from_string(at) for at in playlist_prompt.add_tracks], player=context.player, ) - if tracks := typing.cast(collections.deque[LavalinkTrack], response.tracks): + match response.loadType: + case "track": + tracks = [response.data] + case "search": + tracks = response.data + case "playlist": + tracks = response.data.tracks + case __: + tracks = [] + if tracks := typing.cast(collections.deque[Track_namespace_conflict], tracks): await playlist.add_track(list(tracks)) changed = True tracks_added += sum(1 for __ in tracks) @@ -439,7 +472,16 @@ async def slash_playlist_manage( response = await self.pylav.get_tracks( await Query.from_string(url), bypass_cache=True, player=context.player ) - if not response.tracks: + match response.loadType: + case "track": + tracks = [response.data] + case "search": + tracks = response.data + case "playlist": + tracks = response.data.tracks + case __: + tracks = [] + if not tracks: await context.send( embed=await context.pylav.construct_embed( messageable=context, @@ -455,7 +497,7 @@ async def slash_playlist_manage( ephemeral=True, ) return - if b64_list := typing.cast(list[LavalinkTrack], [track for track in response.tracks if track.encoded]): # type: ignore + if b64_list := typing.cast(list[Track_namespace_conflict], [track for track in tracks if track.encoded]): # type: ignore changed = True await playlist.update_tracks(b64_list) elif playlist.id in BUNDLED_PLAYLIST_IDS: @@ -468,7 +510,7 @@ async def slash_playlist_manage( if playlist_prompt.dedupe: track = await playlist.fetch_tracks() new_tracks = [ - from_dict(data_class=LavalinkTrack, data=json.loads(t)) + from_dict(data_class=Track_namespace_conflict, data=json.loads(t)) for t in {json.dumps(d, sort_keys=True) for d in track} ] if diff := len(track) - len(new_tracks): @@ -802,6 +844,154 @@ async def slash_playlist_upload(self, interaction: DISCORD_INTERACTION_TYPE, url ephemeral=True, ) + @slash_playlist.command(name="mix", description=_("Play a YouTube mix playlist from a input")) + @app_commands.describe( + video=_("The YouTube video ID to play a mix from"), + playlist=_("The YouTube playlist ID to play a mix from"), + user=_("The YouTube user ID to play a mix from"), + channel=_("The YouTube channel ID to play a mix from"), + ) + @app_commands.guild_only() + @invoker_is_dj(slash=True) + async def slash_playlist_mix( + self, + context: DISCORD_INTERACTION_TYPE, + video: str = None, + playlist: str = None, + user: str = None, + channel: str = None, + ): + if isinstance(context, discord.Interaction): + context = await self.bot.get_context(context) + if context.interaction and not context.interaction.response.is_done(): + await context.defer(ephemeral=True) + if not any([video, playlist, user, channel]): + await context.send( + embed=await self.pylav.construct_embed( + description=_("You need to give me a parameter to use."), + messageable=context, + ), + ephemeral=True, + ) + return + player = self.pylav.get_player(context.guild.id) + if player is None: + config = self.pylav.player_config_manager.get_config(context.guild.id) + if (channel := context.guild.get_channel_or_thread(await config.fetch_forced_channel_id())) is None: + channel = rgetattr(context.author, "voice.channel", None) + if not channel: + await context.send( + embed=await self.pylav.construct_embed( + description=_("You must be in a voice channel, so I can connect to it."), + messageable=context, + ), + ephemeral=True, + ) + return + if not ( + (permission := channel.permissions_for(context.guild.me)) and permission.connect and permission.speak + ): + await context.send( + embed=await self.pylav.construct_embed( + description=_( + "I do not have permission to connect or speak in {channel_name_variable_do_not_translate}." + ).format(channel_name_variable_do_not_translate=channel.mention), + messageable=context, + ), + ephemeral=True, + ) + return + player = await self.pylav.connect_player(channel=channel, requester=context.author) + try: + query = await Query.from_string( + await self.pylav.generate_mix_playlist( + video_id=video, playlist_id=playlist, user_id=user, channel_id=channel + ) + ) + except PyLavInvalidArgumentsException as e: + await context.send( + embed=await self.pylav.construct_embed( + description=str(e), + messageable=context, + ), + ephemeral=True, + ) + return + total_tracks_enqueue = 0 + single_track = None + single_track, total_tracks_enqueue = await self._process_play_queries( + context, [query], player, single_track, total_tracks_enqueue + ) + if not (player.is_active or player.queue.empty()): + await player.next(requester=context.author) + + await self._process_play_message(context, single_track, total_tracks_enqueue, [query]) + + async def _process_play_message(self, context, single_track, total_tracks_enqueue, queries): + artwork = None + file = None + match total_tracks_enqueue: + case 1: + if len(queries) == 1: + description = _( + "{track_name_variable_do_not_translate} enqueued for {service_variable_do_not_translate}." + ).format( + service_variable_do_not_translate=queries[0].source, + track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True), + ) + elif len(queries) > 1: + description = _( + "{track_name_variable_do_not_translate} enqueued for {services_variable_do_not_translate}." + ).format( + services_variable_do_not_translate=humanize_list([q.source for q in queries]), + track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True), + ) + else: + description = _("{track_name_variable_do_not_translate} enqueued.").format( + track_name_variable_do_not_translate=await single_track.get_track_display_name(with_url=True) + ) + artwork = await single_track.artworkUrl() + file = await single_track.get_embedded_artwork() + case 0: + if len(queries) == 1: + description = _( + "No tracks were found for your query on {service_variable_do_not_translate}." + ).format(service_variable_do_not_translate=queries[0].source) + elif len(queries) > 1: + description = _( + "No tracks were found for your queries on {services_variable_do_not_translate}." + ).format(services_variable_do_not_translate=humanize_list([q.source for q in queries])) + else: + description = _("No tracks were found for your query.") + case __: + if len(queries) == 1: + description = _( + "{number_of_tracks_variable_do_not_translate} tracks enqueued for {service_variable_do_not_translate}." + ).format( + service_variable_do_not_translate=queries[0].source, + number_of_tracks_variable_do_not_translate=total_tracks_enqueue, + ) + elif len(queries) > 1: + description = _( + "{number_of_tracks_variable_do_not_translate} tracks enqueued for {services_variable_do_not_translate}." + ).format( + services_variable_do_not_translate=humanize_list([q.source for q in queries]), + number_of_tracks_variable_do_not_translate=total_tracks_enqueue, + ) + else: + description = _("{number_of_tracks_variable_do_not_translate} tracks enqueued.").format( + number_of_tracks_variable_do_not_translate=total_tracks_enqueue + ) + await context.send( + embed=await self.pylav.construct_embed( + description=description, + thumbnail=artwork, + messageable=context, + ), + ephemeral=True, + file=file, + ) + @commands.command(name="__command_playlist_play", hidden=True) @always_hidden() async def command_playlist_play(self, context: PyLavContext, *, playlist: PlaylistConverter): @@ -841,7 +1031,7 @@ async def command_playlist_play(self, context: PyLavContext, *, playlist: Playli track_count = await playlist.size() tracks = await playlist.fetch_tracks() - track_objects = [from_dict(data_class=LavalinkTrack, data=track) for track in tracks] + track_objects = [from_dict(data_class=Track_namespace_conflict, data=track) for track in tracks] await player.bulk_add( requester=context.author.id, tracks_and_queries=[ diff --git a/plplaylists/info.json b/plplaylists/info.json index 2c82d57c9..caca0be3b 100644 --- a/plplaylists/info.json +++ b/plplaylists/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Control Playlists from PyLav", "tags": [ diff --git a/plradio/cog.py b/plradio/cog.py index 670b3b0fc..f1c5a47e6 100644 --- a/plradio/cog.py +++ b/plradio/cog.py @@ -45,6 +45,7 @@ def __init__(self, bot: DISCORD_BOT_TYPE, *args, **kwargs): description=shorten_string( max_length=100, string=_("Enqueue a radio station. Use the arguments to filter for a possible station") ), + extras={"red_force_enable": True}, ) @app_commands.describe( stations=shorten_string(max_length=100, string=_("The radio station to enqueue")), @@ -151,6 +152,7 @@ async def slash_radio( messageable=context, ), ephemeral=True, + file=await single_track.get_embedded_artwork(), ) await station.click() else: diff --git a/plradio/info.json b/plradio/info.json index 3e22a53c0..697249c98 100644 --- a/plradio/info.json +++ b/plradio/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "Apply some equalizer presets to the PyLav player.", "tags": [ diff --git a/plutils/cog.py b/plutils/cog.py index 0870ff219..373341cef 100644 --- a/plutils/cog.py +++ b/plutils/cog.py @@ -8,6 +8,7 @@ from pathlib import Path import discord +from discord import AppCommandType from redbot.core import commands from redbot.core.i18n import Translator, cog_i18n from redbot.core.utils.chat_formatting import box, humanize_number, inline, pagify @@ -107,10 +108,34 @@ async def command_plutils_slashes(self, context: PyLavContext): def rich_walk_commands(group: list, tree: Tree): for command in group: if isinstance(command, discord.app_commands.Group): - branch = tree.add(command.name, style="cyan") + branch = tree.add(command.name, style="green") rich_walk_commands(command.commands, branch) - else: + elif isinstance(command, discord.app_commands.Command): tree.add(command.name, style="not bold white") + elif isinstance(command, discord.app_commands.ContextMenu): + if command.type == AppCommandType.user: + tree.add( + _("{command_name_do_not_translate} # User menu").format( + command_name_do_not_translate=command.name + ), + style="not bold cyan", + ) + elif command.type == AppCommandType.message: + tree.add( + _("{command_name_do_not_translate} # Message menu").format( + command_name_do_not_translate=command.name + ), + style="not bold magenta", + ) + else: + tree.add( + _("{command_name_do_not_translate} # Unknown menu").format( + command_name_do_not_translate=command.name + ), + style="not bold yellow", + ) + else: + tree.add(command.name, style="not bold red") all_commands = self.bot.tree.get_commands() rich_tree = Tree("Slash Commands", style="bold yellow") diff --git a/plutils/info.json b/plutils/info.json index ed2370cb2..1be737217 100644 --- a/plutils/info.json +++ b/plutils/info.json @@ -20,7 +20,7 @@ ], "required_cogs": {}, "requirements": [ - "Py-Lav>=1.10.0+dirty<1.11" + "Py-Lav>=1.10.0<1.11" ], "short": "A bunch of utility commands for PyLav cogs.", "tags": [ diff --git a/pyproject.toml b/pyproject.toml index 6ab61b921..fa557cbdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,12 @@ aggressive = 3 [tool.black] line-length = 120 -target-version = ['py310'] +target-version = ['py311'] [tool.isort] profile = "black" line_length = 120 -py_version = 310 +py_version = 311 known_third_party = [ "aiofile", "aiohttp", diff --git a/tools/dependency_updater.py b/tools/dependency_updater.py index 03f782f89..b2c3a3f03 100644 --- a/tools/dependency_updater.py +++ b/tools/dependency_updater.py @@ -17,7 +17,7 @@ print(f"PyLav max version: {pylav_threshold_version}") -new_pylav_version = f"Py-Lav>={__PYLAV_VERSION__}<{pylav_threshold_version}" +new_pylav_version = f"Py-Lav[all]>={__PYLAV_VERSION__}<{pylav_threshold_version}" print(f"New PyLav version range: {new_pylav_version}")