import discord from discord.ext import commands import threading import os from random import shuffle, choice from cogs.utils.dataIO import dataIO from cogs.utils import checks from cogs.utils.chat_formatting import pagify, escape from urllib.parse import urlparse from json import JSONDecodeError import re import logging import collections import copy import asyncio import math import time import inspect import subprocess import urllib.parse import datetime from enum import Enum __author__ = "tekulvw" __version__ = "0.1.1" log = logging.getLogger("red.audio") try: import youtube_dl except: youtube_dl = None try: if not discord.opus.is_loaded(): discord.opus.load_opus('libopus-0.dll') except OSError: # Incorrect bitness opus = False except: # Missing opus opus = None else: opus = True youtube_dl_options = { 'source_address': '0.0.0.0', 'format': 'best', 'extractaudio': True, 'audioformat': "mp3", 'nocheckcertificate': True, 'ignoreerrors': False, 'quiet': True, 'no_warnings': True, 'outtmpl': "data/audio/cache/%(id)s", 'default_search': 'auto', 'encoding': 'utf-8' } class MaximumLength(Exception): def __init__(self, m): self.message = m def __str__(self): return self.message class YouTubeDlError(Exception): def __init__(self, m): self.message = m def __str__(self): return self.message class NotConnected(Exception): pass class AuthorNotConnected(NotConnected): pass class VoiceNotConnected(NotConnected): pass class UnauthorizedConnect(Exception): pass class UnauthorizedSpeak(Exception): pass class ChannelUserLimit(Exception): pass class UnauthorizedSave(Exception): pass class ConnectTimeout(NotConnected): pass class InvalidURL(Exception): pass class InvalidSong(InvalidURL): pass class InvalidPlaylist(InvalidSong): pass class deque(collections.deque): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def peek(self): ret = self.pop() self.append(ret) return copy.deepcopy(ret) def peekleft(self): ret = self.popleft() self.appendleft(ret) return copy.deepcopy(ret) class QueueKey(Enum): REPEAT = 1 PLAYLIST = 2 VOICE_CHANNEL_ID = 3 QUEUE = 4 TEMP_QUEUE = 5 NOW_PLAYING = 6 NOW_PLAYING_CHANNEL = 7 class Song: def __init__(self, **kwargs): self.__dict__ = kwargs self.title = kwargs.pop('title', None) self.id = kwargs.pop('id', None) self.url = kwargs.pop('url', None) self.webpage_url = kwargs.pop('webpage_url', "") self.duration = kwargs.pop('duration', 60) self.start_time = kwargs.pop('start_time', None) self.end_time = kwargs.pop('end_time', None) self.thumbnail = kwargs.pop('thumbnail', None) self.view_count = kwargs.pop('view_count', None) self.rating = kwargs.pop('average_rating', None) self.song_start_time = None class QueuedSong: def __init__(self, url, channel): self.url = url self.channel = channel class Playlist: def __init__(self, server=None, sid=None, name=None, author=None, url=None, playlist=None, path=None, main_class=None, **kwargs): # when is this used? idk # what is server when it's global? None? idk self.server = server self._sid = sid self.name = name # this is an id...... self.author = author self.url = url self.main_class = main_class # reference to Audio self.path = path if url is None and "link" in kwargs: self.url = kwargs.get('link') self.playlist = playlist @property def filename(self): f = "data/audio/playlists" f = os.path.join(f, self.sid, self.name + ".txt") return f def to_json(self): ret = {"author": self.author, "playlist": self.playlist, "link": self.url} return ret def is_author(self, user): """checks if the user is the author of this playlist Returns True/False""" return user.id == self.author def can_edit(self, user): """right now checks if user is mod or higher including server owner global playlists are uneditable atm dev notes: should probably be defined elsewhere later or be dynamic""" # I don't know how global playlists are handled. # Not sure if the framework is there for them to be editable. # Don't know how they are handled by Playlist # Don't know how they are handled by Audio # so let's make sure it's not global at all. if self.main_class._playlist_exists_global(self.name): return False admin_role = self.bot.settings.get_server_admin(self.server) mod_role = self.bot.settings.get_server_mod(self.server) is_playlist_author = self.is_author(user) is_bot_owner = user.id == self.bot.settings.owner is_server_owner = self.server.owner.id == self.author is_admin = discord.utils.get(user.roles, name=admin_role) is not None is_mod = discord.utils.get(user.roles, name=mod_role) is not None return any((is_playlist_author, is_bot_owner, is_server_owner, is_admin, is_mod)) # def __del__() ? def append_song(self, author, url): if not self.can_edit(author): raise UnauthorizedSave elif not self.main_class._valid_playable_url(url): raise InvalidURL else: self.playlist.append(url) self.save() def save(self): dataIO.save_json(self.path, self.to_json()) @property def sid(self): if self._sid: return self._sid elif self.server: return self.server.id else: return None class Downloader(threading.Thread): def __init__(self, url, max_duration=None, download=False, cache_path="data/audio/cache", *args, **kwargs): super().__init__(*args, **kwargs) self.url = url self.max_duration = max_duration self.done = threading.Event() self.song = None self._download = download self.hit_max_length = threading.Event() self._yt = None self.error = None def run(self): try: self.get_info() if self._download: self.download() except youtube_dl.utils.DownloadError as e: self.error = str(e) except MaximumLength: self.hit_max_length.set() except OSError as e: log.warning("An operating system error occurred while downloading URL '{}':\n'{}'".format(self.url, str(e))) self.done.set() def download(self): self.duration_check() if not os.path.isfile('data/audio/cache' + self.song.id): video = self._yt.extract_info(self.url) self.song = Song(**video) def duration_check(self): log.debug("duration {} for songid {}".format(self.song.duration, self.song.id)) if self.max_duration and self.song.duration > self.max_duration: log.debug("songid {} too long".format(self.song.id)) raise MaximumLength("songid {} has duration {} > {}".format( self.song.id, self.song.duration, self.max_duration)) def get_info(self): if self._yt is None: self._yt = youtube_dl.YoutubeDL(youtube_dl_options) if "[SEARCH:]" not in self.url: video = self._yt.extract_info(self.url, download=False, process=False) else: self.url = self.url[9:] yt_id = self._yt.extract_info( self.url, download=False)["entries"][0]["id"] # Should handle errors here ^ self.url = "https://youtube.com/watch?v={}".format(yt_id) video = self._yt.extract_info(self.url, download=False, process=False) if(video is not None): self.song = Song(**video) class Audio: """Music Streaming.""" def __init__(self, bot, player): self.bot = bot self.queue = {} # add deque's, repeat self.downloaders = {} # sid: object self.settings = dataIO.load_json("data/audio/settings.json") self.settings_path = "data/audio/settings.json" self.server_specific_setting_keys = ["VOLUME", "VOTE_ENABLED", "VOTE_THRESHOLD", "NOPPL_DISCONNECT", "NOTIFY", "NOTIFY_CHANNEL", "TIMER_DISCONNECT"] self.cache_path = "data/audio/cache" self.local_playlist_path = "data/audio/localtracks" self._old_game = False self.skip_votes = {} self.connect_timers = {} if player == "ffmpeg": self.settings["AVCONV"] = False elif player == "avconv": self.settings["AVCONV"] = True self.save_settings() async def _add_song_status(self, song): if self._old_game is False: self._old_game = list(self.bot.servers)[0].me.game status = list(self.bot.servers)[0].me.status game = discord.Game(name=song.title, type=2) await self.bot.change_presence(status=status, game=game) log.debug('Bot status changed to song title: ' + song.title) def _add_to_queue(self, server, url, channel): if server.id not in self.queue: self._setup_queue(server) queued_song = QueuedSong(url, channel) self.queue[server.id][QueueKey.QUEUE].append(queued_song) def _add_to_temp_queue(self, server, url, channel): if server.id not in self.queue: self._setup_queue(server) queued_song = QueuedSong(url, channel) self.queue[server.id][QueueKey.TEMP_QUEUE].append(queued_song) def _addleft_to_queue(self, server, url, channel): if server.id not in self.queue: self._setup_queue() queued_song = QueuedSong(url, channel) self.queue[server.id][QueueKey.QUEUE].appendleft(queued_song) def _cache_desired_files(self): filelist = set() for server in self.downloaders: song = self.downloaders[server].song try: filelist.add(song.id) except AttributeError: pass return filelist def _cache_max(self): setting_max = self.settings["MAX_CACHE"] return max([setting_max, self._cache_min()]) # enforcing hard limit def _cache_min(self): x = self._server_count() return max([60, 48 * math.log(x) * x**0.3]) # log is not log10 def _cache_required_files(self): queue = copy.deepcopy(self.queue) filelist = set() for server in queue: now_playing = queue[server].get(QueueKey.NOW_PLAYING) try: filelist.add(now_playing.id) except AttributeError: pass return filelist def _cache_size(self): songs = os.listdir(self.cache_path) size = sum(map(lambda s: os.path.getsize( os.path.join(self.cache_path, s)) / 10**6, songs)) return size def _cache_too_large(self): if self._cache_size() > self._cache_max(): return True return False def _clear_queue(self, server): if server.id not in self.queue: return self.queue[server.id][QueueKey.QUEUE] = deque() self.queue[server.id][QueueKey.TEMP_QUEUE] = deque() async def _create_ffmpeg_player(self, server, filename, local=False, start_time=None, end_time=None): """This function will guarantee we have a valid voice client, even if one doesn't exist previously.""" voice_channel_id = self.queue[server.id][QueueKey.VOICE_CHANNEL_ID] voice_client = self.voice_client(server) if voice_client is None: log.debug("not connected when we should be in sid {}".format( server.id)) to_connect = self.bot.get_channel(voice_channel_id) if to_connect is None: raise VoiceNotConnected("Okay somehow we're not connected and" " we have no valid channel to" " reconnect to. In other words...LOL" " REKT.") log.debug("valid reconnect channel for sid" " {}, reconnecting...".format(server.id)) await self._join_voice_channel(to_connect) # SHIT elif voice_client.channel.id != voice_channel_id: # This was decided at 3:45 EST in #advanced-testing by 26 self.queue[server.id][QueueKey.VOICE_CHANNEL_ID] = voice_client.channel.id log.debug("reconnect chan id for sid {} is wrong, fixing".format( server.id)) # Okay if we reach here we definitively have a working voice_client if local: song_filename = os.path.join(self.local_playlist_path, filename) else: song_filename = os.path.join(self.cache_path, filename) use_avconv = self.settings["AVCONV"] options = '-b:a 64k -bufsize 64k' before_options = '' if start_time: before_options += '-ss {}'.format(start_time) if end_time: options += ' -to {} -copyts'.format(end_time) try: voice_client.audio_player.process.kill() log.debug("killed old player") except AttributeError: pass except ProcessLookupError: pass log.debug("making player on sid {}".format(server.id)) voice_client.audio_player = voice_client.create_ffmpeg_player( song_filename, use_avconv=use_avconv, options=options, before_options=before_options) # Set initial volume vol = self.get_server_settings(server)['VOLUME'] / 100 voice_client.audio_player.volume = vol return voice_client # Just for ease of use, it's modified in-place # TODO: _current_playlist # TODO: _current_song def _delete_playlist(self, server, name): if not name.endswith('.txt'): name = name + ".txt" try: os.remove(os.path.join('data/audio/playlists', server.id, name)) except OSError: pass except WindowsError: pass # TODO: _disable_controls() async def _disconnect_voice_client(self, server): if not self.voice_connected(server): return voice_client = self.voice_client(server) await voice_client.disconnect() async def _download_all(self, queued_song_list, channel): """ Doesn't actually download, just get's info for uses like queue_list """ downloaders = [] for queued_song in queued_song_list: d = Downloader(queued_song.url) d.start() downloaders.append(d) while any([d.is_alive() for d in downloaders]): await asyncio.sleep(0.1) songs = [d.song for d in downloaders if d.song is not None and d.error is None] invalid_downloads = [d for d in downloaders if d.error is not None] invalid_number = len(invalid_downloads) if(invalid_number > 0): await self.bot.send_message(channel, "The queue contains {} item(s)" " that can not be played.".format(invalid_number)) return songs async def _download_next(self, server, curr_dl, next_dl): """Checks to see if we need to download the next, and does. Both curr_dl and next_dl should already be started.""" if curr_dl.song is None: # Only happens when the downloader thread hasn't initialized fully # There's no reason to wait if we can't compare return max_length = self.settings["MAX_LENGTH"] while next_dl.is_alive(): await asyncio.sleep(0.5) error = next_dl.error if(error is not None): raise YouTubeDlError(error) if curr_dl.song.id != next_dl.song.id: log.debug("downloader ID's mismatch on sid {}".format(server.id) + " gonna start dl-ing the next thing on the queue" " id {}".format(next_dl.song.id)) try: next_dl.duration_check() except MaximumLength: return self.downloaders[server.id] = Downloader(next_dl.url, max_length, download=True) self.downloaders[server.id].start() def _dump_cache(self, ignore_desired=False): reqd = self._cache_required_files() log.debug("required cache files:\n\t{}".format(reqd)) opt = self._cache_desired_files() log.debug("desired cache files:\n\t{}".format(opt)) prev_size = self._cache_size() for file in os.listdir(self.cache_path): if file in reqd: continue elif not ignore_desired: if file in opt: continue elif file.endswith('.part') and file[:-5] in opt: continue try: os.remove(os.path.join(self.cache_path, file)) except OSError: # A directory got in the cache? pass except WindowsError: # Removing a file in use, reqd failed pass post_size = self._cache_size() dumped = prev_size - post_size if not ignore_desired and self._cache_too_large(): log.debug("must dump desired files") return dumped + self._dump_cache(ignore_desired=True) log.debug("dumped {} MB of audio files".format(dumped)) return dumped # TODO: _enable_controls() # returns list of active voice channels # assuming list does not change during the execution of this function # if that happens, blame asyncio. def _get_active_voice_clients(self): avcs = [] for vc in self.bot.voice_clients: if hasattr(vc, 'audio_player') and not vc.audio_player.is_done(): avcs.append(vc) return avcs def _get_queue(self, server, limit): if server.id not in self.queue: return [] ret = [] for i in range(limit): try: ret.append(self.queue[server.id][QueueKey.QUEUE][i]) except IndexError: pass return ret def _get_queue_nowplaying(self, server): if server.id not in self.queue: return None return self.queue[server.id][QueueKey.NOW_PLAYING] def _get_queue_nowplaying_channel(self, server): if server.id not in self.queue: return None return self.queue[server.id][QueueKey.NOW_PLAYING_CHANNEL] def _get_queue_playlist(self, server): if server.id not in self.queue: return None return self.queue[server.id][QueueKey.PLAYLIST] def _get_queue_repeat(self, server): if server.id not in self.queue: return None return self.queue[server.id][QueueKey.REPEAT] def _get_queue_tempqueue(self, server, limit): if server.id not in self.queue: return [] ret = [] for i in range(limit): try: ret.append(self.queue[server.id][QueueKey.TEMP_QUEUE][i]) except IndexError: pass return ret async def _guarantee_downloaded(self, server, url): max_length = self.settings["MAX_LENGTH"] if server.id not in self.downloaders: # We don't have a downloader log.debug("sid {} not in downloaders, making one".format( server.id)) self.downloaders[server.id] = Downloader(url, max_length) if self.downloaders[server.id].url != url: # Our downloader is old # I'm praying to Jeezus that we don't accidentally lose a running # Downloader log.debug("sid {} in downloaders but wrong url".format(server.id)) self.downloaders[server.id] = Downloader(url, max_length) try: # We're assuming we have the right thing in our downloader object self.downloaders[server.id].start() log.debug("starting our downloader for sid {}".format(server.id)) except RuntimeError: # Queue manager already started it for us, isn't that nice? pass # Getting info w/o download self.downloaders[server.id].done.wait() # Youtube-DL threw an exception. error = self.downloaders[server.id].error if(error is not None): raise YouTubeDlError(error) # This will throw a maxlength exception if required self.downloaders[server.id].duration_check() song = self.downloaders[server.id].song log.debug("sid {} wants to play songid {}".format(server.id, song.id)) # Now we check to see if we have a cache hit cache_location = os.path.join(self.cache_path, song.id) if not os.path.exists(cache_location): log.debug("cache miss on song id {}".format(song.id)) self.downloaders[server.id] = Downloader(url, max_length, download=True) self.downloaders[server.id].start() while self.downloaders[server.id].is_alive(): await asyncio.sleep(0.5) song = self.downloaders[server.id].song else: log.debug("cache hit on song id {}".format(song.id)) return song def _is_queue_playlist(self, server): if server.id not in self.queue: return False return self.queue[server.id][QueueKey.PLAYLIST] async def _join_voice_channel(self, channel): server = channel.server connect_time = self.connect_timers.get(server.id, 0) if time.time() < connect_time: diff = int(connect_time - time.time()) raise ConnectTimeout("You are on connect cooldown for another {}" " seconds.".format(diff)) if server.id in self.queue: self.queue[server.id][QueueKey.VOICE_CHANNEL_ID] = channel.id try: await asyncio.wait_for(self.bot.join_voice_channel(channel), timeout=5, loop=self.bot.loop) except asyncio.futures.TimeoutError as e: log.exception(e) self.connect_timers[server.id] = time.time() + 300 raise ConnectTimeout("We timed out connecting to a voice channel," " please try again in 10 minutes.") def _list_local_playlists(self): ret = [] for thing in os.listdir(self.local_playlist_path): if os.path.isdir(os.path.join(self.local_playlist_path, thing)): ret.append(thing) log.debug("local playlists:\n\t{}".format(ret)) return ret def _list_playlists(self, server): try: server = server.id except: pass path = "data/audio/playlists" old_playlists = [f[:-4] for f in os.listdir(path) if f.endswith(".txt")] path = os.path.join(path, server) if os.path.exists(path): new_playlists = [f[:-4] for f in os.listdir(path) if f.endswith(".txt")] else: new_playlists = [] return list(set(old_playlists + new_playlists)) def _load_playlist(self, server, name, local=True): try: server = server.id except: pass f = "data/audio/playlists" if local: f = os.path.join(f, server, name + ".txt") else: f = os.path.join(f, name + ".txt") kwargs = dataIO.load_json(f) kwargs['path'] = f kwargs['main_class'] = self kwargs['name'] = name kwargs['sid'] = server kwargs['server'] = self.bot.get_server(server) return Playlist(**kwargs) def _local_playlist_songlist(self, name): dirpath = os.path.join(self.local_playlist_path, name) return sorted(os.listdir(dirpath)) def _make_local_song(self, filename): # filename should be playlist_folder/file_name folder, song = os.path.split(filename) return Song(name=song, id=filename, title=song, url=filename, webpage_url=filename) def _make_playlist(self, author, url, songlist): try: author = author.id except: pass return Playlist(author=author, url=url, playlist=songlist) def _match_sc_playlist(self, url): return self._match_sc_url(url) def _match_yt_playlist(self, url): if not self._match_yt_url(url): return False yt_playlist = re.compile( r'^(https?\:\/\/)?(www\.)?(youtube\.com|youtu\.?be)' r'((\/playlist\?)|\/watch\?).*(list=)(.*)(&|$)') # Group 6 should be the list ID if yt_playlist.match(url): return True return False def _match_sc_url(self, url): sc_url = re.compile( r'^(https?\:\/\/)?(www\.)?(soundcloud\.com\/)') if sc_url.match(url): return True return False def _match_yt_url(self, url): yt_link = re.compile( r'^(https?\:\/\/)?(www\.|m\.)?(youtube\.com|youtu\.?be)\/.+$') if yt_link.match(url): return True return False def _match_any_url(self, url): url = urlparse(url) if url.scheme and url.netloc and url.path: return True return False # TODO: _next_songs_in_queue async def _parse_playlist(self, url): if self._match_sc_playlist(url): return await self._parse_sc_playlist(url) elif self._match_yt_playlist(url): return await self._parse_yt_playlist(url) raise InvalidPlaylist("The given URL is neither a Soundcloud or" " YouTube playlist.") async def _parse_sc_playlist(self, url): playlist = [] d = Downloader(url) d.start() while d.is_alive(): await asyncio.sleep(0.5) error = d.error if(error is not None): raise YouTubeDlError(error) for entry in d.song.entries: if entry["url"][4] != "s": song_url = "https{}".format(entry["url"][4:]) playlist.append(song_url) else: playlist.append(entry.url) return playlist async def _parse_yt_playlist(self, url): d = Downloader(url) d.start() playlist = [] while d.is_alive(): await asyncio.sleep(0.5) error = d.error if(error is not None): raise YouTubeDlError(error) for entry in d.song.entries: try: song_url = "https://www.youtube.com/watch?v={}".format( entry['id']) playlist.append(song_url) except AttributeError: pass except TypeError: pass log.debug("song list:\n\t{}".format(playlist)) return playlist async def _play(self, sid, url, channel): """Returns the song object of what's playing""" if type(sid) is not discord.Server: server = self.bot.get_server(sid) else: server = sid assert type(server) is discord.Server log.debug('starting to play on "{}"'.format(server.name)) if self._valid_playable_url(url) or "[SEARCH:]" in url: clean_url = self._clean_url(url) try: song = await self._guarantee_downloaded(server, url) except YouTubeDlError as e: message = ("I'm unable to play '{}' because of an error:\n" "'{}'".format(clean_url, str(e))) message = escape(message, mass_mentions=True) await self.bot.send_message(channel, message) return except MaximumLength: message = ("I'm unable to play '{}' because it exceeds the " "maximum audio length.".format(clean_url)) message = escape(message, mass_mentions=True) await self.bot.send_message(channel, message) return local = False else: # Assume local try: song = self._make_local_song(url) local = True except FileNotFoundError: raise song.song_start_time = datetime.datetime.now() voice_client = await self._create_ffmpeg_player(server, song.id, local=local, start_time=song.start_time, end_time=song.end_time) # That ^ creates the audio_player property voice_client.audio_player.start() log.debug("starting player on sid {}".format(server.id)) return song def _play_playlist(self, server, playlist, channel): try: songlist = playlist.playlist name = playlist.name except AttributeError: songlist = playlist name = True songlist = self._songlist_change_url_to_queued_song(songlist, channel) log.debug("setting up playlist {} on sid {}".format(name, server.id)) self._stop_player(server) self._stop_downloader(server) self._clear_queue(server) log.debug("finished resetting state on sid {}".format(server.id)) self._setup_queue(server) self._set_queue_playlist(server, name) self._set_queue_repeat(server, True) self._set_queue(server, songlist) def _play_local_playlist(self, server, name, channel): songlist = self._local_playlist_songlist(name) ret = [] for song in songlist: ret.append(os.path.join(name, song)) ret_playlist = Playlist(server=server, name=name, playlist=ret) self._play_playlist(server, ret_playlist, channel) def _songlist_change_url_to_queued_song(self, songlist, channel): queued_songlist = [] for song in songlist: queued_song = QueuedSong(song, channel) queued_songlist.append(queued_song) return queued_songlist def _player_count(self): count = 0 queue = copy.deepcopy(self.queue) for sid in queue: server = self.bot.get_server(sid) try: vc = self.voice_client(server) if vc.audio_player.is_playing(): count += 1 except: pass return count def _playlist_exists(self, server, name): return self._playlist_exists_local(server, name) or \ self._playlist_exists_global(name) def _playlist_exists_global(self, name): f = "data/audio/playlists" f = os.path.join(f, name + ".txt") log.debug('checking for {}'.format(f)) return dataIO.is_valid_json(f) def _playlist_exists_local(self, server, name): try: server = server.id except AttributeError: pass f = "data/audio/playlists" f = os.path.join(f, server, name + ".txt") log.debug('checking for {}'.format(f)) return dataIO.is_valid_json(f) def _remove_queue(self, server): if server.id in self.queue: del self.queue[server.id] async def _remove_song_status(self): if self._old_game is not False: status = list(self.bot.servers)[0].me.status await self.bot.change_presence(game=self._old_game, status=status) log.debug('Bot status returned to ' + str(self._old_game)) self._old_game = False def _save_playlist(self, server, name, playlist): sid = server.id try: f = playlist.filename playlist = playlist.to_json() log.debug("got playlist object") except AttributeError: f = os.path.join("data/audio/playlists", sid, name + ".txt") head, _ = os.path.split(f) if not os.path.exists(head): os.makedirs(head) log.debug("saving playlist '{}' to {}:\n\t{}".format(name, f, playlist)) dataIO.save_json(f, playlist) def _shuffle_queue(self, server): shuffle(self.queue[server.id][QueueKey.QUEUE]) def _shuffle_temp_queue(self, server): shuffle(self.queue[server.id][QueueKey.TEMP_QUEUE]) def _server_count(self): return max([1, len(self.bot.servers)]) def _set_queue(self, server, songlist): if server.id in self.queue: self._clear_queue(server) else: self._setup_queue(server) self.queue[server.id][QueueKey.QUEUE].extend(songlist) def _set_queue_channel(self, server, channel): if server.id not in self.queue: return try: channel = channel.id except AttributeError: pass self.queue[server.id][QueueKey.VOICE_CHANNEL_ID] = channel def _set_queue_nowplaying(self, server, song, channel): if server.id not in self.queue: return self.queue[server.id][QueueKey.NOW_PLAYING] = song self.queue[server.id][QueueKey.NOW_PLAYING_CHANNEL] = channel def _set_queue_playlist(self, server, name=True): if server.id not in self.queue: self._setup_queue(server) self.queue[server.id][QueueKey.PLAYLIST] = name def _set_queue_repeat(self, server, value): if server.id not in self.queue: self._setup_queue(server) self.queue[server.id][QueueKey.REPEAT] = value def _setup_queue(self, server): self.queue[server.id] = {QueueKey.REPEAT: False, QueueKey.PLAYLIST: False, QueueKey.VOICE_CHANNEL_ID: None, QueueKey.QUEUE: deque(), QueueKey.TEMP_QUEUE: deque(), QueueKey.NOW_PLAYING: None, QueueKey.NOW_PLAYING_CHANNEL: None} def _stop(self, server): self._setup_queue(server) self._stop_player(server) self._stop_downloader(server) self.bot.loop.create_task(self._update_bot_status()) async def _stop_and_disconnect(self, server): self._stop(server) await self._disconnect_voice_client(server) def _stop_downloader(self, server): if server.id not in self.downloaders: return del self.downloaders[server.id] def _stop_player(self, server): if not self.voice_connected(server): return voice_client = self.voice_client(server) if hasattr(voice_client, 'audio_player'): voice_client.audio_player.stop() # no return. they can check themselves. async def _update_bot_status(self): if self.settings["TITLE_STATUS"]: song = None try: active_servers = self._get_active_voice_clients() except: log.debug("Voice client changed while trying to update bot's" " song status") return if len(active_servers) == 1: server = active_servers[0].server song = self._get_queue_nowplaying(server) if song: await self._add_song_status(song) else: await self._remove_song_status() def _valid_playlist_name(self, name): for char in name: if char.isdigit() or char.isalpha() or char == "_": pass else: return False return True def _valid_playable_url(self, url): yt = self._match_yt_url(url) sc = self._match_sc_url(url) if yt or sc: # TODO: Add sc check return True return False def _clean_url(self, url): if(self._valid_playable_url(url)): return "<{}>".format(url) return url.replace("[SEARCH:]", "") @commands.group(pass_context=True) async def audioset(self, ctx): """Audio settings.""" if ctx.invoked_subcommand is None: await self.bot.send_cmd_help(ctx) return @audioset.command(name="cachemax") @checks.is_owner() async def audioset_cachemax(self, size: int): """Set the max cache size in MB""" if size < self._cache_min(): await self.bot.say("Sorry, but because of the number of servers" " that your bot is in I cannot safely allow" " you to have less than {} MB of cache.".format( self._cache_min())) return self.settings["MAX_CACHE"] = size await self.bot.say("Max cache size set to {} MB.".format(size)) self.save_settings() @audioset.command(name="emptydisconnect", pass_context=True) @checks.mod_or_permissions(manage_messages=True) async def audioset_emptydisconnect(self, ctx): """Toggles auto disconnection when everyone leaves the channel""" server = ctx.message.server settings = self.get_server_settings(server.id) noppl_disconnect = settings.get("NOPPL_DISCONNECT", True) self.set_server_setting(server, "NOPPL_DISCONNECT", not noppl_disconnect) if not noppl_disconnect: await self.bot.say("If there is no one left in the voice channel" " the bot will automatically disconnect after" " five minutes.") else: await self.bot.say("The bot will no longer auto disconnect" " if the voice channel is empty.") self.save_settings() @audioset.command(name="maxlength") @checks.is_owner() async def audioset_maxlength(self, length: int): """Maximum track length (seconds) for requested links""" if length <= 0: await self.bot.say("Wow, a non-positive length value...aren't" " you smart.") return self.settings["MAX_LENGTH"] = length await self.bot.say("Maximum length is now {} seconds.".format(length)) self.save_settings() @checks.mod_or_permissions(manage_messages=True) @audioset.command(name="notifychannel", pass_context=True) async def audioset_notifychannel(self, ctx, channel: discord.Channel): """Sets the channel for the now playing announcement""" server = ctx.message.server if not server.me.permissions_in(channel).send_messages: await self.bot.say("No permissions to speak in that channel.") return self.set_server_setting(server, "NOTIFY_CHANNEL", channel.id) dataIO.save_json(self.settings_path, self.settings) await self.bot.send_message(channel, "I will now announce new songs here.") @audioset.command(name="notify", pass_context=True) @checks.mod_or_permissions(manage_messages=True) async def audioset_notify(self, ctx): """Sends a notification to the channel when the song changes""" server = ctx.message.server settings = self.get_server_settings(server.id) notify = settings.get("NOTIFY", True) self.set_server_setting(server, "NOTIFY", not notify) if self.get_server_settings(server)["NOTIFY_CHANNEL"] is None: self.set_server_setting(server, "NOTIFY_CHANNEL", ctx.message.channel.id) dataIO.save_json(self.settings_path, self.settings) if not notify: await self.bot.say("Now notifying when a new track plays.") else: await self.bot.say("No longer notifying when a new track plays.") self.save_settings() @audioset.command(name="player") @checks.is_owner() async def audioset_player(self): """Toggles between Ffmpeg and Avconv""" self.settings["AVCONV"] = not self.settings["AVCONV"] if self.settings["AVCONV"]: await self.bot.say("Player toggled. You're now using avconv.") else: await self.bot.say("Player toggled. You're now using ffmpeg.") self.save_settings() @audioset.command(name="status") @checks.is_owner() # cause effect is cross-server async def audioset_status(self): """Enables/disables songs' titles as status""" self.settings["TITLE_STATUS"] = not self.settings["TITLE_STATUS"] if self.settings["TITLE_STATUS"]: await self.bot.say("If only one server is playing music, songs'" " titles will now show up as status") # not updating on disable if we say disable # means don't mess with it. await self._update_bot_status() else: await self.bot.say("Songs' titles will no longer show up as" " status") self.save_settings() @audioset.command(name="timerdisconnect", pass_context=True) @checks.mod_or_permissions(manage_messages=True) async def audioset_timerdisconnect(self, ctx): """Toggles the disconnect timer""" server = ctx.message.server settings = self.get_server_settings(server.id) timer_disconnect = settings.get("TIMER_DISCONNECT", True) self.set_server_setting(server, "TIMER_DISCONNECT", not timer_disconnect) if not timer_disconnect: await self.bot.say("The bot will automatically disconnect after" " playback is stopped and five minutes have" " elapsed. Disable this setting to stop the" " bot from disconnecting with other music cogs" " playing.") else: await self.bot.say("The bot will no longer auto disconnect" " while other music cogs are playing.") self.save_settings() @audioset.command(pass_context=True, name="volume", no_pm=True) @checks.mod_or_permissions(manage_messages=True) async def audioset_volume(self, ctx, percent: int=None): """Sets the volume (0 - 100) Note: volume may be set up to 200 but you may experience clipping.""" server = ctx.message.server if percent is None: vol = self.get_server_settings(server)['VOLUME'] msg = "Volume is currently set to %d%%" % vol elif percent >= 0 and percent <= 200: self.set_server_setting(server, "VOLUME", percent) msg = "Volume is now set to %d." % percent if percent > 100: msg += ("\nWarning: volume levels above 100 may result in" " clipping") # Set volume of playing audio vc = self.voice_client(server) if vc: vc.audio_player.volume = percent / 100 self.save_settings() else: msg = "Volume must be between 0 and 100." await self.bot.say(msg) @audioset.command(pass_context=True, name="vote", no_pm=True) @checks.mod_or_permissions(manage_messages=True) async def audioset_vote(self, ctx, percent: int): """Percentage needed for the masses to skip songs. 0 to disable.""" server = ctx.message.server if percent < 0: await self.bot.say("Can't be less than zero.") return elif percent > 100: percent = 100 if percent == 0: enabled = False await self.bot.say("Voting disabled. All users can stop or skip.") else: enabled = True await self.bot.say("Vote percentage set to {}%".format(percent)) self.set_server_setting(server, "VOTE_THRESHOLD", percent) self.set_server_setting(server, "VOTE_ENABLED", enabled) self.save_settings() @commands.group(pass_context=True) async def audiostat(self, ctx): """General stats on audio stuff.""" if ctx.invoked_subcommand is None: await self.bot.send_cmd_help(ctx) return @audiostat.command(name="servers") async def audiostat_servers(self): """Number of servers currently playing.""" count = self._player_count() await self.bot.say("Currently playing music in {} servers.".format( count)) @commands.group(pass_context=True) async def cache(self, ctx): """Cache management tools.""" if ctx.invoked_subcommand is None: await self.bot.send_cmd_help(ctx) return @cache.command(name="dump") @checks.is_owner() async def cache_dump(self): """Dumps the cache.""" dumped = self._dump_cache() await self.bot.say("Dumped {:.3f} MB of audio files.".format(dumped)) @cache.command(name='stats') async def cache_stats(self): """Reports info about the cache. - Current size of the cache. - Maximum cache size. User setting or minimum, whichever is higher. - Minimum cache size. Automatically determined by number of servers Red is running on. """ await self.bot.say("Cache stats:\n" "Current size: {:.2f} MB\n" "Maximum: {:.1f} MB\n" "Minimum: {:.1f} MB".format(self._cache_size(), self._cache_max(), self._cache_min())) @commands.group(pass_context=True, hidden=True, no_pm=True) @checks.is_owner() async def disconnect(self, ctx): """Disconnects from voice channel in current server.""" if ctx.invoked_subcommand is None: server = ctx.message.server await self._stop_and_disconnect(server) @disconnect.command(name="all", hidden=True, no_pm=True) async def disconnect_all(self): """Disconnects from all voice channels.""" while len(list(self.bot.voice_clients)) != 0: vc = list(self.bot.voice_clients)[0] await self._stop_and_disconnect(vc.server) await self.bot.say("done.") @commands.command(hidden=True, pass_context=True, no_pm=True) @checks.is_owner() async def joinvoice(self, ctx): """Joins your voice channel""" author = ctx.message.author server = ctx.message.server voice_channel = author.voice_channel if voice_channel is not None: self._stop(server) await self._join_voice_channel(voice_channel) @commands.group(pass_context=True, no_pm=True) async def local(self, ctx): """Local playlists commands""" if ctx.invoked_subcommand is None: await self.bot.send_cmd_help(ctx) @local.command(name="start", pass_context=True, no_pm=True) async def play_local(self, ctx, *, name): """Plays a local playlist""" server = ctx.message.server author = ctx.message.author voice_channel = author.voice_channel channel = ctx.message.channel # Checking already connected, will join if not if not self.voice_connected(server): try: self.has_connect_perm(author, server) except AuthorNotConnected: await self.bot.say("You must join a voice channel before I can" " play anything.") return except UnauthorizedConnect: await self.bot.say("I don't have permissions to join your" " voice channel.") return except UnauthorizedSpeak: await self.bot.say("I don't have permissions to speak in your" " voice channel.") return except ChannelUserLimit: await self.bot.say("Your voice channel is full.") return else: await self._join_voice_channel(voice_channel) else: # We are connected but not to the right channel if self.voice_client(server).channel != voice_channel: pass # TODO: Perms # Checking if playing in current server if self.is_playing(server): await self.bot.say("I'm already playing a song on this server!") return # TODO: Possibly execute queue? # If not playing, spawn a downloader if it doesn't exist and begin # downloading the next song if self.currently_downloading(server): await self.bot.say("I'm already downloading a file!") return lists = self._list_local_playlists() if not any(map(lambda l: os.path.split(l)[1] == name, lists)): await self.bot.say("Local playlist not found.") return self._play_local_playlist(server, name, channel) @local.command(name="list", no_pm=True) async def list_local(self): """Lists local playlists""" playlists = ", ".join(self._list_local_playlists()) if playlists: playlists = "Available local playlists:\n\n" + playlists for page in pagify(playlists, delims=[" "]): await self.bot.say(page) else: await self.bot.say("There are no playlists.") @commands.command(pass_context=True, no_pm=True) async def pause(self, ctx): """Pauses the current song, `[p]resume` to continue.""" server = ctx.message.server if not self.voice_connected(server): await self.bot.say("Not voice connected in this server.") return # We are connected somewhere voice_client = self.voice_client(server) if not hasattr(voice_client, 'audio_player'): await self.bot.say("Nothing playing, nothing to pause.") elif voice_client.audio_player.is_playing(): voice_client.audio_player.pause() await self.bot.say("Paused.") else: await self.bot.say("Nothing playing, nothing to pause.") @commands.command(pass_context=True, no_pm=True) async def play(self, ctx, *, url_or_search_terms): """Plays a link / searches and play""" url = url_or_search_terms server = ctx.message.server author = ctx.message.author voice_channel = author.voice_channel channel = ctx.message.channel if "www.youtube.com/playlist" in url: await self.bot.send_message(channel, "Use [p]playlist to manage playlist urls.") return # Checking if playing in current server if self.is_playing(server): await ctx.invoke(self._queue, url=url) return # Default to queue # Checking already connected, will join if not try: self.has_connect_perm(author, server) except AuthorNotConnected: await self.bot.say("You must join a voice channel before I can" " play anything.") return except UnauthorizedConnect: await self.bot.say("I don't have permissions to join your" " voice channel.") return except UnauthorizedSpeak: await self.bot.say("I don't have permissions to speak in your" " voice channel.") return except ChannelUserLimit: await self.bot.say("Your voice channel is full.") return if not self.voice_connected(server): await self._join_voice_channel(voice_channel) else: # We are connected but not to the right channel if self.voice_client(server).channel != voice_channel: await self._stop_and_disconnect(server) await self._join_voice_channel(voice_channel) # If not playing, spawn a downloader if it doesn't exist and begin # downloading the next song if self.currently_downloading(server): await self.bot.say("I'm already downloading a file!") return url = url.strip("<>") if self._match_any_url(url): if not self._valid_playable_url(url): await self.bot.say("That's not a valid URL.") return else: url = url.replace("/", "/") url = "[SEARCH:]" + url if "[SEARCH:]" not in url and "youtube" in url: parsed_url = urllib.parse.urlparse(url) query = urllib.parse.parse_qs(parsed_url.query) query.pop("list", None) parsed_url = parsed_url._replace(query=urllib.parse.urlencode(query, True)) url = urllib.parse.urlunparse(parsed_url) self._stop_player(server) self._clear_queue(server) self._add_to_queue(server, url, channel) @commands.command(pass_context=True, no_pm=True) async def prev(self, ctx): """Goes back to the last song.""" # Current song is in NOW_PLAYING server = ctx.message.server channel = ctx.message.channel if self.is_playing(server): curr_url = self._get_queue_nowplaying(server).webpage_url last_url = None if self._is_queue_playlist(server): # need to reorder queue try: last_url = self.queue[server.id][QueueKey.QUEUE].pop() except IndexError: pass log.debug("prev on sid {}, curr_url {}".format(server.id, curr_url)) self._addleft_to_queue(server, curr_url, channel) if last_url: self._addleft_to_queue(server, last_url, channel) self._set_queue_nowplaying(server, None, None) self.voice_client(server).audio_player.stop() await self.bot.say("Going back 1 song.") else: await self.bot.say("Not playing anything on this server.") @commands.group(pass_context=True, no_pm=True) async def playlist(self, ctx): """Playlist management/control.""" if ctx.invoked_subcommand is None: await self.bot.send_cmd_help(ctx) @playlist.command(pass_context=True, no_pm=True, name="create") async def playlist_create(self, ctx, name): """Creates an empty playlist""" server = ctx.message.server author = ctx.message.author if not self._valid_playlist_name(name) or len(name) > 25: await self.bot.say("That playlist name is invalid. It must only" " contain alpha-numeric characters or _.") return # Returns a Playlist object url = None songlist = [] playlist = self._make_playlist(author, url, songlist) playlist.name = name playlist.server = server self._save_playlist(server, name, playlist) await self.bot.say("Empty playlist '{}' saved.".format(name)) @playlist.command(pass_context=True, no_pm=True, name="add") async def playlist_add(self, ctx, name, url): """Add a YouTube or Soundcloud playlist.""" server = ctx.message.server author = ctx.message.author if not self._valid_playlist_name(name) or len(name) > 25: await self.bot.say("That playlist name is invalid. It must only" " contain alpha-numeric characters or _.") return if self._valid_playable_url(url): try: await self.bot.say("Enumerating song list... This could take" " a few moments.") songlist = await self._parse_playlist(url) except InvalidPlaylist: await self.bot.say("That playlist URL is invalid.") return except YouTubeDlError as e: await self.bot.say("An error occurred while enumerating the playlist:\n" "'{}'".format(str(e))) return playlist = self._make_playlist(author, url, songlist) # Returns a Playlist object playlist.name = name playlist.server = server self._save_playlist(server, name, playlist) await self.bot.say("Playlist '{}' saved. Tracks: {}".format( name, len(songlist))) else: await self.bot.say("That URL is not a valid Soundcloud or YouTube" " playlist link. If you think this is in error" " please let us know and we'll get it" " fixed ASAP.") @playlist.command(pass_context=True, no_pm=True, name="append") async def playlist_append(self, ctx, name, url): """Appends to a playlist.""" author = ctx.message.author server = ctx.message.server if name not in self._list_playlists(server): await self.bot.say("There is no playlist with that name.") return playlist = self._load_playlist( server, name, local=self._playlist_exists_local(server, name)) try: playlist.append_song(author, url) except UnauthorizedSave: await self.bot.say("You're not the author of that playlist.") except InvalidURL: await self.bot.say("Invalid link.") else: await self.bot.say("Done.") @playlist.command(pass_context=True, no_pm=True, name="list") async def playlist_list(self, ctx): """Lists all available playlists""" server = ctx.message.server playlists = ", ".join(self._list_playlists(server)) if playlists: playlists = "Available playlists:\n\n" + playlists for page in pagify(playlists, delims=[" "]): await self.bot.say(page) else: await self.bot.say("There are no playlists.") @playlist.command(pass_context=True, no_pm=True, name="queue") async def playlist_queue(self, ctx, url): """Adds a song to the playlist loop. Does NOT write to disk.""" server = ctx.message.server channel = ctx.message.channel if not self.voice_connected(server): await self.bot.say("Not voice connected in this server.") return # We are connected somewhere if server.id not in self.queue: log.debug("Something went wrong, we're connected but have no" " queue entry.") raise VoiceNotConnected("Something went wrong, we have no internal" " queue to modify. This should never" " happen.") # We have a queue to modify self._add_to_queue(server, url, channel) await self.bot.say("Queued.") @playlist.command(pass_context=True, no_pm=True, name="remove") async def playlist_remove(self, ctx, name): """Deletes a saved playlist.""" author = ctx.message.author server = ctx.message.server if not self._valid_playlist_name(name): await self.bot.say("The playlist's name contains invalid " "characters.") return if not self._playlist_exists(server, name): await self.bot.say("Playlist not found.") return playlist = self._load_playlist( server, name, local=self._playlist_exists_local(server, name)) if not playlist.can_edit(author): await self.bot.say("You do not have permissions to delete that playlist.") return self._delete_playlist(server, name) await self.bot.say("Playlist deleted.") @playlist.command(pass_context=True, no_pm=True, name="start") async def playlist_start(self, ctx, name): """Plays a playlist.""" server = ctx.message.server author = ctx.message.author voice_channel = ctx.message.author.voice_channel channel = ctx.message.channel caller = inspect.currentframe().f_back.f_code.co_name if voice_channel is None: await self.bot.say("You must be in a voice channel to start a" " playlist.") return if self._playlist_exists(server, name): if not self.voice_connected(server): try: self.has_connect_perm(author, server) except AuthorNotConnected: await self.bot.say("You must join a voice channel before" " I can play anything.") return except UnauthorizedConnect: await self.bot.say("I don't have permissions to join your" " voice channel.") return except UnauthorizedSpeak: await self.bot.say("I don't have permissions to speak in" " your voice channel.") return except ChannelUserLimit: await self.bot.say("Your voice channel is full.") return else: await self._join_voice_channel(voice_channel) self._clear_queue(server) playlist = self._load_playlist(server, name, local=self._playlist_exists_local( server, name)) if caller == "playlist_start_mix": shuffle(playlist.playlist) self._play_playlist(server, playlist, channel) await self.bot.say("Playlist queued.") else: await self.bot.say("That playlist does not exist.") @playlist.command(pass_context=True, no_pm=True, name="mix") async def playlist_start_mix(self, ctx, name): """Plays and mixes a playlist.""" await self.playlist_start.callback(self, ctx, name) @commands.command(pass_context=True, no_pm=True, name="queue") async def _queue(self, ctx, *, url=None): """Queues a song to play next. Extended functionality in `[p]help` If you use `queue` when one song is playing, your new song will get added to the song loop (if running). If you use `queue` when a playlist is running, it will temporarily be played next and will NOT stay in the playlist loop.""" if url is None: return await self._queue_list(ctx) server = ctx.message.server channel = ctx.message.channel if not self.voice_connected(server): await ctx.invoke(self.play, url_or_search_terms=url) return # We are connected somewhere if server.id not in self.queue: log.debug("Something went wrong, we're connected but have no" " queue entry.") raise VoiceNotConnected("Something went wrong, we have no internal" " queue to modify. This should never" " happen.") url = url.strip("<>") if self._match_any_url(url): if not self._valid_playable_url(url): await self.bot.say("That's not a valid URL.") return else: url = "[SEARCH:]" + url if "[SEARCH:]" not in url and "youtube" in url: parsed_url = urllib.parse.urlparse(url) query = urllib.parse.parse_qs(parsed_url.query) query.pop("list", None) parsed_url = parsed_url._replace(query=urllib.parse.urlencode(query, True)) url = urllib.parse.urlunparse(parsed_url) # We have a queue to modify if self.queue[server.id][QueueKey.PLAYLIST]: log.debug("queueing to the temp_queue for sid {}".format( server.id)) self._add_to_temp_queue(server, url, channel) else: log.debug("queueing to the actual queue for sid {}".format( server.id)) self._add_to_queue(server, url, channel) await self.bot.say("Queued.") async def _queue_list(self, ctx): """Not a command, use `queue` with no args to call this.""" server = ctx.message.server channel = ctx.message.channel now_playing = self._get_queue_nowplaying(server) if server.id not in self.queue and now_playing is None: await self.bot.say("Nothing playing on this server!") return if len(self.queue[server.id][QueueKey.QUEUE]) == 0 and not self.is_playing(server): await self.bot.say("Nothing queued on this server.") return colour = ''.join([choice('0123456789ABCDEF') for x in range(6)]) em = discord.Embed(description="", colour=int(colour, 16)) msg = "" if self.is_playing(server): msg += "\n***Currently playing:***\n{}\n".format(now_playing.title) msg += self._draw_play(now_playing, server) + "\n" # draw play thing if now_playing.thumbnail is None: now_playing.thumbnail = (self.bot.user.avatar_url).replace('webp', 'png') em.set_thumbnail(url=now_playing.thumbnail) queued_song_list = self._get_queue(server, 10) tempqueued_song_list = self._get_queue_tempqueue(server, 10) await self.bot.say("Gathering information...") queue_song_list = await self._download_all(queued_song_list, channel) tempqueue_song_list = await self._download_all(tempqueued_song_list, channel) song_info = [] for num, song in enumerate(tempqueue_song_list, 1): str_duration = str(datetime.timedelta(seconds=song.duration)) try: if song.title is None: song_info.append("**[{}]** {.webpage_url} ({})".format(num, song, str_duration)) else: song_info.append("**[{}]** {.title} ({})".format(num, song, str_duration)) except AttributeError: song_info.append("**[{}]** {.webpage_url} ({})".format(num, song, str_duration)) for num, song in enumerate(queue_song_list, len(song_info) + 1): str_duration = str(datetime.timedelta(seconds=song.duration)) if num > 10: break try: if song.title is None: song_info.append("**[{}]** {.webpage_url} ({})".format(num, song, str_duration)) else: song_info.append("**[{}]** {.title} ({})".format(num, song, str_duration)) except AttributeError: song_info.append("**[{}]** {.webpage_url} ({})".format(num, song, str_duration)) if song_info: msg += "\n***Next up:***\n" + "\n".join(song_info) em.description = msg.replace('None', '-') more_songs = len(self.queue[server.id][QueueKey.QUEUE]) - 10 if more_songs > 0: em.set_footer(text="And {} more songs...".format(more_songs)) await self.bot.say(embed=em) def _draw_play(self, song, server): song_start_time = song.song_start_time total_time = datetime.timedelta(seconds=song.duration) current_time = datetime.datetime.now() elapsed_time = current_time - song_start_time sections = 12 loc_time = round((elapsed_time/total_time) * sections) # 10 sections bar_char = '\N{BOX DRAWINGS HEAVY HORIZONTAL}' seek_char = '\N{RADIO BUTTON}' play_char = '\N{BLACK RIGHT-POINTING TRIANGLE}' try: if self.voice_client(server).audio_player.is_playing(): play_char = '\N{BLACK RIGHT-POINTING TRIANGLE}' else: play_char = '\N{DOUBLE VERTICAL BAR}' except AttributeError: pass msg = "\n" + play_char + " " for i in range(sections): if i == loc_time: msg += seek_char else: msg += bar_char msg += " `{}`/`{}`".format(str(elapsed_time)[0:7],str(total_time)) return msg @commands.group(pass_context=True, no_pm=True) async def repeat(self, ctx): """Toggles REPEAT""" server = ctx.message.server if ctx.invoked_subcommand is None: if self.is_playing(server): if self.queue[server.id][QueueKey.REPEAT]: msg = "The queue is currently looping." else: msg = "The queue is currently not looping." await self.bot.say(msg) await self.bot.say( "Do `{}repeat toggle` to change this.".format(ctx.prefix)) else: await self.bot.say("Play something to see this setting.") @repeat.command(pass_context=True, no_pm=True, name="toggle") async def repeat_toggle(self, ctx): """Flips repeat setting.""" server = ctx.message.server if not self.is_playing(server): await self.bot.say("I don't have a repeat setting to flip." " Try playing something first.") return self._set_queue_repeat(server, not self.queue[server.id][QueueKey.REPEAT]) repeat = self.queue[server.id][QueueKey.REPEAT] if repeat: await self.bot.say("Repeat toggled on.") else: await self.bot.say("Repeat toggled off.") @commands.command(pass_context=True, no_pm=True) async def resume(self, ctx): """Resumes a paused song or playlist""" server = ctx.message.server if not self.voice_connected(server): await self.bot.say("Not voice connected in this server.") return # We are connected somewhere voice_client = self.voice_client(server) if not hasattr(voice_client, 'audio_player'): await self.bot.say("Nothing paused, nothing to resume.") elif not voice_client.audio_player.is_done() and \ not voice_client.audio_player.is_playing(): voice_client.audio_player.resume() await self.bot.say("Resuming.") else: await self.bot.say("Nothing paused, nothing to resume.") @commands.command(pass_context=True, no_pm=True, name="shuffle") async def _shuffle(self, ctx): """Shuffles the current queue""" server = ctx.message.server if server.id not in self.queue: await self.bot.say("I have nothing in queue to shuffle.") return self._shuffle_queue(server) self._shuffle_temp_queue(server) await self.bot.say("Queues shuffled.") @commands.command(pass_context=True, aliases=["next"], no_pm=True) async def skip(self, ctx): """Skips a song, using the set threshold if the requester isn't a mod or admin. Mods, admins and bot owner are not counted in the vote threshold.""" msg = ctx.message server = ctx.message.server if self.is_playing(server): vchan = server.me.voice_channel vc = self.voice_client(server) if msg.author.voice_channel == vchan: if self.can_instaskip(msg.author): vc.audio_player.stop() if self._get_queue_repeat(server) is False: self._set_queue_nowplaying(server, None, None) await self.bot.say("Skipping...") else: if msg.author.id in self.skip_votes[server.id]: self.skip_votes[server.id].remove(msg.author.id) reply = "I removed your vote to skip." else: self.skip_votes[server.id].append(msg.author.id) reply = "you voted to skip." num_votes = len(self.skip_votes[server.id]) # Exclude bots and non-plebs num_members = sum(not (m.bot or self.can_instaskip(m)) for m in vchan.voice_members) vote = int(100 * num_votes / num_members) thresh = self.get_server_settings(server)["VOTE_THRESHOLD"] if vote >= thresh: vc.audio_player.stop() if self._get_queue_repeat(server) is False: self._set_queue_nowplaying(server, None, None) self.skip_votes[server.id] = [] await self.bot.say("Vote threshold met. Skipping...") return else: reply += " Votes: %d/%d" % (num_votes, num_members) reply += " (%d%% out of %d%% needed)" % (vote, thresh) await self.bot.reply(reply) else: await self.bot.say("You need to be in the voice channel to skip the music.") else: await self.bot.say("Can't skip if I'm not playing.") def can_instaskip(self, member): server = member.server if not self.get_server_settings(server)["VOTE_ENABLED"]: return True admin_role = self.bot.settings.get_server_admin(server) mod_role = self.bot.settings.get_server_mod(server) is_owner = member.id == self.bot.settings.owner is_server_owner = member == server.owner is_admin = discord.utils.get(member.roles, name=admin_role) is not None is_mod = discord.utils.get(member.roles, name=mod_role) is not None nonbots = sum(not m.bot for m in member.voice_channel.voice_members) alone = nonbots <= 1 return is_owner or is_server_owner or is_admin or is_mod or alone @commands.command(pass_context=True, no_pm=True) async def sing(self, ctx): """Makes Red sing one of her songs""" ids = ("zGTkAVsrfg8", "cGMWL8cOeAU", "vFrjMq4aL-g", "WROI5WYBU_A", "41tIUr_ex3g", "f9O2Rjn1azc") url = "https://www.youtube.com/watch?v={}".format(choice(ids)) await ctx.invoke(self.play, url_or_search_terms=url) @commands.command(pass_context=True, aliases=["np"], no_pm=True) async def song(self, ctx): """Info about the current song.""" server = ctx.message.server if not self.is_playing(server): await self.bot.say("I'm not playing on this server.") return song = self._get_queue_nowplaying(server) if song: if not hasattr(song, 'creator'): song.creator = None if not hasattr(song, 'view_count'): song.view_count = None if not hasattr(song, 'uploader'): song.uploader = None if song.rating is None: song.rating = 0 if song.thumbnail is None: song.thumbnail = (self.bot.user.avatar_url).replace('webp', 'png') if hasattr(song, 'duration'): m, s = divmod(song.duration, 60) h, m = divmod(m, 60) if h: dur = "{0}:{1:0>2}:{2:0>2}".format(h, m, s) else: dur = "{0}:{1:0>2}".format(m, s) else: dur = None msg = ("**Author:** `{}`\n**Uploader:** `{}`\n" "**Duration:** `{}`\n**Rating: **`{:.2f}`\n**Views:** `{}`".format( song.creator, song.uploader, str(datetime.timedelta(seconds=song.duration)), song.rating, song.view_count)) msg += self._draw_play(song, server) + "\n" colour = ''.join([choice('0123456789ABCDEF') for x in range(6)]) em = discord.Embed(description="", colour=int(colour, 16)) if 'http' not in song.webpage_url: em.set_author(name=song.title) else: em.set_author(name=song.title, url=song.webpage_url) em.set_thumbnail(url=song.thumbnail) em.description = msg.replace('None', '-') await self.bot.say("**Currently Playing:**", embed=em) else: await self.bot.say("Darude - Sandstorm.") @commands.command(pass_context=True, no_pm=True) async def stop(self, ctx): """Stops a currently playing song or playlist. CLEARS QUEUE.""" server = ctx.message.server if self.is_playing(server): if ctx.message.author.voice_channel == server.me.voice_channel: if self.can_instaskip(ctx.message.author): await self.bot.say('Stopping...') self._stop(server) else: await self.bot.say("You can't stop music when there are other" " people in the channel! Vote to skip" " instead.") else: await self.bot.say("You need to be in the voice channel to stop the music.") else: await self.bot.say("Can't stop if I'm not playing.") @commands.command(name="yt", pass_context=True, no_pm=True) async def yt_search(self, ctx, *, search_terms: str): """Searches and plays a video from YouTube""" await self.bot.say("Searching...") await ctx.invoke(self.play, url_or_search_terms=search_terms) def is_playing(self, server): if not self.voice_connected(server): return False if self.voice_client(server) is None: return False if not hasattr(self.voice_client(server), 'audio_player'): return False if self.voice_client(server).audio_player.is_done(): return False return True async def cache_manager(self): while self == self.bot.get_cog("Audio"): if self._cache_too_large(): # Our cache is too big, dumping log.debug("cache too large ({} > {}), dumping".format( self._cache_size(), self._cache_max())) self._dump_cache() await asyncio.sleep(5) # No need to run this every half second async def cache_scheduler(self): await asyncio.sleep(30) # Extra careful self.bot.loop.create_task(self.cache_manager()) def currently_downloading(self, server): if server.id in self.downloaders: if self.downloaders[server.id].is_alive(): return True return False async def disconnect_timer(self): stop_times = {} while self == self.bot.get_cog('Audio'): for vc in self.bot.voice_clients: server = vc.server if not hasattr(vc, 'audio_player') and \ (server not in stop_times or stop_times[server] is None): log.debug("putting sid {} in stop loop, no player".format( server.id)) stop_times[server] = int(time.time()) if hasattr(vc, 'audio_player'): if vc.audio_player.is_done(): if server not in stop_times or stop_times[server] is None: log.debug("putting sid {} in stop loop".format(server.id)) stop_times[server] = int(time.time()) noppl_disconnect = self.get_server_settings(server) noppl_disconnect = noppl_disconnect.get("NOPPL_DISCONNECT", True) if noppl_disconnect and len(vc.channel.voice_members) == 1: if server not in stop_times or stop_times[server] is None: log.debug("putting sid {} in stop loop".format(server.id)) stop_times[server] = int(time.time()) elif not vc.audio_player.is_done(): stop_times[server] = None for server in stop_times: if stop_times[server] and \ int(time.time()) - stop_times[server] > 300: # 5 min not playing to d/c timer_disconnect = self.get_server_settings(server) timer_disconnect = timer_disconnect.get("TIMER_DISCONNECT", True) if timer_disconnect: log.debug("dcing from sid {} after 300s".format(server.id)) self._clear_queue(server) await self._stop_and_disconnect(server) stop_times[server] = None await asyncio.sleep(5) def get_server_settings(self, server): try: sid = server.id except: sid = server if sid not in self.settings["SERVERS"]: self.settings["SERVERS"][sid] = {} ret = self.settings["SERVERS"][sid] # Not the cleanest way. Some refactoring is suggested if more settings # have to be added if "NOPPL_DISCONNECT" not in ret: ret["NOPPL_DISCONNECT"] = True if "NOTIFY" not in ret: ret["NOTIFY"] = False if "NOTIFY_CHANNEL" not in ret: ret["NOTIFY_CHANNEL"] = None if "TIMER_DISCONNECT" not in ret: ret["TIMER_DISCONNECT"] = True for setting in self.server_specific_setting_keys: if setting not in ret: # Add the default ret[setting] = self.settings[setting] if setting.lower() == "volume" and ret[setting] <= 1: ret[setting] *= 100 # ^This will make it so that only users with an outdated config will # have their volume set * 100. In theory. self.save_settings() return ret def has_connect_perm(self, author, server): channel = author.voice_channel if channel: in_channel = bool(server.me in channel.voice_members) is_admin = channel.permissions_for(server.me).administrator if channel.user_limit == 0: is_full = False else: is_full = len(channel.voice_members) >= channel.user_limit if channel is None: raise AuthorNotConnected elif channel.permissions_for(server.me).connect is False and not in_channel: raise UnauthorizedConnect elif channel.permissions_for(server.me).speak is False: raise UnauthorizedSpeak elif is_full and not is_admin and not in_channel: raise ChannelUserLimit else: return True return False async def queue_manager(self, sid): """This function assumes that there's something in the queue for us to play""" server = self.bot.get_server(sid) if self.get_server_settings(server)["NOTIFY"] is True: notify_channel = self.settings["SERVERS"][server.id]["NOTIFY_CHANNEL"] if self.get_server_settings(server)["NOTIFY"] is False: notify_channel = None max_length = self.settings["MAX_LENGTH"] # This is a reference, or should be at least temp_queue = self.queue[server.id][QueueKey.TEMP_QUEUE] queue = self.queue[server.id][QueueKey.QUEUE] repeat = self.queue[server.id][QueueKey.REPEAT] last_song = self.queue[server.id][QueueKey.NOW_PLAYING] last_song_channel = self.queue[server.id][QueueKey.NOW_PLAYING_CHANNEL] assert temp_queue is self.queue[server.id][QueueKey.TEMP_QUEUE] assert queue is self.queue[server.id][QueueKey.QUEUE] # _play handles creating the voice_client and player for us if not self.is_playing(server): log.debug("not playing anything on sid {}".format(server.id) + ", attempting to start a new song.") self.skip_votes[server.id] = [] # Reset skip votes for each new song if len(temp_queue) > 0: # Fake queue for irdumb's temp playlist songs log.debug("calling _play because temp_queue is non-empty") try: queued_song = temp_queue.popleft() url = queued_song.url channel = queued_song.channel song = await self._play(sid, url, channel) await self.display_now_playing(server, song, notify_channel) except MaximumLength: return elif len(queue) > 0: # We're in the normal queue queued_song = queue.popleft() url = queued_song.url channel = queued_song.channel log.debug("calling _play on the normal queue") try: song = await self._play(sid, url, channel) await self.display_now_playing(server, song, notify_channel) except MaximumLength: return if repeat and last_song: queued_last_song = QueuedSong(last_song.webpage_url, last_song_channel) queue.append(queued_last_song) else: song = None self._set_queue_nowplaying(server, song, channel) log.debug("set now_playing for sid {}".format(server.id)) self.bot.loop.create_task(self._update_bot_status()) elif server.id in self.downloaders: # We're playing but we might be able to download a new song curr_dl = self.downloaders.get(server.id) if len(temp_queue) > 0: queued_next_song = temp_queue.peekleft() next_url = queued_next_song.url next_channel = queued_next_song.channel next_dl = Downloader(next_url, max_length) elif len(queue) > 0: queued_next_song = queue.peekleft() next_url = queued_next_song.url next_channel = queued_next_song.channel next_dl = Downloader(next_url, max_length) else: next_dl = None if next_dl is not None: try: # Download next song next_dl.start() await self._download_next(server, curr_dl, next_dl) except YouTubeDlError as e: if len(temp_queue) > 0: temp_queue.popleft() elif len(queue) > 0: queue.popleft() clean_url = self._clean_url(next_url) message = ("I'm unable to play '{}' because of an " "error:\n'{}'".format(clean_url, str(e))) message = escape(message, mass_mentions=True) await self.bot.send_message(next_channel, message) async def display_now_playing(self, server, song, notify_channel:int): channel = discord.utils.get(server.channels, id=notify_channel) if channel is None: return if song.title is None: return def to_delete(m): if "Now Playing" in m.content and m.author == self.bot.user: return True else: return False try: await self.bot.purge_from(channel, limit=50, check=to_delete) except discord.errors.Forbidden: await self.bot.say("I need permissions to manage messages in this channel.") if song: if not hasattr(song, 'creator'): song.creator = None if not hasattr(song, 'uploader'): song.uploader = None if song.rating is None: song.rating = 0 if song.thumbnail is None: song.thumbnail = (self.bot.user.avatar_url).replace('webp', 'png') msg = ("**Author:** `{}`\n**Uploader:** `{}`\n" "**Duration:** `{}`\n**Rating: **`{:.2f}`\n**Views:** `{}`".format( song.creator, song.uploader, str(datetime.timedelta(seconds=song.duration)), song.rating, song.view_count)) colour = ''.join([choice('0123456789ABCDEF') for x in range(6)]) em = discord.Embed(description="", colour=int(colour, 16)) if 'http' not in song.webpage_url: em.set_author(name=song.title) else: em.set_author(name=song.title, url=song.webpage_url) em.set_thumbnail(url=song.thumbnail) em.description = msg.replace('None', '-') await self.bot.send_message(channel, "**Now Playing:**", embed=em) async def queue_scheduler(self): while self == self.bot.get_cog('Audio'): tasks = [] queue = copy.deepcopy(self.queue) for sid in queue: if len(queue[sid][QueueKey.QUEUE]) == 0 and \ len(queue[sid][QueueKey.TEMP_QUEUE]) == 0: continue # log.debug("scheduler found a non-empty queue" # " for sid: {}".format(sid)) tasks.append( self.bot.loop.create_task(self.queue_manager(sid))) completed = [t.done() for t in tasks] while not all(completed): completed = [t.done() for t in tasks] await asyncio.sleep(0.5) await asyncio.sleep(1) async def reload_monitor(self): while self == self.bot.get_cog('Audio'): await asyncio.sleep(0.5) for vc in self.bot.voice_clients: try: vc.audio_player.stop() except: pass def save_settings(self): dataIO.save_json('data/audio/settings.json', self.settings) def set_server_setting(self, server, key, value): if server.id not in self.settings["SERVERS"]: self.settings["SERVERS"][server.id] = {} self.settings["SERVERS"][server.id][key] = value def voice_client(self, server): return self.bot.voice_client_in(server) def voice_connected(self, server): if self.bot.is_voice_connected(server): return True return False async def voice_state_update(self, before, after): server = after.server # Member objects if after.voice_channel != before.voice_channel: try: self.skip_votes[server.id].remove(after.id) except (ValueError, KeyError): pass # Either the server ID or member ID already isn't in there if after is None: return if server.id not in self.queue: return if after != server.me: return # Member is the bot if before.voice_channel != after.voice_channel: self._set_queue_channel(after.server, after.voice_channel) if before.mute != after.mute: vc = self.voice_client(server) if after.mute and vc.audio_player.is_playing(): log.debug("Just got muted, pausing") vc.audio_player.pause() elif not after.mute and \ (not vc.audio_player.is_playing() and not vc.audio_player.is_done()): log.debug("just got unmuted, resuming") vc.audio_player.resume() def __unload(self): for vc in self.bot.voice_clients: self.bot.loop.create_task(vc.disconnect()) def check_folders(): folders = ("data/audio", "data/audio/cache", "data/audio/playlists", "data/audio/localtracks", "data/audio/sfx") for folder in folders: if not os.path.exists(folder): print("Creating " + folder + " folder...") os.makedirs(folder) def check_files(): default = {"VOLUME": 50, "MAX_LENGTH": 3700, "VOTE_ENABLED": True, "MAX_CACHE": 0, "SOUNDCLOUD_CLIENT_ID": None, "TITLE_STATUS": True, "AVCONV": False, "VOTE_THRESHOLD": 50, "SERVERS": {}} settings_path = "data/audio/settings.json" if not os.path.isfile(settings_path): print("Creating default audio settings.json...") dataIO.save_json(settings_path, default) else: # consistency check try: current = dataIO.load_json(settings_path) except JSONDecodeError: # settings.json keeps getting corrupted for unknown reasons. Let's # try to keep it from making the cog load fail. dataIO.save_json(settings_path, default) current = dataIO.load_json(settings_path) if current.keys() != default.keys(): for key in default.keys(): if key not in current.keys(): current[key] = default[key] print( "Adding " + str(key) + " field to audio settings.json") dataIO.save_json(settings_path, current) def verify_ffmpeg_avconv(): try: subprocess.call(["ffmpeg", "-version"], stdout=subprocess.DEVNULL) except FileNotFoundError: pass else: return "ffmpeg" try: subprocess.call(["avconv", "-version"], stdout=subprocess.DEVNULL) except FileNotFoundError: return False else: return "avconv" def setup(bot): check_folders() check_files() if youtube_dl is None: raise RuntimeError("You need to run `pip3 install youtube_dl`") if opus is False: raise RuntimeError( "Your opus library's bitness must match your python installation's" " bitness. They both must be either 32bit or 64bit.") elif opus is None: raise RuntimeError( "You need to install ffmpeg and opus. See \"https://github.com/" "Twentysix26/Red-DiscordBot/wiki/Requirements\"") player = verify_ffmpeg_avconv() if not player: if os.name == "nt": msg = "ffmpeg isn't installed" else: msg = "Neither ffmpeg nor avconv are installed" raise RuntimeError( "{}.\nConsult the guide for your operating system " "and do ALL the steps in order.\n" "https://twentysix26.github.io/Red-Docs/\n" "".format(msg)) n = Audio(bot, player=player) # Praise 26 bot.add_cog(n) bot.add_listener(n.voice_state_update, 'on_voice_state_update') bot.loop.create_task(n.queue_scheduler()) bot.loop.create_task(n.disconnect_timer()) bot.loop.create_task(n.reload_monitor()) bot.loop.create_task(n.cache_scheduler())