diff --git a/.dev/notes.txt b/.dev/notes.txt index 268f4c7..652e8cf 100644 --- a/.dev/notes.txt +++ b/.dev/notes.txt @@ -58,4 +58,7 @@ colori del bot: - Modificare l'estensione verify per far si che gli utenti non possano accedere ai contenuti del server fino a quando la richiesta non e' stata accettata dai moderatori ecc. # How to create a disable slash_command -https://stackoverflow.com/questions/78211436/discord-py-check-if-slash-command-exists \ No newline at end of file +https://stackoverflow.com/questions/78211436/discord-py-check-if-slash-command-exists + +Ricreare un logger con una configurazione json +- https://github.com/mCodingLLC/VideosSampleCode/tree/master/videos/135_modern_logging \ No newline at end of file diff --git a/config/config.jsonc.template b/config/config.jsonc.template index 5fa95ab..0d13a29 100644 --- a/config/config.jsonc.template +++ b/config/config.jsonc.template @@ -57,6 +57,10 @@ ], "cookiefile": "./data/cookies/ytdlcookies.txt" } + }, + "lavalink" : { + "nodes" : [ + ] } }, @@ -97,6 +101,8 @@ "db" : "./data/database.db", "db_script" : "./config/database.sql", + "db_migrations" : "./config/migrations.sql", + "chatbot_templates" : "./data/chatbot-templates/", diff --git a/config/config.test.jsonc b/config/config.test.jsonc index 2c0c447..487c05c 100644 --- a/config/config.test.jsonc +++ b/config/config.test.jsonc @@ -57,6 +57,9 @@ ], "cookiefile": "./data/cookies/ytdlcookies.txt" } + }, + "lavalink" : { + "nodes" : [] } }, diff --git a/config/migrations.sql b/config/migrations.sql index 9bc9f8a..7b9860c 100644 --- a/config/migrations.sql +++ b/config/migrations.sql @@ -1,36 +1,2 @@ -- This file is used to update the database structure to the latest version before using it. --- EDIT ONLY IF YOU KNOW WHAT YOU ARE DOING! - -PRAGMA foreign_keys = OFF; - -ALTER TABLE users RENAME TO old_users; -ALTER TABLE extensions RENAME TO old_extensions; - -CREATE TABLE users ( - guild_id INTEGER, - user_id INTEGER, - level INTEGER, - config JSON, - PRIMARY KEY (user_id, guild_id), - FOREIGN KEY (guild_id) REFERENCES guilds (guild_id) ON DELETE CASCADE -); - -CREATE TABLE extensions ( - guild_id INTEGER, - config JSON, - extension_id TEXT, - enabled BOOLEAN, - PRIMARY KEY (guild_id, extension_id), - FOREIGN KEY (guild_id) REFERENCES guilds (guild_id) ON DELETE CASCADE -); - -INSERT INTO users (guild_id, user_id, level, config) -SELECT guild_id, user_id, level, config FROM old_users; - -INSERT INTO extensions (guild_id, config, extension_id, enabled) -SELECT guild_id, config, extension_id, enabled FROM old_extensions; - -DROP TABLE old_users; -DROP TABLE old_extensions; - -PRAGMA foreign_keys = ON; \ No newline at end of file +-- EDIT ONLY IF YOU KNOW WHAT YOU ARE DOING! \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index fc7c34b..5d63972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ aiosignal==1.3.2 aiosqlite==0.21.0 annotated-types==0.7.0 anyio==4.9.0 +async-timeout==5.0.1 asyncio==3.4.3 attrs==25.3.0 blinker==1.9.0 @@ -18,6 +19,7 @@ click==8.1.8 cloudflare==4.1.0 colorama==0.4.6 comtypes==1.4.10 +discord.py==2.5.2 distro==1.9.0 Flask==3.1.1 frozenlist==1.6.0 @@ -54,6 +56,7 @@ spotipy==2.25.1 urllib3==2.4.0 typing_extensions==4.13.2 valo_api==2.1.0 +wavelink==3.4.1 websockets==15.0.1 Werkzeug==3.1.3 yarl==1.20.0 diff --git a/src/bot/commands/general/Greetings.py b/src/bot/commands/general/Greetings.py index 38d9516..423bbac 100644 --- a/src/bot/commands/general/Greetings.py +++ b/src/bot/commands/general/Greetings.py @@ -38,10 +38,10 @@ async def on_member_join(self, member : nextcord.Member): ) embed.set_thumbnail(url=(member.avatar.url if member.avatar else member.default_avatar.url)) + + if channel: await channel.send(embed=embed) except ExtensionException as e: pass - else: - await channel.send(embed=embed) @commands.Cog.listener() async def on_member_remove(self, member : nextcord.Member): @@ -61,10 +61,11 @@ async def on_member_remove(self, member : nextcord.Member): ) embed.set_thumbnail(url=(member.avatar.url if member.avatar else member.default_avatar.url)) + + if channel: + await channel.send(embed=embed) except ExtensionException as e: pass - else: - await channel.send(embed=embed) def setup(bot : commands.Bot): bot.add_cog(Greetings(bot)) \ No newline at end of file diff --git a/src/bot/commands/music/MusicApi.py b/src/bot/commands/music/.old/MusicApi.py similarity index 99% rename from src/bot/commands/music/MusicApi.py rename to src/bot/commands/music/.old/MusicApi.py index 0940880..3d41aee 100644 --- a/src/bot/commands/music/MusicApi.py +++ b/src/bot/commands/music/.old/MusicApi.py @@ -5,8 +5,6 @@ import asyncio import os - - class MusicApi: def __init__(self, loop : asyncio.AbstractEventLoop): self.yt = YoutubeExtension(loop=loop,params=config['music']['youtube']['ytdl_params']) diff --git a/src/bot/commands/music/.old/MusicCommands.py b/src/bot/commands/music/.old/MusicCommands.py new file mode 100644 index 0000000..1161a51 --- /dev/null +++ b/src/bot/commands/music/.old/MusicCommands.py @@ -0,0 +1,335 @@ +from nextcord.ext import commands +from nextcord import Permissions, Colour, SlashOption +from typing import Literal +import traceback +import asyncio +import stat +import sys +import os + +from utils.abc import Page +from utils.exceptions import GGsBotException +from utils.terminal import getlogger +from utils.config import config +from utils.commons import \ + Extensions, \ + GLOBAL_INTEGRATION, \ + GUILD_INTEGRATION, \ + USER_INTEGRATION + +from .MusicApi import MusicApi +from .MusicUtilities import * + +logger = getlogger() + +permissions = Permissions( + use_slash_commands=True, + connect=True, + speak=True, +) + +class MusicCommands(commands.Cog): + def __init__(self, bot : commands.Bot): + self.musicapi = MusicApi(bot.loop) + self.sessions : dict[int, Session] = {} + self.bot = bot + + @nextcord.slash_command("music","Listen music in discord voice channels", default_member_permissions=8, integration_types=GUILD_INTEGRATION) + async def music(self, interaction : nextcord.Interaction): pass + + @music.subcommand("join","Bring the bot on your current voice channel to play music (not the same as `/tts join`)") + async def join(self, interaction : nextcord.Interaction): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif interaction.guild.voice_client: + raise GGsBotException( + title="I am already in a voice channel!", + description=f'I am currently in a voice channel!', + suggestions="Call `/music leave` command first and try again." + ) + + await interaction.user.voice.channel.connect() + + self.sessions[interaction.guild.id] = Session(self.bot,interaction.guild,interaction.user) + + page = Page( + colour=Colour.green(), + title="Connected!", + description=f"I have successfully joined your voice channel!" + ) + + await interaction.followup.send(embed=page, ephemeral=True) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('play',"Play songs with the bot in your channel") + async def play(self, + interaction : nextcord.Interaction, + queryurl : str = SlashOption("queryurl", "Query or URL of the song or playlist you want to play", required=True), + searchengine : Literal['Spotify','Youtube'] = SlashOption("searchengine", "Search engine to use for the query", choices=['Spotify','Youtube'], default='Spotify') + ): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + session : Session = self.sessions[interaction.guild.id] + + if queryurl: + tracks = await self.musicapi.get(queryurl, searchengine) + + if tracks: + if isinstance(tracks, list): + session.queue.extend(tracks) + await interaction.send(f"{interaction.user.mention} added {len(tracks)} songs to the queue. Now playing {session.queue[0].title}",ephemeral=True) + elif isinstance(tracks,Song): + await interaction.send(f"{interaction.user.mention} playing {tracks.name}",ephemeral=True) + session.queue.append(tracks) + else: + pass # error getting song or songs (tracks = None) + + if interaction.guild.voice_client.is_playing() and not queryurl: + await interaction.send(f"{interaction.user.mention} The bot is already playing music",ephemeral=True) + else: + await session.play() + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('add',"Add a song to the end of the queue") + async def add(self, + interaction : nextcord.Interaction, + queryurl : str = SlashOption("queryurl", "Query or URL of the song or playlist you want to add", required=True), + searchengine : Literal['Spotify','Youtube'] = SlashOption("searchengine", "Search engine to use for the query", choices=['Spotify','Youtube'], default='Spotify') + ): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + tracks = await self.musicapi.get(queryurl, searchengine) + + session : Session = self.sessions[interaction.guild.id] + + if tracks: + if isinstance(tracks, list): + await interaction.send(f"{interaction.user.mention} added {len(tracks)} songs to the queue...",ephemeral=True,delete_after=5.0) + session.queue.extend(tracks) + elif isinstance(tracks,Song): + await interaction.send(f"{interaction.user.mention} {tracks.name} added to the queue...",ephemeral=True,delete_after=5.0) + session.queue.append(tracks) + else: + pass # error getting song or songs (tracks = None) + + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('skip',"Skip the current playing song") + async def skip(self, interaction : nextcord.Interaction): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + elif not interaction.guild.voice_client.is_playing(): + raise GGsBotException( + title="I am not playing anything at the moment!", + description=f'{interaction.user.mention} I am not playing anything at the moment!', + suggestions="Call `/music play` command first and try again." + ) + + session : Session = self.sessions[interaction.guild.id] + await session.skip() + + await interaction.send(f"{interaction.user.mention} I skipped '{session.currentsong.name}'!",ephemeral=True) + + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('stop',"Stop the current playing session") + async def stop(self, interaction : nextcord.Interaction): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + elif not interaction.guild.voice_client.is_playing(): + raise GGsBotException( + title="I am not playing anything at the moment!", + description=f'{interaction.user.mention} I am not playing anything at the moment!', + suggestions="Call `/music play` command first and try again." + ) + + interaction.guild.voice_client.stop() + + await interaction.send(f"{interaction.user.mention} I have stopped the music.",ephemeral=True,delete_after=5) + + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('pause',"Pause the current playing session") + async def pause(self, interaction : nextcord.Interaction): + try: + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + elif not interaction.guild.voice_client.is_playing(): + raise GGsBotException( + title="I am not playing anything at the moment!", + description=f'{interaction.user.mention} I am not playing anything at the moment!', + suggestions="Call `/music play` command first and try again." + ) + await interaction.response.defer(ephemeral=True) + + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('resume',"Resume the current playing session") + async def resume(self, interaction : nextcord.Interaction): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + elif not interaction.guild.voice_client.is_playing(): + raise GGsBotException( + title="I am not playing anything at the moment!", + description=f'{interaction.user.mention} I am not playing anything at the moment!', + suggestions="Call `/music play` command first and try again." + ) + + interaction.guild.voice_client.resume() + session : Session = self.sessions[interaction.guild.id] + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + else: + await interaction.send(f"Resume playing \'{session.currentsong.name}\'",ephemeral=True,delete_after=5.0) + + @music.subcommand('replay',"Replay the last song played in the history") + async def replay(self, interaction : nextcord.Interaction): + pass + + @music.subcommand('setvolume','Set volume for the current playing session') + async def setvolume(self, interaction : nextcord.Interaction, volume : float): + pass + + @music.subcommand('leave',"The bot will leave your vocal channel") + async def leave(self, interaction : nextcord.Interaction): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + interaction.guild.voice_client.stop() + await interaction.guild.voice_client.disconnect() + + session : Session = self.sessions[interaction.guild.id] + session.queue.clear() + session.history.clear() + if session.task: session.task.cancel() + + del session + + await interaction.send(f"{interaction.user.mention} I left the voice channel!",ephemeral=True,delete_after=5) + + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + +def setup(bot : commands.Bot): + if not os.path.exists(ffmpeg_path:=f"{config['paths']['bin'].format(os=OS,arch=ARCH)}/ffmpeg{'.exe' if OS == 'Windows' else ''}"): + raise FileNotFoundError(f"The extension cannot start, the ffmpeg executable at \'{ffmpeg_path}\' is missing") + if not os.path.isfile(ffmpeg_path): + raise FileNotFoundError(f"The extension cannot start, the ffmpeg executable at \'{ffmpeg_path}\' must be an executable") + + if not (permissions:=os.stat(ffmpeg_path).st_mode) & stat.S_IXUSR: + os.chmod(ffmpeg_path, permissions | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + bot.add_cog(MusicCommands(bot)) \ No newline at end of file diff --git a/src/bot/commands/music/MusicServices.py b/src/bot/commands/music/.old/MusicServices.py similarity index 97% rename from src/bot/commands/music/MusicServices.py rename to src/bot/commands/music/.old/MusicServices.py index 11dd7b1..93c373d 100644 --- a/src/bot/commands/music/MusicServices.py +++ b/src/bot/commands/music/.old/MusicServices.py @@ -3,9 +3,14 @@ import yt_dlp import spotipy import asyncio +import wavelink from utils.exceptions import * +class LavalinkExtension: + def __init__(self): + pass + class YoutubeExtension(yt_dlp.YoutubeDL): def __init__(self, *, loop : asyncio.AbstractEventLoop, params : dict): super().__init__(params) diff --git a/src/bot/commands/music/.old/MusicUtilities.py b/src/bot/commands/music/.old/MusicUtilities.py new file mode 100644 index 0000000..8479cf6 --- /dev/null +++ b/src/bot/commands/music/.old/MusicUtilities.py @@ -0,0 +1,302 @@ +from typing import Iterable +from nextcord.ext import commands +from nextcord import Colour +from dataclasses import dataclass, field, fields +from datetime import datetime, timezone, timedelta +from urllib.parse import urlparse +from collections import deque +from functools import partial +from enum import Enum +import traceback +import nextcord +import asyncio +import random +import time +import sys + +from utils.terminal import getlogger, stream as logger_stream +from utils.classes import BytesIOFFmpegPCMAudio +from utils.system import OS, ARCH +from utils.config import config +from utils.abc import Page + +from .MusicUI import FinishedPlayingPage, NothingToPlayPage, NowPlayingPage + +logger = getlogger() + +class UrlType(Enum): + YoutubeSong = "YoutubeSong" + YoutubePlaylist = "YoutubePlaylist" + + SpotifySong = "SpotifySong" + SpotifyPlaylist = "SpotifyPlaylist" + SpotifyAlbum = "SpotifyAlbum" + + Query = "Query" + Unknown = "Unknown" + +def urltype(url : str) -> UrlType: + """From a given url return the corresponding UrlType""" + parsed = urlparse(url) + + if parsed.netloc == 'www.youtube.com' and parsed.path == 'watch': + if 'list=' in parsed.params: + return UrlType.YoutubePlaylist + elif 'v=' in parsed.params: + return UrlType.YoutubeSong + else: + return UrlType.Unknown + elif parsed.netloc == 'open.spotify.com': + if 'track' in parsed.path: + return UrlType.SpotifySong + elif 'playlist' in parsed.path: + return UrlType.SpotifyPlaylist + elif 'album' in parsed.path: + return UrlType.SpotifyAlbum + else: + return UrlType.Unknown + + elif 'https' in parsed.scheme or 'http' in parsed.scheme: + return UrlType.Unknown + + return UrlType.Query + +def fromseconds(s : float): + """convert from a given time in seconds to an hours, minutes, seconds and milliseconds format""" + hours = int(s // 3600) + minutes = int((s % 3600) // 60) + seconds = int(s % 60) + milliseconds = int((s % 1) * 1000) + return (hours, minutes, seconds, milliseconds) + +def fromformat(time: tuple[int, int, int, int]): + """Convert from a given hours, minutes, seconds and milliseconds format to a seconds format.""" + return time[0] * 3600 + time[1] * 60 + time[2] + time[3] / 1000 + +song_content_sample = { + 'album': { + 'album_type': 'album', + 'artists': [ + {'external_urls': { 'spotify': 'https://open.spotify.com/artist/0QJIPDAEDILuo8AIq3pMuU' }, + 'href': 'https://api.spotify.com/v1/artists/0QJIPDAEDILuo8AIq3pMuU', + 'id': '0QJIPDAEDILuo8AIq3pMuU', + 'name': 'M.I.A.', + 'type': 'artist', + 'uri': 'spotify:artist:0QJIPDAEDILuo8AIq3pMuU'} + ], + 'available_markets': ['AR', 'AU', 'AT', 'BE', 'BO', 'BR', 'BG', 'CA', 'CL', 'CO', 'CR', 'CY', 'CZ', 'DK', 'DO', 'DE', 'EC', 'EE', 'SV', 'FI', 'FR', 'GR', 'GT', 'HN', 'HK', 'HU', 'IS', 'IE', 'IT', 'LV', 'LT', 'LU', 'MY', 'MT', 'MX', 'NL', 'NZ', 'NI', 'NO', 'PA', 'PY', 'PE', 'PH', 'PL', 'PT', 'SG', 'SK', 'ES', 'SE', 'CH', 'TW', 'TR', 'UY', 'US', 'GB', 'AD', 'LI', 'MC', 'ID', 'JP', 'TH', 'VN', 'RO', 'IL', 'ZA', 'PS', 'IN', 'BY', 'KZ', 'MD', 'UA', 'AL', 'BA', 'HR', 'ME', 'MK', 'RS', 'SI', 'KR', 'PK', 'LK', 'GH', 'KE', 'NG', 'TZ', 'UG', 'AG', 'AM', 'BS', 'BB', 'BZ', 'BT', 'BW', 'BF', 'CV', 'CW', 'DM', 'FJ', 'GM', 'GD', 'GW', 'GY', 'JM', 'KI', 'LS', 'LR', 'MW', 'MV', 'ML', 'MH', 'FM', 'NA', 'NR', 'NE', 'PW', 'PG', 'WS', 'ST', 'SN', 'SC', 'SL', 'SB', 'KN', 'LC', 'VC', 'SR', 'TL', 'TO', 'TT', 'TV', 'AZ', 'BN', 'BI', 'KH', 'CM', 'TD', 'KM', 'GQ', 'SZ', 'GA', 'GN', 'KG', 'LA', 'MO', 'MR', 'MN', 'NP', 'RW', 'TG', 'UZ', 'ZW', 'BJ', 'MG', 'MU', 'MZ', 'AO', 'CI', 'DJ', 'ZM', 'CD', 'CG', 'TJ', 'VE', 'XK'], 'external_urls': {'spotify': 'https://open.spotify.com/album/3dAxXNscIj0p53lBMEziYR'}, 'href': 'https://api.spotify.com/v1/albums/3dAxXNscIj0p53lBMEziYR', 'id': '3dAxXNscIj0p53lBMEziYR', 'images': [{'url': 'https://i.scdn.co/image/ab67616d0000b2733d6fa293f49903ed38fbe0de', 'width': 640, 'height': 640}, {'url': 'https://i.scdn.co/image/ab67616d00001e023d6fa293f49903ed38fbe0de', 'width': 300, 'height': 300}, {'url': 'https://i.scdn.co/image/ab67616d000048513d6fa293f49903ed38fbe0de', 'width': 64, 'height': 64}], 'name': 'Matangi', 'release_date': '2013-01-01', 'release_date_precision': 'day', 'total_tracks': 15, 'type': 'album', 'uri': 'spotify:album:3dAxXNscIj0p53lBMEziYR'}, 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/0QJIPDAEDILuo8AIq3pMuU'}, 'href': 'https://api.spotify.com/v1/artists/0QJIPDAEDILuo8AIq3pMuU', 'id': '0QJIPDAEDILuo8AIq3pMuU', 'name': 'M.I.A.', 'type': 'artist', 'uri': 'spotify:artist:0QJIPDAEDILuo8AIq3pMuU'}], 'available_markets': ['AR', 'AU', 'AT', 'BE', 'BO', 'BR', 'BG', 'CA', 'CL', 'CO', 'CR', 'CY', 'CZ', 'DK', 'DO', 'DE', 'EC', 'EE', 'SV', 'FI', 'FR', 'GR', 'GT', 'HN', 'HK', 'HU', 'IS', 'IE', 'IT', 'LV', 'LT', 'LU', 'MY', 'MT', 'MX', 'NL', 'NZ', 'NI', 'NO', 'PA', 'PY', 'PE', 'PH', 'PL', 'PT', 'SG', 'SK', 'ES', 'SE', 'CH', 'TW', 'TR', 'UY', 'US', 'GB', 'AD', 'LI', 'MC', 'ID', 'JP', 'TH', 'VN', 'RO', 'IL', 'ZA', 'PS', 'IN', 'BY', 'KZ', 'MD', 'UA', 'AL', 'BA', 'HR', 'ME', 'MK', 'RS', 'SI', 'KR', 'PK', 'LK', 'GH', 'KE', 'NG', 'TZ', 'UG', 'AG', 'AM', 'BS', 'BB', 'BZ', 'BT', 'BW', 'BF', 'CV', 'CW', 'DM', 'FJ', 'GM', 'GD', 'GW', 'GY', 'JM', 'KI', 'LS', 'LR', 'MW', 'MV', 'ML', 'MH', 'FM', 'NA', 'NR', 'NE', 'PW', 'PG', 'WS', 'ST', 'SN', 'SC', 'SL', 'SB', 'KN', 'LC', 'VC', 'SR', 'TL', 'TO', 'TT', 'TV', 'AZ', 'BN', 'BI', 'KH', 'CM', 'TD', 'KM', 'GQ', 'SZ', 'GA', 'GN', 'KG', 'LA', 'MO', 'MR', 'MN', 'NP', 'RW', 'TG', 'UZ', 'ZW', 'BJ', 'MG', 'MU', 'MZ', 'AO', 'CI', 'DJ', 'ZM', 'CD', 'CG', 'TJ', 'VE', 'XK'], + 'disc_number': 1, + 'duration_ms': 227520, + 'explicit': False, + 'external_ids': {'isrc': 'USUG11200143'}, + 'external_urls': {'spotify': 'https://open.spotify.com/track/6nzXkCBOhb2mxctNihOqbb'}, + 'href': 'https://api.spotify.com/v1/tracks/6nzXkCBOhb2mxctNihOqbb', + 'id': '6nzXkCBOhb2mxctNihOqbb', + 'is_local': False, + 'name': 'Bad Girls', + 'popularity': 74, + 'preview_url': None, + 'track_number': 8, + 'type': 'track', + 'uri': 'spotify:track:6nzXkCBOhb2mxctNihOqbb' +} + +@dataclass +class Song: + """ + Class representing a Song object + + :param: data (dict): + Song information from Spotify or a Third-Party source + + :param: url (str): + Song file url from a Third-Party source (not Spotify) + """ + + raw: dict = field(default_factory=dict,repr=False) + """Raw data obtained from Spotify or a Third-Party source""" + url : str = field(default_factory=str,init=False, repr=False) + """The song file url obatined from a Third-Party source (not Spotify)""" + uri : str = field(default_factory=str,init=False, repr=False) + """The Spotify song URI""" + duration : float = field(default_factory=float,init=False, repr=False) + """The song duration in seconds""" + duration_str : str = field(default_factory=str,init=False, repr=True) + """The song duration in seconds as a string""" + name : str = field(default_factory=str,init=False, repr=True) + """The song name""" + id : str = field(default_factory=str, init=False, repr=True) + """The Spotify song ID""" + album_type : str = field(default_factory=str,init=False,repr=False) + """The song album type (e.g., album, single)""" + album_name : str = field(default_factory=str,init=False,repr=True) + """The song album name""" + album_url : str = field(default_factory=str,init=False,repr=False) + """The song album URL""" + album_img64px : str = field(default_factory=str, init=False, repr=False) + """The song album image URL (64px)""" + album_img300px : str = field(default_factory=str, init=False, repr=False) + """The song album image URL (300x)""" + album_release : str = field(default_factory=str,init=False,repr=True) + """The song album release date""" + artists : list[dict] = field(default_factory=list,init=False,repr=False) + """A list of dictionary containing artist information""" + explicit : bool = field(default_factory=bool,init=False) + """Whether the song is explicit""" + popularity : int = field(default_factory=int,init=False, repr=True) + """The song's popularity on Spotify expressed as a number between 0 and 100""" + preview_url : str = field(default_factory=str,init=False) + """The song's preview URL if available""" + + def __post_init__(self): + for field_info in fields(self): + if field_info.init: continue # Skip 'raw' and other fields that should not be initialized from raw + setattr(self, field_info.name, self.raw.get(field_info.name)) + + album = self.raw.get('album', {}) + images = album.get('images', []) + + self.album_name = album.get('name', None) + self.album_type = album.get('album_type', None) + self.album_url = album.get('external_urls', {}).get('spotify', None) + self.album_img300px = str(images[0]['url']) if len(images) > 0 else None + self.album_img64px = str(images[1]['url']) if len(images) > 0 else None + self.album_release = album.get('release_date', None) + + self.duration = float(self.raw.get('duration_ms', 0)) + self.duration_str = datetime.fromtimestamp(self.duration / 1000).strftime('%M:%S') + +class Playlist: + pass + +class History(deque): + """Subclass of deque for playing history""" + def __init__(self, *, songs : Iterable = [], maxlen : int = None): + deque.__init__(self,songs,maxlen) + +class Queue(deque): + """Subclass of deque for a music queue with shuffle""" + def __init__(self, *, songs : Iterable = [], maxlen : int = None): + deque.__init__(self,songs,maxlen) + + def __add__(self, other): + if isinstance(other, deque): + self.extend(other) + return self + return NotImplemented + + def shuffle(self): + random.shuffle(self) + + def move(self, origin : int, dest : int): + self.insert(dest,self.__getitem__(origin)) + del self[origin] + +class Session: + """Guild music playing session""" + def __init__(self, bot : commands.Bot, guild : nextcord.Guild, owner : nextcord.User): + self.volume : float = float(config['music'].get('defaultvolume',100.0)) + self.history : History[Song] = History() + self.queue : Queue[Song] = Queue() + self.currentsong : Song + self.totaltime = 0.0 + self.guild = guild + self.channel : nextcord.VoiceChannel = guild.voice_client.channel + self.owner = owner + self.bot = bot + self.loop = False + self.cycle = False + self.task : asyncio.Task | None = None + + async def _next(self, error : Exception, lastsong : Song, stime : float, attempts : int = 1): + """Invoked after a song is finished. Plays the next song if there is one.""" + logger.debug(f"Song {lastsong} finished. attempts: {attempts}, start time: {stime}") + + if error: logger.error(traceback.format_exc()) + + # Se il tempo di riproduzione e' minore della durata della canzone e i tentativi di riproduzione non sono finiti + if (((ptime:=time.time() - stime + 3) < lastsong.duration) or error) and attempts < config['music']['attempts']: + # Riproduci la canzone da dove si era interrotta + logger.error(f"""The song \'{lastsong.name}\' was not played until the end""") + logger.error(f"Playback time: {ptime}, Song Duration: {lastsong.duration}") + logger.error(f"Playback attempts made: {attempts}/{config['music']['attempts']}") + ftime = fromseconds(ptime) + else: + # Altrimenti toglila dalla queue + ftime = (0,0,0,0) + self.totaltime+=ptime + self.history.append(lastsong) + if len(self.queue) > 0: self.queue.popleft() + + page = FinishedPlayingPage( + lastsong.name, + lastsong.popularity, + lastsong.duration_str, + lastsong.album_name, + lastsong.album_url, + lastsong.album_img64px, + lastsong.artists + ) + + #await self.channel.send(embed=page)" + + coro = self.play(lastsong if self.loop else None,st=ftime, attempts=attempts+1) + self.task = asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + + async def play(self, song : Song = None, *, st : tuple = (0,0,0,0), attempts : int = 1): + self.guild.voice_client.stop() # Assicuriamo che non ci sia altro in riproduzione + + if not song and len(self.queue) > 0: + song = self.queue[0] # Se non e' stata specificata una canzone prende la successiva nella coda + elif not song and len(self.queue) == 0: + page = NothingToPlayPage() + await self.channel.send(embed=page) + return # Se non e' stata specificata una canzone e la coda e vuota allora non c'e' nulla da riprodurre + + source = BytesIOFFmpegPCMAudio( + source=song.url, + stderr=sys.stderr, + executable=f"{config['paths']['bin'].format(os=OS,arch=ARCH)}ffmpeg{'.exe' if OS == 'windows' else ''}", + before_options=f'-ss {st[0]}:{st[1]}:{st[2]}.{st[3]}', + ) + + stime = time.time() if sum(st) == 0 else fromformat(st) + + source = nextcord.PCMVolumeTransformer(source, self.volume / 100.0) + + self.guild.voice_client.play( + source, + after=lambda e: asyncio.run_coroutine_threadsafe( + self._next(e, lastsong=song, stime=stime, attempts=attempts), + self.bot.loop + ) + ) + + page = NowPlayingPage( + song.name, + song.popularity, + song.duration_str, + song.album_name, + song.album_url, + song.album_img64px, + song.artists + ) + + await self.channel.send(embed=page, view=page) + + self.currentsong = song + + async def skip(self): + self.guild.voice_client.stop() + if len(self.queue) > 0: self.queue.popleft() + #await self.play() + + async def replay(self): + self.guild.voice_client.stop() + + if self.guild.voice_client.is_playing(): + coro = self.play() + else: + coro = self.play(self.history[-1]) + + self.task = asyncio.create_task(coro) diff --git a/src/bot/commands/music/Music.py b/src/bot/commands/music/Music.py new file mode 100644 index 0000000..93cc20e --- /dev/null +++ b/src/bot/commands/music/Music.py @@ -0,0 +1,597 @@ +from nextcord.ext import commands +from nextcord.ext.tasks import loop +from nextcord import \ + Message, \ + Permissions, \ + Colour, \ + Interaction, \ + SlashOption, \ + VoiceClient, \ + Client, \ + VoiceChannel, \ + VoiceProtocol, \ + VoiceState, \ + Member, \ + slash_command +from typing import Literal, cast +from wavelink import \ + AutoPlayMode, \ + InvalidNodeException, \ + Playable, \ + Player, \ + Node, \ + NodeStatus, \ + Playlist, \ + Pool, \ + QueueEmpty, \ + Search, \ + NodeReadyEventPayload, \ + PlayerUpdateEventPayload, \ + StatsEventPayload, \ + TrackStuckEventPayload, \ + TrackExceptionEventPayload, \ + TrackStartEventPayload, \ + TrackEndEventPayload, \ + TrackSource +from datetime import datetime, timezone, timedelta +import wavelink +import traceback +import asyncio +import stat +import sys +import os + +from utils.abc import Page +from utils.exceptions import GGsBotException +from utils.terminal import getlogger +from utils.system import OS, ARCH +from utils.config import config +from utils.commons import \ + Extensions, \ + GLOBAL_INTEGRATION, \ + GUILD_INTEGRATION, \ + USER_INTEGRATION + +from .MusicUI import \ + NowPlayingPage, \ + NothingToPlayPage, \ + AddedToQueue, \ + UserResumed, \ + UserShuffled, \ + UserSkipped, \ + UserPaused, \ + NoTracksFound, \ + MiniPlayer + +logger = getlogger() + +permissions = Permissions( + use_slash_commands=True, + connect=True, + speak=True, +) + +class NextcordWavelinkPlayer(Player, VoiceProtocol): + def __init__(self, client : Client, channel : VoiceChannel, nodes : list | None = None): + Player.__init__(self, client=client, channel=channel, nodes=nodes) + VoiceProtocol.__init__(self, client=client, channel=channel) + self.ui : MiniPlayer | None = None + + def __repr__(self) -> str: + return f"NextcordWavelinkPlayer(channel={self.channel})" + + async def disconnect(self, **kwargs): + await Player.disconnect(self, **kwargs) + self.cleanup() + + def cleanup(self): + VoiceProtocol.cleanup(self) + + +class Music(commands.Cog): + def __init__(self, bot : commands.Bot): + self.sessions : set[int] = set() + self.bot = bot + + # Events + + @commands.Cog.listener() + async def on_ready(self) -> None: + await self.bot.wait_until_ready() + + try: + logger.debug("Connecting to Lavalink nodes") + nodes_dict : list[dict] = config['music']['lavalink'].get('nodes', []) + nodes :list[Node] = [] + for node in nodes_dict: + uri = uri if (uri:=node.get('uri', None)) is not None else node.get('host', None) + port = node.get('port', None) + id = node.get('identifier', None) + password = node.get('password', None) + secure = node.get('secure', False) + + node = Node( + uri=f"{'https' if secure else 'http'}://{uri}:{port}" if port else uri, + identifier=id, + password=password, + retries=3 + ) + nodes.append(node) + #break + + await Pool.connect(nodes=nodes, client=self.bot, cache_capacity=100) + except Exception as e: + logger.error(traceback.format_exc()) + + @commands.Cog.listener() + async def on_wavelink_node_ready(self, payload: NodeReadyEventPayload) -> None: + logger.debug(f"Wavelink Node connected: {payload.node} | Resumed: {payload.resumed}") + + @commands.Cog.listener() + async def on_wavelink_node_disconnected(self, payload) -> None: + logger.debug(f"Node disconnected {payload}") + + @commands.Cog.listener() + async def on_wavelink_track_start(self, payload: TrackStartEventPayload): + logger.debug(f"Track {payload.track} ({payload.original}) started in player {payload.player}") + if not payload.player: return + if not hasattr(payload.player, 'ui'): return + + await payload.player.channel.edit(status=f'Listening music with GGsBot') + + ui : MiniPlayer = payload.player.ui + await ui.update_track() + + @commands.Cog.listener() + async def on_wavelink_track_end(self, payload: TrackEndEventPayload): + logger.debug(f"Track {payload.track} ({payload.original}) ended in player {payload.player} with reason {payload.reason}") + player : NextcordWavelinkPlayer = cast(NextcordWavelinkPlayer, payload.player) + + if not player or not player.ui: return + + try: + next_song = player.queue.get() + except QueueEmpty as e: + await player.ui.update_track(finished=True) + return + else: + await player.play(next_song, volume=30) + + await player.ui.update_track() + + @commands.Cog.listener() + async def on_wavelink_inactive_player(self, player : NextcordWavelinkPlayer) -> None: + logger.debug(f"player {player} is inactive") + + try: + await player.stop() + except Exception as e: + logger.error(traceback.format_exc()) + + try: + await player.disconnect() + except Exception as e: + logger.error(traceback.format_exc()) + + @commands.Cog.listener() + async def on_wavelink_stats_update(self, payload: StatsEventPayload) -> None: + pass + #logger.debug(f"{payload.cpu.cores} cores, {payload.cpu.lavalink_load}% lavalink load, {payload.cpu.system_load}% system load") + + @commands.Cog.listener() + async def on_wavelink_player_update(self, payload: PlayerUpdateEventPayload) -> None: + #logger.debug(f"Player {payload.player} updated (ping: {payload.ping}, time: {payload.time}, position: {payload.position}, connected: {payload.connected})") + player : NextcordWavelinkPlayer = cast(NextcordWavelinkPlayer, payload.player) + if not player: return + + if not player.playing: return + + if not player.ui: + logger.debug(f'Player {payload.player} has no attribute ui, maybe it was not initialized yet?') + return + + now = datetime.now(timezone.utc) + + if player.ui.last_update > now - timedelta(seconds=10): + #logger.debug(f'Cannot update track because it was updated within the last 10 seconds.') + return + + await player.ui.update_track() + + @commands.Cog.listener() + async def on_wavelink_track_exception(self, payload: TrackExceptionEventPayload) -> None: + logger.debug(f"Track {payload.track} failed to play in player {payload.player} with error:\n{payload.exception}") + + @commands.Cog.listener() + async def on_wavelink_track_stuck(self, payload: TrackStuckEventPayload) -> None: + logger.debug(f"Track {payload.track} is stuck for {payload.threshold}ms in player {payload.player}") + + @commands.Cog.listener() + async def on_voice_state_update(self, member : Member, before : VoiceState, after : VoiceState): + try: + if not before.channel or before.channel.id not in self.sessions: return + + player = cast(NextcordWavelinkPlayer, before.channel.guild.voice_client) + if not player: + self.sessions.discard(before.channel.id) + return + + if after.channel and before.channel: + if before.channel.id == after.channel.id: return + + player.disconnect(force=True) + await player.ui.message.delete() + + self.sessions.discard(before.channel.id) + except Exception as e: + logger.error(traceback.format_exc()) + + # Playback Commands + + @slash_command("music","Listen music in discord voice channels", default_member_permissions=8, integration_types=GUILD_INTEGRATION) + async def music(self, interaction : Interaction): pass + + @music.subcommand("join","Bring the bot on your current voice channel to play music (not the same as `/tts join`)") + async def join(self, + interaction : Interaction, + autoplay = SlashOption( + description="Set autoplay mode (default: enabled)", + default=str(AutoPlayMode.enabled.value), + choices={'enabled' : str(AutoPlayMode.enabled.value), 'disabled' : str(AutoPlayMode.disabled.value), 'partial' : str(AutoPlayMode.partial.value)}, + ) + ): + try: + await interaction.response.defer(ephemeral=True,with_message=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif interaction.guild.voice_client: + raise GGsBotException( + title="I am already in a voice channel!", + description=f'I am currently in a voice channel!', + suggestions="Call `/music leave` command first and try again." + ) + + player : NextcordWavelinkPlayer = await interaction.user.voice.channel.connect(cls=NextcordWavelinkPlayer) + player.autoplay = AutoPlayMode(value=int(autoplay)) + + await interaction.delete_original_message() + + message = await player.channel.send(content="Mini player is loading...") + + self.sessions.add(interaction.channel.id) + ui = MiniPlayer(player, message) + player.ui = ui + + await message.edit(content=None, embed=ui, view=ui) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except InvalidNodeException as e: + error = GGsBotException( + title="No nodes available", + description=str(e), + suggestions="Try again later or contact a moderator." + ) + + await interaction.followup.send(embed=error.asEmbed(), ephemeral=True) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('play',"Play songs with the bot in your channel") + async def play(self, + interaction : Interaction, + queryurl : str = SlashOption("queryurl", "Query or URL of the song or playlist you want to play", required=True), + searchengine = SlashOption( + description="Search engine to use for the query (default: Youtube)", + choices={"Youtube" : str(TrackSource.YouTube.value),"Youtube Music" : str(TrackSource.YouTubeMusic.value),"Soundcloud" : str(TrackSource.SoundCloud.value), "Spotify" : "spsearch"}, + default=str(TrackSource.YouTube.value) + ), + shuffle : bool = SlashOption( + description="Shuffle the queue after adding the song/s (default: false)", + default=False + ), + ): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + + tracks: Search = await Playable.search(queryurl, source=searchengine) + + if not tracks: + page = NoTracksFound(queryurl) + await interaction.followup.send(embed=page, ephemeral=True) + return + + tracks.extras = { + 'requestor_id' : interaction.user.id + } + + await player.queue.put_wait(tracks) + if shuffle: player.queue.shuffle() + + if isinstance(tracks, Playlist): + page = AddedToQueue(tracks, interaction.user) + else: + page = AddedToQueue(tracks[0], interaction.user) + + await interaction.followup.send(embed=page, ephemeral=True, delete_after=5) + + if not player.playing: + if player.paused: await player.pause(False) + await player.play(player.queue.get(), volume=30) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('add',"Add songs to the end of the queue") + async def add(self, + interaction : Interaction, + queryurl : str = SlashOption("queryurl", "Query or URL of the song or playlist you want to add", required=True), + searchengine = SlashOption( + description="Search engine to use for the query (default: Youtube)", + choices={"Youtube" : str(TrackSource.YouTube.value),"Youtube Music" : str(TrackSource.YouTubeMusic.value),"Soundcloud" : str(TrackSource.SoundCloud.value), "Spotify" : "spsearch:"}, + default=str(TrackSource.YouTube.value) + ), + shuffle : bool = SlashOption( + description="Shuffle the queue after adding the song/s (default: false)", + default=False + ), + ): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + tracks : Search = await Playable.search(queryurl, source=searchengine) + + tracks.extras = { + 'requestor_id' : interaction.user.id + } + + await player.queue.put_wait(tracks) + if shuffle: player.queue.shuffle() + + if isinstance(tracks, Playlist): + page = AddedToQueue(tracks, interaction.user) + else: + page = AddedToQueue(tracks[0], interaction.user) + + await interaction.followup.send(embed=page, ephemeral=True, delete_after=5) + + if not player.playing: + if player.paused: await player.pause(False) + next_song = player.queue.get() + await player.play(next_song, volume=30) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('skip',"Skip the current playing song") + async def skip(self, + interaction : Interaction, + playlist : bool = SlashOption( + description="Whether to skip the current playing playlist if any (default: false)", + default=False + ), + ): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + + current = player.current + + if player.paused: + raise GGsBotException( + title="I am not playing anything at the moment!", + description=f'{interaction.user.mention} I am not playing anything at the moment!', + suggestions="Call `/music play` or `/music resume` command first and try again." + ) + + if playlist and current and current.playlist: + for track in set(filter(lambda track: track.playlist == current.playlist, player.queue)): + player.queue.remove(track, None) + + page = UserSkipped(interaction.user, current.playlist if playlist and current.playlist else current) + await interaction.followup.send(embed=page, ephemeral=True, delete_after=5) + + await player.skip() + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('shuffle', "Shuffle the queue") + async def shuffle(self, interaction : Interaction): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + player : Player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + player.queue.shuffle() + + await interaction.followup.send(embed=UserShuffled(interaction.user), ephemeral=True, delete_after=5) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('pause',"Pause the current playing song") + async def pause(self, interaction : Interaction): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + + if not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + elif not interaction.guild.voice_client.is_playing(): + raise GGsBotException( + title="I am not playing anything at the moment!", + description=f'{interaction.user.mention} I am not playing anything at the moment!', + suggestions="Call `/music play` or `/music resume` command first and try again." + ) + + player : Player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + + await player.pause(True) + + await interaction.followup.send(embed=UserPaused(interaction.user), ephemeral=True, delete_after=5) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('resume',"Resume the current playing song") + async def resume(self, interaction : Interaction): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + player : Player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + + if not player.paused: + raise GGsBotException( + title="I am already playing something at the moment!", + description=f'{interaction.user.mention} I am already playing something at the moment!', + suggestions="Call `/music pause` command first and try again." + ) + + player : Player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + await player.pause(False) + + await interaction.followup.send(embed=UserResumed(interaction.user), ephemeral=True, delete_after=5) + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + @music.subcommand('leave', "The bot will leave your vocal channel") + async def leave(self, interaction : Interaction): + try: + await interaction.response.defer(ephemeral=True) + + if not interaction.user.voice: + raise GGsBotException( + title="You are not in a voice channel!", + description=f'{interaction.user.mention} You have to join a voice channel first!', + suggestions="Join a voice channel and try again." + ) + elif not interaction.guild.voice_client: + raise GGsBotException( + title="I am not in a voice channel!", + description=f'{interaction.user.mention} I am not in a voice channel!', + suggestions="Call `/music join` command first and try again." + ) + + player : Player = cast(NextcordWavelinkPlayer, interaction.guild.voice_client) + self.sessions.discard(interaction.channel.id) + await player.disconnect() + await player.ui.message.delete() + + await interaction.delete_original_message() + except GGsBotException as e: + await interaction.followup.send(embed=e.asEmbed()) + except Exception as e: + logger.error(traceback.format_exc()) + + """ + # Music Set commands + + @music.subcommand("set", "Set the bot's music settings") + async def set(self, interaction : Interaction): pass + + # Music effects commands + + @music.subcommand("effects", "Apply effects to the audio") + async def effects(self, interaction : Interaction): pass + """ + +def setup(bot : commands.Bot): + if not os.path.exists(ffmpeg_path:=f"{config['paths']['bin'].format(os=OS,arch=ARCH)}/ffmpeg{'.exe' if OS == 'Windows' else ''}"): + raise FileNotFoundError(f"The extension cannot start, the ffmpeg executable at \'{ffmpeg_path}\' is missing") + if not os.path.isfile(ffmpeg_path): + raise FileNotFoundError(f"The extension cannot start, the ffmpeg executable at \'{ffmpeg_path}\' must be an executable") + + if not (permissions:=os.stat(ffmpeg_path).st_mode) & stat.S_IXUSR: + os.chmod(ffmpeg_path, permissions | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + bot.add_cog(Music(bot)) \ No newline at end of file diff --git a/src/bot/commands/music/MusicCommands.py b/src/bot/commands/music/MusicCommands.py deleted file mode 100644 index 0d0d3a3..0000000 --- a/src/bot/commands/music/MusicCommands.py +++ /dev/null @@ -1,215 +0,0 @@ -from nextcord.ext import commands -from nextcord import Permissions -from typing import Literal -import nextcord -import asyncio -import stat -import sys -import os - -from utils.terminal import getlogger -from utils.config import config -from utils.commons import \ - Extensions, \ - GLOBAL_INTEGRATION, \ - GUILD_INTEGRATION, \ - USER_INTEGRATION - -from .MusicApi import MusicApi -from .MusicUtilities import * - -logger = getlogger() - -permissions = Permissions( - use_slash_commands=True, - connect=True, - speak=True, -) - -class MusicCommands(commands.Cog): - def __init__(self, bot : commands.Bot): - self.musicapi = MusicApi(bot.loop) - self.sessions = {} - self.bot = bot - #2147483648 - - @nextcord.slash_command("music","Listen music in discord voice channels", default_member_permissions=8, integration_types=GUILD_INTEGRATION) - async def music(self, interaction : nextcord.Interaction): pass - - @music.subcommand("join","Bring the bot on your current voice channel to play music (not the same as `/tts join`)") - async def join(self, interaction : nextcord.Interaction): - try: - await interaction.response.defer(ephemeral=True,with_message=True) - assert interaction.user.voice, f'{interaction.user.mention} You have to join a voice channel first!' - assert not interaction.guild.voice_client, f'{interaction.user.mention} I am currently in a voice channel!' - - await interaction.user.voice.channel.connect() - self.sessions[interaction.guild.id] = Session(self.bot,interaction.guild,interaction.user) - await interaction.send(f"{interaction.user.mention} I have joined your voice channel!", ephemeral=True, delete_after=5.0) - except AssertionError as e: - await interaction.send(str(e),ephemeral=True,delete_after=5.0) - except Exception as e: - logger.error(e) - - @music.subcommand('play',"Play songs with the bot in your channel") - async def play(self, interaction : nextcord.Interaction, queryurl : str = None, searchengine : Literal['Spotify','Youtube'] = 'Spotify'): - try: - await interaction.response.defer(ephemeral=True,with_message=True) - assert interaction.user.voice.channel, f'{interaction.user.mention} You have to join a voice channel first!' - assert interaction.guild.voice_client, f'{interaction.user.mention} You have to call */join* command first!' - - session : Session = self.sessions[interaction.guild.id] - - if queryurl: - tracks = await self.musicapi.get(queryurl, searchengine) - - if tracks: - if isinstance(tracks, list): - session.queue.extend(tracks) - await interaction.send(f"{interaction.user.mention} added {len(tracks)} songs to the queue. Now playing {session.queue[0].title}",ephemeral=True,delete_after=5.0) - elif isinstance(tracks,Song): - await interaction.send(f"{interaction.user.mention} playing {tracks.name}",ephemeral=True,delete_after=5.0) - session.queue.append(tracks) - else: - pass # error getting song or songs (tracks = None) - - if interaction.guild.voice_client.is_playing() and not queryurl: - await interaction.send(f"{interaction.user.mention} The bot is already playing music",ephemeral=True,delete_after=5.0) - else: - await session.play() - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - - @music.subcommand('add',"Add a song to the end of the queue") - async def add(self, interaction : nextcord.Interaction, queryurl : str): - try: - await interaction.response.defer(ephemeral=True,with_message=True) - assert interaction.user.voice.channel, f'{interaction.user.mention} You have to join a voice channel first!' - assert interaction.guild.voice_client, f'{interaction.user.mention} You have to call */join* command first!' - - tracks = await self.yt.get_info(queryurl) - - session : Session = self.sessions[interaction.guild.id] - - if tracks: - if isinstance(tracks, list): - await interaction.send(f"{interaction.user.mention} added {len(tracks)} songs to the queue...",ephemeral=True,delete_after=5.0) - session.queue.extend(tracks) - elif isinstance(tracks,Song): - await interaction.send(f"{interaction.user.mention} {tracks.title} added to the queue...",ephemeral=True,delete_after=5.0) - session.queue.append(tracks) - else: - pass # error getting song or songs (tracks = None) - - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - except nextcord.errors.ClientException as e: - logger.fatal(e) - - @music.subcommand('skip',"Skip the current playing song") - async def skip(self, interaction : nextcord.Interaction): - try: - await interaction.response.defer(ephemeral=True,with_message=True) - assert interaction.user.voice.channel, f'{interaction.user.mention} You have to join a voice channel first!' - assert interaction.guild.voice_client, f'{interaction.user.mention} I am not in a vocal channel!' - assert interaction.guild.voice_client.is_playing(), f'{interaction.user.mention} I am not playing anything at the moment!' - - session : Session = self.sessions[interaction.guild.id] - await session.skip() - - await interaction.send(f"{interaction.user.mention} I skipped '{session.currentsong.name}'!",ephemeral=True,delete_after=5) - - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - except nextcord.errors.ClientException as e: - logger.fatal(e) - - @music.subcommand('stop',"Stop the current playing session") - async def stop(self, interaction : nextcord.Interaction): - try: - await interaction.response.defer(ephemeral=True,with_message=True) - assert interaction.guild.voice_client, f'{interaction.user.mention} I am not in a vocal channel!' - assert interaction.guild.voice_client.is_playing(), f'{interaction.user.mention} I am not playing anything at the moment!' - - interaction.guild.voice_client.stop() - - await interaction.send(f"{interaction.user.mention} I !",ephemeral=True,delete_after=5) - - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - except nextcord.errors.ClientException as e: - logger.fatal(e) - - @music.subcommand('pause',"Pause the current playing session") - async def pause(self, interaction : nextcord.Interaction): - try: - assert interaction.guild.voice_client, f'{interaction.user.mention} I am not in a vocal channel!' - assert interaction.guild.voice_client.is_playing(), f'{interaction.user.mention} I am not playing anything at the moment!' - - interaction.guild.voice_client.pause() - session : Session = self.sessions[interaction.guild.id] - - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - except Exception as e: - logger.error(e) - else: - await interaction.send(f"Paused song \'{session.currentsong.name}\'",ephemeral=True,delete_after=5.0) - - @music.subcommand('resume',"Resume the current playing session") - async def resume(self, interaction : nextcord.Interaction): - try: - assert interaction.guild.voice_client, f'{interaction.user.mention} I am not in a vocal channel!' - assert interaction.guild.voice_client.is_paused(), f'{interaction.user.mention} I do not have a paused song at the moment!' - - interaction.guild.voice_client.resume() - session : Session = self.sessions[interaction.guild.id] - - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - except Exception as e: - logger.error(e) - else: - await interaction.send(f"Resume playing \'{session.currentsong.name}\'",ephemeral=True,delete_after=5.0) - - @music.subcommand('replay',"Replay the last song played in the history") - async def replay(self, interaction : nextcord.Interaction): - pass - - @music.subcommand('setvolume','Set volume for the current playing session') - async def setvolume(self, interaction : nextcord.Interaction, volume : float): - pass - - @music.subcommand('leave',"The bot will leave your vocal channel") - async def leave(self, interaction : nextcord.Interaction): - try: - await interaction.response.defer(ephemeral=True,with_message=True) - assert interaction.guild.voice_client, f'{interaction.user.mention} I am not in a vocal channel!' - - interaction.guild.voice_client.stop() - await interaction.guild.voice_client.disconnect() - - session : Session = self.sessions[interaction.guild.id] - session.queue.clear() - session.history.clear() - if session.task: session.task.cancel() - - del session - - await interaction.send(f"{interaction.user.mention} I left the voice channel!",ephemeral=True,delete_after=5) - - except AssertionError as e: - await interaction.send(e,ephemeral=True,delete_after=5.0) - except nextcord.errors.ClientException as e: - logger.fatal(e) - -def setup(bot : commands.Bot): - if not os.path.exists(ffmpeg_path:=f"{config['paths']['bin'].format(os=OS,arch=ARCH)}/ffmpeg{'.exe' if OS == 'Windows' else ''}"): - raise FileNotFoundError(f"The extension cannot start, the ffmpeg executable at \'{ffmpeg_path}\' is missing") - if not os.path.isfile(ffmpeg_path): - raise FileNotFoundError(f"The extension cannot start, the ffmpeg executable at \'{ffmpeg_path}\' must be an executable") - - if not (permissions:=os.stat(ffmpeg_path).st_mode) & stat.S_IXUSR: - os.chmod(ffmpeg_path, permissions | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) - - bot.add_cog(MusicCommands(bot)) \ No newline at end of file diff --git a/src/bot/commands/music/MusicUI.py b/src/bot/commands/music/MusicUI.py new file mode 100644 index 0000000..6beee18 --- /dev/null +++ b/src/bot/commands/music/MusicUI.py @@ -0,0 +1,407 @@ +from typing import Any, Callable, Coroutine +from nextcord import Colour, HTTPException, Member, Message +from nextcord.ui import Button, button, Modal, TextInput, Select +from nextcord import ButtonStyle, Interaction, Emoji, PartialEmoji, TextInputStyle +from wavelink import LavalinkLoadException, Playlist, Search, Playable, Player, TrackSource, QueueMode, AutoPlayMode +from datetime import datetime, timedelta, timezone +import traceback +import asyncio + +from utils.abc import Page +from utils.exceptions import GGsBotException +from utils.system import getlogger +from utils.emojis import * +from .MusicUtils import fromseconds, isurl + + +logger = getlogger() + +class NowPlayingPage(Page): + def __init__(self, track : Playable): + Page.__init__(self, timeout=0) + self.color = Colour.green() + self.url = track.uri + self.title = f"**{track.title}**" + self.set_author(name="Now Playing") + if track.artwork: self.set_thumbnail(track.artwork) + + self.description = f"in `{track.album.name}`\nby *{track.author}*" if track.album.name else f'by {track.author}' + hours, minutes, seconds, milliseconds = fromseconds(track.length / 1000) + self.add_field(name="Duration", value=f'`{hours:02d}:{minutes:02d}:{seconds:02d}`' if hours > 0 else f'`{minutes:02d}:{seconds:02d}`', inline=False) + if track.playlist: self.add_field(name="Playlist", value=f'`{track.playlist.name}`') + +class FinishedPlayingPage(Page): + def __init__(self, title : str, popularity : int, duration_str : str, album_name : str, album_url : str, album_img : str, artists : list[dict]): + Page.__init__(self, timeout=0) + self.url = album_url + self.title = f"**{title}**" + self.colour = Colour.green() + self.description = f"in `{album_name}`\nby {', '.join("*" + artist['name'] + "*" for artist in artists)}" + + self.add_field(name="Duration", value=f'`{duration_str}`', inline=True) + self.add_field(name="Popularity", value=f"`{popularity}%`", inline=True) + + self.set_thumbnail(album_img) + self.set_author(name="Song Finished") + +class NothingToPlayPage(Page): + def __init__(self): + Page.__init__(self, timeout=0) + self.set_author(name="Nothing to play") + self.description = "There is nothing in the queue to play." + self.color = Colour.red() + +class AddedToQueue(Page): + def __init__(self, tracks : Playlist | Playable, member : Member): + Page.__init__(self, timeout=0) + self.color = Colour.green() + self.title = f'**{tracks.name}**' if isinstance(tracks, Playlist) else f'**{tracks.title}**' + self.url = tracks.url if hasattr(tracks, 'url') else tracks.uri if hasattr(tracks, 'uri') else None + self.set_author(name="Added to queue") + if tracks.artwork: self.set_thumbnail(tracks.artwork) + + if isinstance(tracks, Playlist): + self.description = f'by *{tracks.author}*' if tracks.author else None + + total_duration = sum(song.length for song in tracks.tracks) + hours, minutes, seconds, milliseconds = fromseconds(total_duration / 1000) + + self.add_field(name="Duration", value=f'`{hours:02d}:{minutes:02d}:{seconds:02d}`' if hours > 0 else f'`{minutes:02d}:{seconds:02d}`', inline=True) + self.add_field(name="Tracks", value=f'`{num_tracks}`' if (num_tracks:=len(tracks.tracks)) < 600 else f'`{num_tracks}`\n(Not all songs are shown)', inline=True) + self.add_field(name="Type", value=f"`{tracks.type}`", inline=True) + + elif isinstance(tracks, Playable): + self.description = f"in `{tracks.album.name}`\nby *{tracks.author}*" if tracks.album.name else f'by {tracks.author}' + hours, minutes, seconds, milliseconds = fromseconds(tracks.length / 1000) + self.add_field(name="Duration", value=f'`{hours:02d}:{minutes:02d}:{seconds:02d}`' if hours > 0 else f'`{minutes:02d}:{seconds:02d}`', inline=True) + self.add_field(name="Type", value=f"`Song`", inline=True) + + self.add_field(name="Added by", value=f'{member.mention}', inline=False) + +class NoTracksFound(Page): + def __init__(self, query : str): + Page.__init__(self, timeout=0) + self.set_author(name="No tracks found") + self.description = f"No tracks found for query: \"{query}\"" + self.color = Colour.red() + +class UserResumed(Page): + def __init__(self, user : Member): + Page.__init__(self, timeout=0) + self.set_author(name="Resumed") + self.description = f"{user.mention} resumed the current song." + self.color = Colour.green() + +class UserSkipped(Page): + def __init__(self, user : Member, skipped : Playable | Playlist): + Page.__init__(self, timeout=0) + self.set_author(name="Skipped") + if isinstance(skipped, Playable): + self.description = f"{user.mention} skipped the current playing song `{skipped.title}`." + else: + self.description = f"{user.mention} skipped the current playing playlist `{skipped.name}`." + self.color = Colour.yellow() + +class UserShuffled(Page): + def __init__(self, user : Member): + Page.__init__(self, timeout=0) + self.set_author(name="Shuffled") + self.description = f"{user.mention} shuffled the queue." + self.color = Colour.green() + +class UserPaused(Page): + def __init__(self, user : Member): + Page.__init__(self, timeout=0) + self.set_author(name="Paused") + self.description = f"{user.mention} paused the current playing song." + self.color = Colour.yellow() + +class AddModal(Modal): + def __init__(self, on_query_callback : Coroutine[Any, Any, None]): + Modal.__init__(self, "Add a track to the queue") + self.on_query_callback = on_query_callback + + self.query = TextInput( + label="Add tracks, playlists and albums to the queue", + placeholder="Name and URls are allowed", + style=TextInputStyle.short, + required=True + ) + + self.searchengine = TextInput( + label="Search engine for queries", + placeholder="YouTube, YouTubeMusic, SoundCloud or Spotify", + style=TextInputStyle.short, + default_value="Spotify", + required=True, + row=1 + ) + + self.query.callback = self.callback + + self.add_item(self.query) + self.add_item(self.searchengine) + + async def callback(self, interaction: Interaction): + try: + asyncio.create_task(self.on_query_callback(interaction, self.query.value.strip(), self.searchengine.value.strip())) + except Exception as e: + logger.error(traceback.format_exc()) + +class MiniPlayer(Page): + def __init__(self, player : Player, message : Message): + Page.__init__(self, timeout=None) + self.message = message + self.set_author(name="Now Playing") + self.title = "No track playing" + self.description = "Add a track to the queue to see it here." + self.color = Colour.green() + self.player = player + self.last_update : datetime = datetime.now(timezone.utc) + + self.last_shuffle : datetime = None + + # First row + + self.shuffle_button = Button(style=ButtonStyle.grey, label="Shuffle", emoji=shuffle, row=0, disabled=True) + self.shuffle_button.callback = self.on_shuffle + + self.repeat_button = Button(style=ButtonStyle.grey, label="Loop [off]", emoji=repeat, row=0, disabled=True) + self.repeat_button.callback = self.on_repeat + + self.autoqueue_button = Button(style=ButtonStyle.grey, label="Autoqueue [disabled]", emoji=autoqueue_disabled, row=0, disabled=True) + self.autoqueue_button.callback = self.on_autoqueue + + # Second row + + self.add_button = Button(style=ButtonStyle.grey, label="Add", emoji=queue_add, row=1, disabled=False) + self.add_button.callback = self.on_add + + # Third row + + self.back_button = Button(style=ButtonStyle.grey, label="Back", emoji=skip_back, row=2, disabled=True) + self.back_button.callback = self.on_back + + self.playpause_button = Button(style=ButtonStyle.grey, label="Play", emoji=play, row=2, disabled=True) + self.playpause_button.callback = self.on_play + + self.next_button = Button(style=ButtonStyle.grey, label="Next", emoji=skip_forward, row=2, disabled=True) + self.next_button.callback = self.on_next + + self.add_item(self.shuffle_button) + self.add_item(self.repeat_button) + self.add_item(self.autoqueue_button) + + self.add_item(self.add_button) + + self.add_item(self.back_button) + self.add_item(self.playpause_button) + self.add_item(self.next_button) + + def _get_progress_bar(self, position: int, length: int, size: int = 10) -> str: + progress_ratio = position / length + filled_blocks = round(progress_ratio * size) + empty_blocks = size - filled_blocks + return str(progress_bar_green) * filled_blocks + str(progress_bar_black) * empty_blocks + + async def update_track(self, finished : bool = False): + try: + now = datetime.now(timezone.utc) + + #if self.last_update > now - timedelta(seconds=10): + #logger.debug(f'Cannot update track because it was updated within the last 10 seconds.') + #return + + self.last_update = datetime.now(timezone.utc) + + track = self.player.current + + self.back_button.disabled = not track + self.playpause_button.disabled = not track + self.next_button.disabled = not track + self.repeat_button.disabled = not track + self.autoqueue_button.disabled = not track + + if (self.last_shuffle and self.last_shuffle < now - timedelta(minutes=1)) or not self.last_shuffle: + self.shuffle_button.disabled = not track + + self.playpause_button.label = "Pause" if track and self.player.playing else "Play" + self.playpause_button.emoji = pause if track and self.player.playing else play + + if not track or finished: + self.url = None + self.title = "No track playing" + self.description = "Add a track to the queue to see it here." + self.set_thumbnail(None) + await self.message.edit(embed=self, view=self) + return + + end_h, end_m, end_s, end_ms = fromseconds(track.length / 1000) + pos_h, pos_m, pos_s, pos_ms = fromseconds(self.player.position / 1000) + + end_str = f'`{end_h:02d}:{end_m:02d}:{end_s:02d}`' if end_h > 0 else f'`{end_m:02d}:{end_s:02d}`' + pos_str = f'`{pos_h:02d}:{pos_m:02d}:{pos_s:02d}`' if pos_h > 0 else f'`{pos_m:02d}:{pos_s:02d}`' + + self.url = track.uri + self.title = f"**{track.title}**" + self.description = f"in `{track.album.name}`\nby *{track.author}*" if track.album.name else f'by {track.author}' + self.description += "\n\n" + f'{pos_str}' + self._get_progress_bar(self.player.position,track.length, 15) + f'{end_str}' + + track_string = lambda track: f'1. [**{track.title if len(track.title) < 40 else track.title[:37] + '...'}**]({track.uri}) by *{track.author}*' + + self.description += f"\n### Queue ({len(self.player.queue)} tracks):\n" + self.description += '\n'.join([track_string(track) for track in self.player.queue[0:5]]) \ + if len(self.player.queue) > 0 \ + else '-# There are no other tracks in the queue' + self.description += f"\n### History ({max(len(self.player.queue.history) - 1, 0)} tracks):\n" + self.description += '\n'.join([track_string(track) for track in self.player.queue.history[-2:-7:-1]]) \ + if len(self.player.queue.history) - 1 > 0 \ + else '-# There are no tracks in the history' + + if track.artwork: self.set_thumbnail(url=track.artwork) + + await self.message.edit(embed=self, view=self) + except HTTPException as e: + if e.code == 30046: + await self.message.delete() + self.message = await self.message.channel.send(embed=self, view=self) + except Exception as e: + logger.error(traceback.format_exc()) + + async def on_query(self, interaction : Interaction, query : str, searchengine : str): + try: + if searchengine in ['Youtube', 'YouTubeMusic', 'SoundCloud']: + searchengine = TrackSource[searchengine] + elif searchengine == "Spotify": + searchengine = "spsearch:" + else: + error = GGsBotException( + title="Invalid Search Engine", + description=f"{searchengine} is not a valid search engine.", + suggestions=f"Please use one of the following: {','.join(source.name for source in TrackSource) + ',Spotify'}" + ) + + await interaction.followup.send(embed=error.asEmbed(), ephemeral=True) + return + + tracks : Playlist | list[Playable] = await Playable.search(query, source=searchengine) + + if isinstance(tracks, list) and len(tracks) == 0: + await interaction.followup.send(embed=NoTracksFound(query), delete_after=5, ephemeral=True) + return + + tracks.extras = { + 'requestor_id' : interaction.user.id + } + + await self.player.queue.put_wait(tracks) + + if not self.player.playing: + await self.player.pause(False) + next_song = self.player.queue.get() + await self.player.play(next_song, volume=30) + + if isinstance(tracks, Playlist): + embed = AddedToQueue(tracks, interaction.user) + else: + embed = AddedToQueue(tracks[0], interaction.user) + + await interaction.followup.send(embed=embed, delete_after=5, ephemeral=True) + except LavalinkLoadException as e: + await interaction.followup.send(embed=GGsBotException.formatException(e).asEmbed(), ephemeral=True) + except Exception as e: + logger.error(traceback.format_exc()) + + # First row + + async def on_add(self, interaction : Interaction): + try: + modal = AddModal(self.on_query) + await interaction.response.send_modal(modal) + except Exception as e: + logger.error(traceback.format_exc()) + + # Second row + + async def on_shuffle(self, interaction : Interaction): + try: + self.player.queue.shuffle() + + self.shuffle_button.disabled = True + self.last_shuffle = datetime.now(timezone.utc) + + await self.message.edit(embed=self, view=self) + + page = UserShuffled(interaction.user) + + await interaction.response.send_message(embed=page, view=page, ephemeral=True, delete_after=5) + except Exception as e: + logger.error(traceback.format_exc()) + + async def on_repeat(self, interaction : Interaction): + try: + if self.player.queue.mode == QueueMode.normal: + self.player.queue.mode = QueueMode.loop + self.repeat_button.emoji = infinity + self.repeat_button.label = "Loop [song]" + elif self.player.queue.mode == QueueMode.loop: + self.player.queue.mode = QueueMode.loop_all + self.repeat_button.emoji = repeat_enabled + self.repeat_button.label = "Loop [queue]" + else: + self.player.queue.mode = QueueMode.normal + self.repeat_button.emoji = repeat + self.repeat_button.label = "Loop [off]" + + await self.message.edit(embed=self, view=self) + except Exception as e: + logger.error(traceback.format_exc()) + + async def on_autoqueue(self, interaction : Interaction): + try: + if self.player.autoplay == AutoPlayMode.disabled: + self.player.autoplay = AutoPlayMode.enabled + self.autoqueue_button.emoji = autoqueue + self.autoqueue_button.label = "Autoqueue [enabled]" + elif self.player.autoplay == AutoPlayMode.enabled: + self.player.autoplay = AutoPlayMode.disabled + self.autoqueue_button.emoji = autoqueue_disabled + self.autoqueue_button.label = "Autoqueue [disabled]" + + await self.message.edit(embed=self, view=self) + except Exception as e: + logger.error(traceback.format_exc()) + + # Third row + + async def on_back(self, interaction : Interaction): + try: + if not self.player.queue.history: return + if (len_history:=len(self.player.queue.history)) == 0: return + + next_song = self.player.queue.history.get_at(-2 if len_history >= 2 else -1) + self.player.queue.put_at(0, next_song) + if self.player.current: self.player.queue.put_at(1, self.player.current) + await self.player.skip() + except Exception as e: + logger.error(traceback.format_exc()) + + async def on_play(self, interaction : Interaction): + try: + if self.playpause_button.label == "Play": + self.playpause_button.label = "Pause" + self.playpause_button.emoji = pause + else: + self.playpause_button.label = "Play" + self.playpause_button.emoji = play + + await self.player.pause(not self.player.paused) + await self.message.edit(embed=self, view=self) + except Exception as e: + logger.error(traceback.format_exc()) + + async def on_next(self, interaction : Interaction): + try: + await self.player.skip() + except Exception as e: + logger.error(traceback.format_exc()) diff --git a/src/bot/commands/music/MusicUtilities.py b/src/bot/commands/music/MusicUtilities.py deleted file mode 100644 index 0b65adb..0000000 --- a/src/bot/commands/music/MusicUtilities.py +++ /dev/null @@ -1,200 +0,0 @@ -from typing import Iterable -from nextcord.ext import commands -from utils.system import OS, ARCH -from utils.config import config -from utils.terminal import getlogger -from dataclasses import dataclass, field, fields -from urllib.parse import urlparse -from collections import deque -from enum import Enum -import nextcord -import random -import time -import sys - -logger = getlogger() - -class UrlType(Enum): - YoutubeSong = "YoutubeSong" - YoutubePlaylist = "YoutubePlaylist" - - SpotifySong = "SpotifySong" - SpotifyPlaylist = "SpotifyPlaylist" - SpotifyAlbum = "SpotifyAlbum" - - Query = "Query" - Unknown = "Unknown" - -def urltype(url : str) -> UrlType: - """From a given url return the corresponding UrlType""" - parsed = urlparse(url) - - if parsed.netloc == 'www.youtube.com' and parsed.path == 'watch': - if 'list=' in parsed.params: - return UrlType.YoutubePlaylist - elif 'v=' in parsed.params: - return UrlType.YoutubeSong - else: - return UrlType.Unknown - elif parsed.netloc == 'open.spotify.com': - if 'track' in parsed.path: - return UrlType.SpotifySong - elif 'playlist' in parsed.path: - return UrlType.SpotifyPlaylist - elif 'album' in parsed.path: - return UrlType.SpotifyAlbum - else: - return UrlType.Unknown - - elif 'https' in parsed.scheme or 'http' in parsed.scheme: - return UrlType.Unknown - - return UrlType.Query - -def fromseconds(s : float): - """convert from a given time in seconds to an hours, minutes, seconds and milliseconds format""" - hours = int(s // 3600) - minutes = int((s % 3600) // 60) - seconds = int(s % 60) - milliseconds = int((s % 1) * 1000) - return (hours, minutes, seconds, milliseconds) - -def fromformat(time: tuple[int, int, int, int]): - """Convert from a given hours, minutes, seconds and milliseconds format to a seconds format.""" - return time[0] * 3600 + time[1] * 60 + time[2] + time[3] / 1000 - -@dataclass -class Song: - """ - Class representing a Song object - - :param: data (dict): - Song information from Spotify - - :param: url (str): - Song file url from A Third-Party source (not Spotify) - - """ - data: dict = field(default_factory=dict,repr=False) - url : str = field(default_factory=str,init=False) - duration : float = field(default_factory=float,init=False) - name : str = field(default_factory=str,init=False) - album_type : str = field(default_factory=str,init=False,repr=False) - album_name : str = field(default_factory=str,init=False,repr=False) - album_url : str = field(default_factory=str,init=False,repr=False) - album_release : str = field(default_factory=str,init=False,repr=False) - artists : list = field(default_factory=list,init=False,repr=False) - explicit : bool = field(default_factory=bool,init=False) - popularity : int = field(default_factory=int,init=False) - preview_url : str = field(default_factory=str,init=False) - - def __post_init__(self): - for field_info in fields(self): - if field_info.init: continue # Skip 'data' and other fields that should not be initialized from raw - setattr(self, field_info.name, self.data.get(field_info.name)) - - self.album_name = self.data['album']['name'] - self.album_type = self.data['album']['album_type'] - self.album_url = self.data['album']['external_urls']['spotify'] - self.album_release = self.data['album']['release_date'] - -class Playlist: - pass - -class History(deque): - """Subclass of deque for playing history""" - def __init__(self, *, songs : Iterable = [], maxlen : int = None): - deque.__init__(self,songs,maxlen) - -class Queue(deque): - """Subclass of deque for a music queue with shuffle""" - def __init__(self, *, songs : Iterable = [], maxlen : int = None): - deque.__init__(self,songs,maxlen) - - def __add__(self, other): - if isinstance(other, deque): - queue = Queue(songs=self) - queue.extend(other) - return queue - return NotImplemented - - def shuffle(self): - random.shuffle(self) - - def move(self, origin : int, dest : int): - self.insert(dest,self.__getitem__(origin)) - del self[origin] - -class Session: - """Guild music playing session""" - def __init__(self, bot : commands.Bot, guild : nextcord.Guild, owner : nextcord.User): - self.volume : float = float(config['music'].get('defaultvolume',100.0)) - self.history : History[Song] = History() - self.queue : Queue[Song] = Queue() - self.currentsong : Song - self.totaltime = 0.0 - self.guild = guild - self.owner = owner - self.bot = bot - self.loop = False - self.cycle = False - self.task = None - - def _next(self, error : Exception, lastsong : Song, stime : float, attempts : int = 1): - """Invoked after a song is finished. Plays the next song if there is one.""" - - if error: logger.error(f'Discord Stream audio Error: {error}') - - # Se il tempo di riproduzione e' minore della durata della canzone e i tentativi di riproduzione non sono finiti - if (((ptime:=time.time() - stime + 3) < lastsong.duration) or error) and attempts < config['music']['attempts']: - # Riproduci la canzone da dove si era interrotta - logger.error(f"""The song \'{lastsong.name}\' was not played until the end""") - logger.error(f"Playback time: {ptime}, Song Duration: {lastsong.duration}") - logger.error(f"Playback attempts made: {attempts}/{config['music']['attempts']}") - ftime = fromseconds(ptime) - else: - # Altrimenti toglila dalla queue - ftime = (0,0,0,0) - self.totaltime+=ptime - self.history.append(lastsong) - if len(self.queue) > 0: self.queue.popleft() - - coro = self.play(lastsong if self.loop else None,st=ftime, attempts=attempts+1) - self.task = self.bot.loop.create_task(coro) - self.task.add_done_callback(lambda: print("ciao")) - - async def play(self, song : Song = None, *, st : tuple = (0,0,0,0), attempts : int = 1): - self.guild.voice_client.stop() # Assicuriamo che non ci sia altro in riproduzione - - if not song and len(self.queue) > 0: - song = self.queue[0] # Se non e' stata specificata una canzone prende la successiva nella coda - elif not song and len(self.queue) == 0: - return # Se non e' stata specificata una canzone e la coda e vuota allora non c'e' nulla da riprodurre - - source = nextcord.FFmpegPCMAudio( - source=song.url, - stderr=sys.stderr, - executable=f"{config['paths']['bin'].format(os=OS,arch=ARCH)}ffmpeg{'.exe' if OS == 'windows' else ''}", - before_options=f'-ss {st[0]}:{st[1]}:{st[2]}.{st[3]}', - ) - - stime = time.time() if sum(st) == 0 else fromformat(st) - - self.guild.voice_client.play(source,after=lambda e: self._next(e,lastsong=song,stime=stime,attempts=attempts)) - - self.guild.voice_client.source = nextcord.PCMVolumeTransformer(source) - self.guild.voice_client.source.volume = float(self.volume) / 100.0 - - self.currentsong = song - - async def skip(self): - self.guild.voice_client.stop() - if len(self.queue) > 0: self.queue.popleft() - #await self.play() - - async def replay(self): - self.guild.voice_client.stop() - - coro = self.play() if self.guild.voice_client.is_playing() else self.play(self.history[-1]) - - self.task = self.bot.loop.create_task(coro) diff --git a/src/bot/commands/music/MusicUtils.py b/src/bot/commands/music/MusicUtils.py new file mode 100644 index 0000000..dd080b2 --- /dev/null +++ b/src/bot/commands/music/MusicUtils.py @@ -0,0 +1,12 @@ +import re + +def isurl(string : str): + return bool(re.match(r'^https?://[^\s]+', string)) + +def fromseconds(s : float): + """convert from a given time in `seconds` to an `hours`, `minutes`, `seconds` and `milliseconds` format""" + hours = int(s // 3600) + minutes = int((s % 3600) // 60) + seconds = int(s % 60) + milliseconds = int((s % 1) * 1000) + return (hours, minutes, seconds, milliseconds) \ No newline at end of file diff --git a/src/bot/commands/user/TextToSpeech.py b/src/bot/commands/user/TextToSpeech.py index cb03270..455804c 100644 --- a/src/bot/commands/user/TextToSpeech.py +++ b/src/bot/commands/user/TextToSpeech.py @@ -691,5 +691,29 @@ async def on_message(self, message: Message): except Exception as e: logger.error(traceback.format_exc()) + @commands.Cog.listener() + async def on_voice_state_update(self, member : Member, before : VoiceState, after : VoiceState): + try: + if not before.channel or before.channel.id not in self.sessions: return + + if after.channel and before.channel: + if before.channel.id == after.channel.id: return + + len_members = len(before.channel.members) + + if len_members <= 1: + self.sessions.discard(before.channel.id) + + voice_client : VoiceClient = before.channel.guild.voice_client + + if voice_client and voice_client.is_connected(): + voice_client.stop() + await voice_client.disconnect() + + await before.channel.send(embed=self.tts_disabled_page) + + except Exception as e: + logger.error(traceback.format_exc()) + def setup(bot : commands.Bot): bot.add_cog(TextToSpeech(bot)) \ No newline at end of file diff --git a/src/bot/commands/user/placeholder.txt b/src/bot/commands/user/placeholder.txt deleted file mode 100644 index c36f18a..0000000 --- a/src/bot/commands/user/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -!!This is only a placeholder for loading this folder on github!! \ No newline at end of file diff --git a/src/bot/emojis/autoqueue.png b/src/bot/emojis/autoqueue.png new file mode 100644 index 0000000..021da5a Binary files /dev/null and b/src/bot/emojis/autoqueue.png differ diff --git a/src/bot/emojis/autoqueue_disabled.png b/src/bot/emojis/autoqueue_disabled.png new file mode 100644 index 0000000..8d025b2 Binary files /dev/null and b/src/bot/emojis/autoqueue_disabled.png differ diff --git a/src/bot/emojis/infinity.png b/src/bot/emojis/infinity.png new file mode 100644 index 0000000..9936771 Binary files /dev/null and b/src/bot/emojis/infinity.png differ diff --git a/src/bot/emojis/pause.png b/src/bot/emojis/pause.png new file mode 100644 index 0000000..e624121 Binary files /dev/null and b/src/bot/emojis/pause.png differ diff --git a/src/bot/emojis/play.png b/src/bot/emojis/play.png new file mode 100644 index 0000000..1234007 Binary files /dev/null and b/src/bot/emojis/play.png differ diff --git a/src/bot/emojis/progrees_bar_green.png b/src/bot/emojis/progrees_bar_green.png new file mode 100644 index 0000000..25ab449 Binary files /dev/null and b/src/bot/emojis/progrees_bar_green.png differ diff --git a/src/bot/emojis/progress_bar_black.png b/src/bot/emojis/progress_bar_black.png new file mode 100644 index 0000000..d8e9c19 Binary files /dev/null and b/src/bot/emojis/progress_bar_black.png differ diff --git a/src/bot/emojis/queue.png b/src/bot/emojis/queue.png new file mode 100644 index 0000000..c959e6d Binary files /dev/null and b/src/bot/emojis/queue.png differ diff --git a/src/bot/emojis/queue_add.png b/src/bot/emojis/queue_add.png new file mode 100644 index 0000000..659a262 Binary files /dev/null and b/src/bot/emojis/queue_add.png differ diff --git a/src/bot/emojis/repeat.png b/src/bot/emojis/repeat.png new file mode 100644 index 0000000..b72f596 Binary files /dev/null and b/src/bot/emojis/repeat.png differ diff --git a/src/bot/emojis/repeat2.png b/src/bot/emojis/repeat2.png new file mode 100644 index 0000000..a7e40bc Binary files /dev/null and b/src/bot/emojis/repeat2.png differ diff --git a/src/bot/emojis/shuffle.png b/src/bot/emojis/shuffle.png new file mode 100644 index 0000000..de191b3 Binary files /dev/null and b/src/bot/emojis/shuffle.png differ diff --git a/src/bot/emojis/skip-back.png b/src/bot/emojis/skip-back.png new file mode 100644 index 0000000..55b80f7 Binary files /dev/null and b/src/bot/emojis/skip-back.png differ diff --git a/src/bot/emojis/skip-forward.png b/src/bot/emojis/skip-forward.png new file mode 100644 index 0000000..8af6f7a Binary files /dev/null and b/src/bot/emojis/skip-forward.png differ diff --git a/src/utils/classes.py b/src/utils/classes.py index f2a9a2f..5d8098c 100644 --- a/src/utils/classes.py +++ b/src/utils/classes.py @@ -8,16 +8,15 @@ from utils.system import logger class BytesIOFFmpegPCMAudio(AudioSource): - def __init__(self, source, *, executable='ffmpeg', pipe=False, stderr=None, before_options=None, options=None): + def __init__(self, source, *, executable='ffmpeg', pipe=False, stderr=None, before_options : str = None, options : str = None): stdin = None if not pipe else source - command = [ + command = (shlex.split(before_options) if before_options else []) + [ '-i', '-' if pipe else source, '-f', 's16le', '-ar', '48000', '-ac', '2', '-loglevel', 'warning', ] - if before_options is not None: command.insert(0, before_options) command.insert(0, executable) - if options is not None: command.append(options) + if options is not None: command.extend(shlex.split(options)) command.append('pipe:1') logger.debug(f"FFmpeg command: {command}") diff --git a/src/utils/db.py b/src/utils/db.py index d6163df..9929578 100644 --- a/src/utils/db.py +++ b/src/utils/db.py @@ -1,4 +1,3 @@ -from re import L from cachetools import TTLCache #from redis import Redis # Piu' avanti potro' usare un client Redis per ora una semplice cache in memoria import traceback @@ -114,7 +113,8 @@ def cursor(self) -> aiosqlite.Cursor: return self._cursor @property - def num_queries(self) -> int: return self._num_queries + def num_queries(self) -> int: + return self._num_queries async def execute(self, query: str, params: tuple = ()): try: diff --git a/src/utils/emojis.py b/src/utils/emojis.py new file mode 100644 index 0000000..e664ca8 --- /dev/null +++ b/src/utils/emojis.py @@ -0,0 +1,16 @@ +from nextcord import PartialEmoji + +progress_bar_black = PartialEmoji(name="progress_bar_black", id=1358794186480156729) +progress_bar_green = PartialEmoji(name="progress_bar_green", id=1358794140028113089) +skip_forward = PartialEmoji(name="skip_forward", id=1360005491182407921) +skip_back = PartialEmoji(name="skip_back", id=1360005482533621980) +shuffle = PartialEmoji(name="shuffle", id=1360005473671184434) +play = PartialEmoji(name="play", id=1360005439378685992) +pause = PartialEmoji(name="pause", id=1360005404054261772) +queue_add = PartialEmoji(name="queue_add", id=1360005463231561829) +queue_list = PartialEmoji(name="queue", id=1360005453966344454) +repeat = PartialEmoji(name="repeat", id=1360347728030339225) +repeat_enabled = PartialEmoji(name="repeat_enabled", id=1360347939247095969) +infinity = PartialEmoji(name="infinity", id=1360347752797835474) +autoqueue = PartialEmoji(name="autoqueue", id=1360354243718156569) +autoqueue_disabled = PartialEmoji(name="autoqueue_disabled", id=1360354363457147177) diff --git a/src/utils/system.py b/src/utils/system.py index f47d2e5..51d66e6 100644 --- a/src/utils/system.py +++ b/src/utils/system.py @@ -13,17 +13,17 @@ ARCH = 'x64' if '64' in platform.architecture()[0] else 'x32' def printsysteminfo(): - logger.debug(f'Current Working Directory: {os.getcwd()}') - logger.debug(f'Architecture: {platform.architecture()} (arch: {ARCH})') - logger.debug(f'Platform: {platform.platform()}') - logger.debug(f'Machine: {platform.machine()}') - logger.debug(f'Processor: {platform.processor()}') - logger.debug(f'Node: {platform.node()}') - logger.debug(f'System: {platform.system()} (os: {OS})') - logger.debug(f'Libc Version: {platform.libc_ver()}') - logger.debug(f'Python Version: {platform.python_version()}') - logger.debug(f'Python Build: {platform.python_build()}') - logger.debug(f'Python Revision: {platform.python_revision()}') + logger.debug(f'Current Working Directory: {os.getcwd()}') + logger.debug(f'Architecture: {platform.architecture()} (arch: {ARCH})') + logger.debug(f'Platform: {platform.platform()}') + logger.debug(f'Machine: {platform.machine()}') + logger.debug(f'Processor: {platform.processor()}') + logger.debug(f'Node: {platform.node()}') + logger.debug(f'System: {platform.system()} (os: {OS})') + logger.debug(f'Libc Version: {platform.libc_ver()}') + logger.debug(f'Python Version: {platform.python_version()}') + logger.debug(f'Python Build: {platform.python_build()}') + logger.debug(f'Python Revision: {platform.python_revision()}') def get_psutil_stats(): boot_time = datetime.now(timezone.utc) - datetime.fromtimestamp(psutil.boot_time(), timezone.utc) diff --git a/src/web/HTTPServer.py b/src/web/HTTPServer.py index e1601de..2a69529 100644 --- a/src/web/HTTPServer.py +++ b/src/web/HTTPServer.py @@ -167,6 +167,9 @@ async def status(self, request : Request): stats = get_psutil_stats() status['machine'] = stats + # potrebbe essere un metodo per ottenere le statistiche di lavalink + # self.bot.get_cog('Music') + status['machine']['os'] = OS status['uptime'] = uptime.total_seconds() status['discord'] = { 'latency' : self.bot.latency if self.bot else None }