async def create_from_search(cls, search, loop=None): """Create a YTDLSource object using a search term. Args: search (str): The URL or search term to use to find audio. loop (async Event Loop, optional): The loop to run the search on. """ loop = loop or asyncio.get_event_loop() for _ in range(YTDL_RETRIES): try: info = await loop.run_in_executor( None, lambda: YTDL.extract_info(search, download=False)) break except UnsupportedError: raise BananaCrime( "I cannot stream audio from that website; see " "https://rg3.github.io/youtube-dl/supportedsites.html " "for a list of supported sites") except DownloadError: LOGGER.warn('Youtube had an issue extracting video info; ' 'retrying in 3 seconds...') await asyncio.sleep(3) else: LOGGER.warn('Ran out of retries; giving up') raise BananaCrime( 'After multiple attempts, I was unable to find a video ' 'using what you gave me, so either you gave me a wonky ' 'search term, the video I found required payment, or ' 'YoutubeDL is temporarily unhappy') # If it found multiple possibilities, just grab the 1st one if 'entries' in info: info = info['entries'][0] return cls(discord.FFmpegPCMAudio(info['url'], **FFMPEG_OPTS), info)
async def play(self, ctx, *, desire: str = None): """Play a video's audio; give me a URL or a search term. Keyword/phrase searches are performed across YouTube. However, URLs can come from any of these sites: https://rg3.github.io/youtube-dl/supportedsites.html """ if not desire: raise BananaCrime('Give me a search term') gvr = self.bot.guild_records[ctx.guild.id] if gvr.searching_ytdl: raise BananaCrime("I'm already trying to find a video") async with ctx.typing(): gvr.searching_ytdl = True try: ytdl_src = await YTDLSource.create_from_search( desire, self.bot.loop) async with gvr.lock: vclient = await self.prepare_to_play(ctx) vclient.play(ytdl_src, after=partial(ffmpeg_error_catcher, self.bot.loop, ctx.channel)) finally: gvr.searching_ytdl = False await ctx.send(f"*Now playing:*\n{ytdl_src}")
async def sbrequest(self, ctx, url, start_time=None, end_time=None): """Put in a request to add a sound to the soundboard. URL should link to the corresponding video. Can optionally provide start/end timestamps in the format hh:mm:ss, which would greatly be appreciated so I know what you're looking for, especially if the video is longer than a few seconds. If you give me anything invalid, I will know who you are and will publicly shame you accordingly. """ if 'dQw4w9WgXcQ' in url: raise BananaCrime( 'Did you really just try to rick roll me? In 2020? Come on') if os.stat(self.sb_request_filename).st_size \ > self.sb_request_file_max_size_b: await ctx.send("My request queue is too full; try again later.") return if len(url) > 100: raise BananaCrime('URLs cannot be longer than 100 characters') if (start_time and len(start_time) > 10) \ or (end_time and len(end_time) > 10): raise BananaCrime('Timestamps cannot be longer than 10 characters') with open(self.sb_request_filename, 'a') as f: f.write(f"[{datetime.now()}] {ctx.message.author} requests {url}") if start_time: f.write(f' from {start_time}') if end_time: f.write(f' to {end_time}') f.write('\n') LOGGER.info('Recorded a soundboard request.') await ctx.send('Request made.')
async def pause(self, ctx): """Pause song playback.""" vclient = ctx.voice_client if not vclient or not vclient.is_playing(): raise BananaCrime("I'm not playing anything") if type(vclient.source) != YTDLSource: raise BananaCrime("You can't pause the soundboard") vclient.pause()
async def stop(self, ctx): """Stop all playback but stays in channel.""" vclient = ctx.voice_client if not vclient: raise BananaCrime("I'm not in a voice channel") if vclient.is_playing() or vclient.is_paused(): vclient.stop() else: raise BananaCrime("I'm not playing anything")
async def resume(self, ctx): """Resume playing a song.""" vclient = ctx.voice_client if not vclient or not vclient.is_connected(): raise BananaCrime("I'm not in a voice channel") if type(vclient.source) != YTDLSource: raise BananaCrime("You can't pause/unpause the soundboard") if not vclient.is_paused(): raise BananaCrime("I'm not paused") vclient.resume()
async def playing(self, ctx): """Get the information of the currently playing song, if any.""" vclient = ctx.voice_client if not vclient or not vclient.is_connected(): raise BananaCrime("I'm not even in a voice channel") if (vclient.is_playing() or vclient.is_paused()) \ and type(vclient.source) == YTDLSource: await ctx.send(f"*Currently playing:*\n{vclient.source}") else: raise BananaCrime("I'm not playing any songs at the moment")
async def join(self, ctx, channel: VoiceChannel = None): """Join the given voice channel.""" if not channel: raise BananaCrime('I need a channel to join') if not channel.permissions_for(ctx.guild.me).connect: raise BananaCrime( "I don't have permission to join that voice channel") vclient = ctx.voice_client async with self.bot.guild_records[ctx.guild.id].lock: if vclient and vclient.is_connected(): if vclient.channel == channel: raise BananaCrime("I'm already in this channel") await vclient.move_to(channel) else: await channel.connect()
async def choose(ctx, *choices): """Pick a random option from a given list of choices.""" if not len(choices): raise BananaCrime('I need a list of choices') elif len(choices) == 1: ctx.send(f'`{choices[0]}`, you sneaky rapscallion.') else: entry_num = random.randint(0, len(choices) - 1) await ctx.send(f'`{choices[entry_num]}` all the way.' if entry_num % 2 else f"I'm feelin `{choices[entry_num]}`.")
async def _summon(self, ctx): """Helper that attempts to join the VC of the caller. NOTE: Does not perform any locking. """ if not ctx.author.voice: raise BananaCrime('You are not in a voice channel') target_channel = ctx.author.voice.channel if not target_channel.permissions_for(ctx.guild.me).connect: raise BananaCrime( "I don't have permission to join your voice channel") vclient = ctx.voice_client if vclient and vclient.is_connected(): if vclient.channel == target_channel: raise BananaCrime("I'm already in this channel") await vclient.move_to(target_channel) else: await target_channel.connect()
async def play_sound(self, ctx, category, sound): """Helper that plays a given sound belonging to a given category.""" guild_lock = self.bot.guild_records[ctx.guild.id].lock if guild_lock.locked(): raise BananaCrime("I'm already trying to process a VC command") async with guild_lock: vclient = await self.prepare_to_play(ctx, False) path = f'{SOUND_DIR}/{category}/{sound}.mp3' vclient.play( discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(path), 0.5))
async def volume(self, ctx, vol: int = None): """Get or set the volume of whatever is currently playing. Takes a percentage as an integer, if any. """ vclient = ctx.voice_client if not vclient or not vclient.is_connected(): raise BananaCrime("I'm not even in a voice channel") if not vclient.is_playing(): raise BananaCrime("I'm not playing anything") if vol is None: await ctx.send( f'Volume is currently at {int(vclient.source.volume * 100)}%.') return if not vol >= 0 or not vol <= 100: raise BananaCrime("That's not a valid integer percentage (0-100)") vclient.source.volume = vol / 100 await ctx.send(f"Volume set to {vol}%.")
async def roll(ctx, desire=None): """Handle a dice roll in the form (A)dB(+C-D...). Examples: * `d6` rolls a d6 * `2d4` rolls 2 d4's and adds them * `4d20+4+2-1` rolls 4 d20's, adds them, then adds 5 """ if not desire: raise BananaCrime("Standard form using integers, d, +, and - " "(i.e. `q.roll 2d4+2-1` rolls 2 d4's and adds 1)") desire = desire.lower() if 'd' not in desire: raise BananaCrime('A d is required (i.e. `2d6`)') count_raw, modifiers = desire.split('d', 1) if count_raw and not count_raw.isdigit(): raise BananaCrime('Invalid number of times to roll') count = int(count_raw) if count_raw else 1 curr_num = '' curr_sum = 0 for c in reversed(modifiers): if c.isdigit(): curr_num = c + curr_num elif c == '+' and curr_num: curr_sum += int(curr_num) curr_num = '' elif c == '-' and curr_num: curr_sum -= int(curr_num) curr_num = '' else: raise BananaCrime('Invalid format to the right of the `d`') if not curr_num: raise BananaCrime('Invalid format to the right of the `d`') rolls = [random.randint(1, int(curr_num)) for _ in range(count)] str_rolls = " + ".join(str(roll) for roll in rolls) result = sum(rolls) + curr_sum await ctx.send(f'`{desire}` gave {str_rolls} *+ {curr_sum}* = **{result}**' )
async def saylang(self, ctx, lang=None, *, desire=None): """Use gTTS to play a text-to-speech message using a given language.""" if not lang: await ctx.send('Available languages:\n' + '\n'.join( [f'{v}: `{k}`' for k, v in self.valid_tts_langs.items()])) return if not desire: raise BananaCrime('Give me text to speak') async with self.bot.guild_records[ctx.guild.id].lock: vclient = await self.prepare_to_play(ctx) try: tts = gTTS(desire, lang=lang) except ValueError: raise BananaCrime('Invalid language') tts.save(GTTS_TEMP_FILE) vclient.play( discord.PCMVolumeTransformer( discord.FFmpegPCMAudio( # Suppress bitrate estimation warning GTTS_TEMP_FILE, options='-loglevel error'), 1))
async def prepare_to_play(self, ctx, require_silence=True): """Prepare to play an AudioSource. Specifically, stop playback or complain if something's already playing depending on the require_silence parameter, and if I'm not in a VC, attempt to join that of the caller. NOTE: Does not perform any locking. """ vclient = ctx.voice_client if not vclient or not vclient.is_connected(): await self._summon(ctx) return ctx.voice_client if vclient.is_playing(): if require_silence: raise BananaCrime("I'm already playing something") else: vclient.stop() elif vclient.is_paused(): vclient.stop() return vclient
async def leave(self, ctx): """Leave the current voice channel.""" vclient = ctx.voice_client if not vclient: raise BananaCrime("I'm not in a voice channel") guild_lock = self.bot.guild_records[ctx.guild.id].lock # If I was called at the same time as I'm processing a command, # when I eventually get my turn to execute, I need to wait a moment # before leaving, otherwise the library will be confused # In addition, passing this condition is a sneaky/mean thing to do, so # I'll also be sassy should_wait = guild_lock.locked() async with guild_lock: if should_wait: await asyncio.sleep(0.1) await ctx.send( "You people need to make up your minds; do you want me to " "play something or not? :rolling_eyes:") ctx.voice_client.stop() await ctx.voice_client.disconnect() self.bot.guild_records[ctx.guild.id].mark_inactive()
async def sb(self, ctx, *desires): """Play one or more given sounds from the soundboard. Call this command with no argument to see available sounds. """ if not desires: await ctx.send('Available categories: ' f'{", ".join(self.category_to_sounds)}, new, all') return tgt_paths = [] to_send = [] for desire in desires: if desire == 'all': # Use split send since this guy can easily grow above 2k chars await split_send( ctx, 'All available sounds:\n' + '\n'.join([ '\n'.join(sounds) for sounds in self.category_to_sounds.values() ])) return elif desire == 'new': await ctx.send('Most recently added sounds:\n' + '\n'.join(self.newest_sounds)) return # If they searched a category elif desire in self.category_to_sounds: await ctx.send(f'Category {desire}: ' f'{", ".join(self.category_to_sounds[desire])}') return elif desire == 'random': category, sounds = random.choice( list(self.category_to_sounds.items())) sound = random.choice(sounds) to_send.append(f"You just heard `{sound}`.") # If the sound exists elif desire in self.sound_to_category: sound = desire category = self.sound_to_category[sound] # See if we can guess what they meant else: # Always go with edit distance before substring matching sound = min(self.sound_to_category, key=partial(editdistance.eval, desire)) # Ensure it is within the minimum edit distance # If no luck with edit distance, go with substring matching if editdistance.eval(desire, sound) > MIN_EDIT_DIST: substring_matches = [ sound for sound in self.sound_to_category if desire in sound or sound in desire ] if substring_matches: sound = min( substring_matches, key=lambda p, l=len(desire): abs(len(p) - l)) # If I still didn't find anything, give up else: continue # Otherwise, go for it category = self.sound_to_category.get(sound) to_send.append(f"`{desire}` isn't a valid sound, " f"so I'll play `{sound}` instead.") tgt_paths.append(f'{SOUND_DIR}/{category}/{sound}.mp3') if not tgt_paths: raise BananaCrime( 'Invalid category/sound name(s); ' f'try `{self.bot.command_prefix}sb` with no arguments') record = self.bot.guild_records[ctx.guild.id] # TODO run this in other places too, like maybe explicitly on shutdown # or, if you were a cool kid, on leave, stop, blah blah blah # ok especially because calling leave raises an exception in the thread lol # BUT HEY, it *seemingly* doesn't break anything # and stop skips the sound (lol) await record.reap_sb_task() if record.lock.locked(): raise BananaCrime("I'm already trying to process a VC command") async with record.lock: vclient = await self.prepare_to_play(ctx, False) if len(tgt_paths) == 1: vclient.play( discord.PCMVolumeTransformer( discord.FFmpegPCMAudio(tgt_paths[0]), 0.5)) else: record.sb_task = asyncio.create_task( schedule_sb_queue(ctx, tgt_paths)) if to_send: await ctx.send('\n'.join(to_send))