diff --git a/Dockerfile b/Dockerfile index 16f4b7f3..ba679027 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ ENV SUPPORTED_EXTENSIONS="('.webm', '.mp4', '.mp3', '.avi', '.wav', '.m4v', '.og ENV COOKIE_PATH=config/cookies/cookies.txt ENV GLOBAL_DISABLE_AUTOJOIN_VC=False ENV ANNOUNCE_DISCONNECT=True +ENV ENABLE_PLAYLISTS=True RUN pip --no-cache-dir install -r requirements.txt \ && apt-get update \ diff --git a/config/config.py b/config/config.py index 6311afbb..47e2792a 100644 --- a/config/config.py +++ b/config/config.py @@ -77,6 +77,8 @@ class Config: # whether to tell users the bot is disconnecting ANNOUNCE_DISCONNECT = True + ENABLE_PLAYLISTS = True + def __init__(self): current_cfg = self.load() diff --git a/config/en.json b/config/en.json index b72fedfa..2ca85bca 100644 --- a/config/en.json +++ b/config/en.json @@ -76,6 +76,20 @@ "HELP_RESET_LONG": "Fully resets player (use when bot gets stuck)", "HELP_REMOVE_SHORT": "Remove a song", "HELP_REMOVE_LONG": "Allows to remove a song from the queue by typing it's position (defaults to the last song).", + "HELP_SAVE_PLAYLIST_SHORT": "Save current playlist", + "HELP_SAVE_PLAYLIST_LONG": "Saves current playlist with the specified name to replay it later", + "PLAYLIST_SAVED_MESSAGE": "Playlist saved.", + "PLAYLIST_ALREADY_EXISTS": "Playlist with this name already exists.", + "HELP_LOAD_PLAYLIST_SHORT": "Load and play playlist", + "HELP_LOAD_PLAYLIST_LONG": "Loads playlist with the specified name and plays it", + "PLAYLIST_NOT_FOUND": "Playlist not found.", + "HELP_REMOVE_PLAYLIST_SHORT": "Remove playlist", + "HELP_REMOVE_PLAYLIST_LONG": "Removes playlist with the specified name", + "PLAYLIST_REMOVED": "Playlist removed.", + "HELP_ADD_TO_PLAYLIST_SHORT": "Add song to playlist", + "HELP_ADD_TO_PLAYLIST_LONG": "Adds the song or playlist to the saved playlist", + "PLAYLIST_UPDATED": "Playlist updated.", + "PLAYLISTS_ARE_DISABLED": "Playlists are disabled globally in this bot.", "SETTINGS_EMOJI_CHECK_MSG": "Checking the emoji...", "SEARCH_EMBED_TITLE": "Results:", diff --git a/musicbot/audiocontroller.py b/musicbot/audiocontroller.py index 37b015e3..6d1e89b6 100644 --- a/musicbot/audiocontroller.py +++ b/musicbot/audiocontroller.py @@ -329,19 +329,23 @@ async def play_song(self, song: Song): self.next_song(forced=True) return - self.guild.voice_client.play( - discord.PCMVolumeTransformer( - discord.FFmpegPCMAudio( - song.url, - before_options="-reconnect 1 -reconnect_streamed 1" - " -reconnect_delay_max 5", - options="-loglevel error", - stderr=sys.stderr, + try: + self.guild.voice_client.play( + discord.PCMVolumeTransformer( + discord.FFmpegPCMAudio( + song.url, + before_options="-reconnect 1 -reconnect_streamed 1" + " -reconnect_delay_max 5", + options="-loglevel error", + stderr=sys.stderr, + ), + float(self.volume) / 100.0, ), - float(self.volume) / 100.0, - ), - after=self.next_song, - ) + after=self.next_song, + ) + except discord.ClientException: + await self.udisconnect() + return if ( self.bot.settings[self.guild].announce_songs diff --git a/musicbot/commands/music.py b/musicbot/commands/music.py index 7aabfadc..18bc7223 100644 --- a/musicbot/commands/music.py +++ b/musicbot/commands/music.py @@ -1,17 +1,23 @@ -from typing import Iterable, Union +import json +from typing import Iterable, List, Union -from discord import Attachment +from discord import Attachment, AutocompleteContext from discord.ui import View from discord.ext import commands, bridge from discord.ext.bridge import BridgeOption +from sqlalchemy import select, delete +from sqlalchemy.exc import IntegrityError from config import config -from musicbot import linkutils, utils +from musicbot import linkutils, utils, loader from musicbot.song import Song from musicbot.bot import MusicBot, Context +from musicbot.utils import dj_check from musicbot.audiocontroller import PLAYLIST, AudioController, MusicButton from musicbot.loader import SongError, search_youtube from musicbot.playlist import PlaylistError, LoopMode +from musicbot.settings import SavedPlaylist +from musicbot.linkutils import Origins, SiteTypes class AudioContext(Context): @@ -323,6 +329,152 @@ async def _volume( await ctx.send("Volume set to {}% :loud_sound:".format(str(value))) ctx.audiocontroller.volume = value + async def _playlist_autocomplete( + self, ctx: AutocompleteContext + ) -> List[str]: + async with ctx.bot.DbSession() as session: + return ( + await session.execute( + select(SavedPlaylist.name) + .where( + SavedPlaylist.guild_id == str(ctx.interaction.guild.id) + ) + .where(SavedPlaylist.name.startswith(ctx.value)) + ) + ).scalars() + + @bridge.bridge_command( + name="save_playlist", + aliases=["spl"], + description=config.HELP_SAVE_PLAYLIST_LONG, + help=config.HELP_SAVE_PLAYLIST_SHORT, + ) + @commands.check(dj_check) + async def _save_playlist(self, ctx: AudioContext, name: str): + if not config.ENABLE_PLAYLISTS: + await ctx.send(config.PLAYLISTS_ARE_DISABLED) + return + + await ctx.defer() + urls = [ + song.webpage_url for song in ctx.audiocontroller.playlist.playque + ] + if not urls: + await ctx.send(config.QUEUE_EMPTY) + return + async with ctx.bot.DbSession() as session: + session.add( + SavedPlaylist( + guild_id=str(ctx.guild.id), + name=name, + songs_json=json.dumps(urls), + ) + ) + try: + await session.commit() + except IntegrityError: + await ctx.send(config.PLAYLIST_ALREADY_EXISTS) + return + await ctx.send(config.PLAYLIST_SAVED_MESSAGE) + + @bridge.bridge_command( + name="load_playlist", + aliases=["lpl"], + description=config.HELP_LOAD_PLAYLIST_LONG, + help=config.HELP_LOAD_PLAYLIST_SHORT, + ) + async def _load_playlist( + self, + ctx: AudioContext, + name: BridgeOption(str, autocomplete=_playlist_autocomplete), + ): + await ctx.defer() + async with ctx.bot.DbSession() as session: + playlist = ( + await session.execute( + select(SavedPlaylist) + .where(SavedPlaylist.guild_id == str(ctx.guild.id)) + .where(SavedPlaylist.name == name) + ) + ).scalar_one_or_none() + if playlist is None: + await ctx.send(config.PLAYLIST_NOT_FOUND) + return + for url in json.loads(playlist.songs_json): + ctx.audiocontroller.playlist.add( + Song(Origins.Playlist, SiteTypes.YT_DLP, url) + ) + if not ctx.audiocontroller.is_active(): + await ctx.audiocontroller.play_song( + ctx.audiocontroller.playlist[0] + ) + await ctx.send(config.SONGINFO_PLAYLIST_QUEUED) + + @bridge.bridge_command( + name="remove_playlist", + aliases=["rpl"], + description=config.HELP_REMOVE_PLAYLIST_LONG, + help=config.HELP_REMOVE_PLAYLIST_SHORT, + ) + @commands.check(dj_check) + async def _remove_playlist( + self, + ctx: AudioContext, + name: BridgeOption(str, autocomplete=_playlist_autocomplete), + ): + await ctx.defer() + async with ctx.bot.DbSession() as session: + result = await session.execute( + delete(SavedPlaylist) + .where(SavedPlaylist.guild_id == str(ctx.guild.id)) + .where(SavedPlaylist.name == name) + ) + await session.commit() + if result.rowcount == 0: + await ctx.send(config.PLAYLIST_NOT_FOUND) + return + await ctx.send(config.PLAYLIST_REMOVED) + + @bridge.bridge_command( + name="add_to_playlist", + aliases=["apl"], + description=config.HELP_ADD_TO_PLAYLIST_LONG, + help=config.HELP_ADD_TO_PLAYLIST_SHORT, + ) + @commands.check(dj_check) + async def _add_to_playlist( + self, + ctx: AudioContext, + playlist: BridgeOption(str, autocomplete=_playlist_autocomplete), + track: str, + ): + await ctx.defer() + song = await loader.load_song(track) + if song is None: + await ctx.send(config.SONGINFO_ERROR) + return + if isinstance(song, Song): + urls = [song.webpage_url] + else: + urls = [s.webpage_url for s in song] + + async with ctx.bot.DbSession() as session: + playlist = ( + await session.execute( + select(SavedPlaylist) + .where(SavedPlaylist.guild_id == str(ctx.guild.id)) + .where(SavedPlaylist.name == playlist) + ) + ).scalar_one_or_none() + if playlist is None: + await ctx.send(config.PLAYLIST_NOT_FOUND) + return + playlist.songs_json = json.dumps( + json.loads(playlist.songs_json) + urls + ) + await session.commit() + await ctx.send(config.PLAYLIST_UPDATED) + def setup(bot: MusicBot): bot.add_cog(Music(bot)) diff --git a/musicbot/settings.py b/musicbot/settings.py index fcd47b4c..bd252d4d 100644 --- a/musicbot/settings.py +++ b/musicbot/settings.py @@ -299,6 +299,14 @@ async def update_setting( return True +class SavedPlaylist(Base): + __tablename__ = "playlists" + + guild_id: Mapped[DiscordIdStr] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(primary_key=True) + songs_json: Mapped[str] + + def run_migrations(connection): """Automatically creates or deletes tables and columns Reflects code changes"""