Skip to content

Commit

Permalink
Playlists feature (#211)
Browse files Browse the repository at this point in the history
* Perform disconnect when playing fails

Handles the case when current voice channel gets deleted

* Add basic playlist features

* Improve playlist feature

- Add checks
- Add remove_playlist command
- Add autocomplete

* Add add_to_playlist command

* Make playlists toggleable globally
  • Loading branch information
solaluset authored Nov 15, 2024
1 parent 791857f commit 1756563
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 15 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 2 additions & 0 deletions config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
14 changes: 14 additions & 0 deletions config/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
28 changes: 16 additions & 12 deletions musicbot/audiocontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 155 additions & 3 deletions musicbot/commands/music.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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))
8 changes: 8 additions & 0 deletions musicbot/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down

0 comments on commit 1756563

Please sign in to comment.