music.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. import discord
  2. from discord.ext.commands import Cog, command, Context
  3. from discord.ext.pages import Paginator
  4. import asyncio # used to run async functions within regular functions
  5. import subprocess # for running ffprobe and getting duration of files
  6. from os import getenv, path, makedirs
  7. from time import time # performance tracking
  8. import random # for shuffling the queue
  9. import math # for ceiling function in queue pages
  10. from functools import partial
  11. import logging
  12. if not path.exists('.logs'):
  13. makedirs('.logs')
  14. logger = logging.getLogger(__name__)
  15. logger.setLevel(logging.WARNING)
  16. fh = logging.FileHandler('.logs/music.log')
  17. formatter = logging.Formatter('%(asctime)s | %(name)s | [%(levelname)s] %(message)s', '%Y-%m-%d %H:%M:%S')
  18. fh.setFormatter(formatter)
  19. if not len(logger.handlers):
  20. logger.addHandler(fh)
  21. def setup(bot: discord.Bot):
  22. bot.add_cog(Music(bot))
  23. def format_time(d: int) -> str:
  24. """Convert seconds to timestamp"""
  25. h = d // 3600
  26. m = d % 3600 // 60
  27. s = d % 60
  28. if h:
  29. return '{}:{:02}:{:02}'.format(h,m,s)
  30. return '{}:{:02}'.format(m,s)
  31. def format_date(d: str) -> str:
  32. """Convert YYYYMMDD to YYYY/MM/DD"""
  33. return f"{d[:4]}/{d[4:6]}/{d[6:]}"
  34. class Track:
  35. def __init__(
  36. self,
  37. *,
  38. source: str,
  39. requester: discord.User,
  40. title: str = None,
  41. original_url: str = None,
  42. duration = None,
  43. author: str = None,
  44. author_icon: str = None,
  45. data: dict = None,
  46. ):
  47. self.source = source
  48. self.requester = requester
  49. self.title = title
  50. self.original_url = original_url
  51. self.duration = duration
  52. self.author = author
  53. self.data = data
  54. def __repr__(self):
  55. return f"<Track {self.source=} {self.requester=} {self.title=} {self.duration=}"
  56. def __str__(self):
  57. title = f"**{self.title}**" if self.title else f"`{self.source}`"
  58. duration = f" ({format_time(int(self.duration))})" if self.duration else " (?:??)"
  59. return (
  60. title + duration + f"\nRequested by {self.requester.display_name} ({self.requester})"
  61. if self.requester
  62. else
  63. title + duration + f"\nRequested by ???"
  64. )
  65. class Player(discord.PCMVolumeTransformer):
  66. def __init__(self,
  67. source,
  68. duration,
  69. *,
  70. data = None,
  71. ffmpeg_options = {"options": "-vn"},
  72. ):
  73. super().__init__(discord.FFmpegPCMAudio(source, **ffmpeg_options))
  74. self.packets_read = 0
  75. self.source = source
  76. self.duration = duration
  77. self.data = data
  78. logger.info(f"Player created for {source}")
  79. @classmethod
  80. async def prepare_file(cls, track: Track, *, loop):
  81. loop = loop or asyncio.get_event_loop()
  82. logger.info(f"Preparing player from file: {track.source}")
  83. return cls(track.source, track.duration, data = track.data, ffmpeg_options = {"options": "-vn"})
  84. @classmethod
  85. async def prepare_stream(cls, track: Track, *, loop):
  86. loop = loop or asyncio.get_event_loop()
  87. logger.info(f"Preparing player from stream: {track.source}")
  88. to_run = partial(ytdl.extract_info, url = track.source, download = False)
  89. data = await loop.run_in_executor(None, to_run)
  90. logger.info(f"Stream URL: {data['url']}")
  91. return cls(data['url'], track.duration, data = data, ffmpeg_options = {"options": "-vn", "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"})
  92. def __repr__(self):
  93. return ''.join([f"{key=}\n" for key in self.__dict__ if key != "data"])
  94. def __str__(self):
  95. return (
  96. f"{self.original}\n"
  97. f"{self.progress}"
  98. )
  99. def read(self) -> bytes:
  100. data = self.original.read()
  101. if data:
  102. self.packets_read += 1
  103. return data
  104. @property
  105. def elapsed(self) -> float:
  106. return self.packets_read * 0.02 # each packet is 20ms
  107. @property
  108. def progress(self) -> str:
  109. elapsed = format_time(int(self.elapsed))
  110. duration = format_time(int(self.duration)) if self.duration else "?:??"
  111. return f"{elapsed} / {duration}"
  112. @property
  113. def length(self) -> str:
  114. return format_time(int(self.duration))
  115. class Music(Cog):
  116. """Play audio within a voice channel."""
  117. REPEAT_NONE = 0
  118. REPEAT_ONE = 1
  119. REPEAT_ALL = 2
  120. MAX_RESULTS = 5
  121. PAGE_SIZE = 10
  122. def __init__(self, bot: discord.Bot):
  123. self.bot: discord.Bot = bot
  124. self.q: list[Track] = [] # queue
  125. self.track: Track | None = None
  126. self.repeat_mode = Music.REPEAT_NONE
  127. self.search_results: dict = None
  128. self.i: int = -1 # initial value used for repeat mode
  129. self.h: list[Track] = [] # history
  130. print("Initialized Music cog")
  131. @command(aliases=['start', 'summon', 'connect'])
  132. async def join(self, ctx: Context, *, channel: discord.VoiceChannel = None):
  133. """Joins a voice channel"""
  134. logger.info(f".join {channel}" if channel else ".join")
  135. if not channel: # Upon a raw "join" command without a channel specified,
  136. if not ctx.author.voice:
  137. msg = await ctx.send(
  138. "You must either be in a voice channel, "
  139. "or specify a voice channel in order to use this command"
  140. )
  141. if msg:
  142. logger.info(f"Message sent: no channel specified, and {ctx.author} is not in a voice channel")
  143. return
  144. channel = ctx.author.voice.channel # bind to your current vc channel.
  145. if ctx.voice_client: # If the bot is in a different channel,
  146. await ctx.voice_client.move_to(channel) # move to your channel.
  147. logger.info(f"existing voice client moved to {channel}")
  148. return
  149. voice_client = await channel.connect() # Finally, join the chosen channel.
  150. if voice_client:
  151. logger.info("voice client created")
  152. @command(aliases=['quit', 'dismiss', 'disconnect'])
  153. async def leave(self, ctx: Context):
  154. """Stop+disconnect from voice"""
  155. logger.info(".leave")
  156. if ctx.voice_client:
  157. await ctx.voice_client.disconnect()
  158. logger.info("voice client disconnected")
  159. def get_duration_from_file(self, filename: str):
  160. cmd = subprocess.run(
  161. [
  162. 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of',
  163. 'default=noprint_wrappers=1:nokey=1', filename
  164. ],
  165. stdout=subprocess.PIPE,
  166. stderr=subprocess.STDOUT
  167. )
  168. return float(cmd.stdout)
  169. async def get_tracks_from_query(self, ctx: Context, query: str):
  170. logger.debug(f"get_tracks_from_query() called for query: {query}")
  171. # Detect if the track should be downloaded
  172. download = False
  173. if query.endswith('!dl'):
  174. download = True
  175. query = query[:-3]
  176. elif query.endswith('!download'):
  177. download = True
  178. query = query[:-9]
  179. # Handle attachment playback
  180. if query == "file":
  181. logger.info(f"getting tracks from attachment")
  182. return await self.get_tracks_from_attachments(ctx)
  183. # Handle online playback
  184. elif query.startswith('http'):
  185. logger.info(f"getting tracks from url")
  186. return await self.get_tracks_from_url(ctx, query, download=download)
  187. # Handle local playback
  188. elif tracks := await self.get_tracks_from_path(ctx, query):
  189. logger.info(f"getting tracks from path to local file")
  190. return tracks
  191. # Do a youtube search if not found and no prior search
  192. elif not self.search_results:
  193. logger.info(f"performing a search result")
  194. return await self.search_youtube(ctx, query=query)
  195. # Handle prior search
  196. try:
  197. i = int(query) - 1
  198. except ValueError:
  199. logger.info(f"performing a search result")
  200. return await self.search_youtube(ctx, query=query)
  201. if i not in range(self.MAX_RESULTS + 1):
  202. return await ctx.send(f"Please provide an integer between 1 and {self.MAX_RESULTS}")
  203. url = self.search_results['entries'][i]['url']
  204. self.search_results = []
  205. logger.info(f"handling a prior search")
  206. return await self.get_tracks_from_url(ctx, url)
  207. async def get_tracks_from_url(self, ctx: Context, url: str, download: bool = False):
  208. logger.debug(f"get_tracks_from_url() called for URL: {url}")
  209. try:
  210. data = ytdl.extract_info(url, download=download)
  211. # logger.debug(f"{data=}")
  212. # Detect tabs
  213. if data['extractor'] == 'youtube:tab' and not "entries" in data:
  214. logger.info("youtube:tab detected, no entries in data (so not a playlist)")
  215. data = ytdl.extract_info(data['url'], download=download) # process the playlist url
  216. logger.debug(f"{data=}")
  217. except Exception as e:
  218. logger.error("Exception thrown!")
  219. logger.error(f"{e=}")
  220. return e
  221. # Detect playlists
  222. entries = [data] # Assume that there is only one song.
  223. if "entries" in data: # If we're wrong, just overwrite our singlet list.
  224. entries = data["entries"] # yapf: disable
  225. # Create Track objects
  226. tracks = []
  227. for entry in entries:
  228. source = entry["url"]
  229. original_url = entry.get("original_url")
  230. title = entry.get("title", "(no title)")
  231. duration = None
  232. data = entry
  233. if not "duration" in data and not "duration_string" in data:
  234. logger.info("duration not found in entry's extracted data -- refetching")
  235. logger.debug(f"{data=}")
  236. start = time()
  237. data = ytdl.extract_info(url, download=download)
  238. logger.info(f"Refetching data took {time() - start} seconds")
  239. if "duration" in data:
  240. duration = data["duration"]
  241. elif "duration_string" in data:
  242. d = [int(x) for x in data["duration_string"].split(':')]
  243. if len(d) == 2:
  244. m,s = d
  245. h = 0
  246. elif len(d) == 3:
  247. h,m,s = d
  248. duration = s + 60*m + 3600*h
  249. tracks.append(
  250. Track(
  251. source=source,
  252. original_url=original_url,
  253. requester=ctx.message.author,
  254. title=title,
  255. duration=duration,
  256. data=data
  257. )
  258. )
  259. logger.info(f"Got {len(tracks)} track(s) from URL")
  260. logger.debug(f"{tracks=}")
  261. return tracks
  262. async def get_tracks_from_path(self, ctx: Context, query: str):
  263. """Attempt to load a local file from path"""
  264. logger.debug(f"get_tracks_from_path() called for query: {query}")
  265. if "/.." in query:
  266. return None
  267. filename = f"sounds/normalized/{query}"
  268. try:
  269. player = discord.FFmpegPCMAudio(filename)
  270. except:
  271. return None
  272. if player.read():
  273. logger.info("filename is readable from path")
  274. return [
  275. Track(
  276. source=filename,
  277. requester=ctx.message.author,
  278. title=query,
  279. duration=self.get_duration_from_file(filename)
  280. )
  281. ]
  282. return None
  283. async def get_tracks_from_attachments(self, ctx: Context):
  284. """Fetch the attachment URL and convert it to a track"""
  285. logger.debug(f"get_tracks_from_attachment() called")
  286. attachments = ctx.message.attachments
  287. tracks = []
  288. for attachment in attachments:
  289. try:
  290. track = await self.get_tracks_from_url(ctx, attachment.url, download=False)
  291. tracks += track
  292. except Exception as e:
  293. logger.error("Exception thrown!")
  294. logger.error(f"{e=}")
  295. msg = await ctx.send(
  296. f"An error occurred while adding `{attachment.filename}`:\n"
  297. f"```{e.exc_info[1]}```"
  298. )
  299. if msg:
  300. logger.warning("Message sent: An error occurred while adding `{attachment.filename}`")
  301. return e
  302. logger.debug(f"{tracks=}")
  303. return tracks
  304. @command(name='search')
  305. async def search_youtube(self, ctx: Context, *, query: str):
  306. """Do a YouTube search for the given query"""
  307. logger.debug(f"search_youtube() called for query: {query}")
  308. try:
  309. self.search_results = ytdl.extract_info(f"ytsearch{self.MAX_RESULTS}:{query}", download=False)
  310. except Exception as e:
  311. logger.error("Exception thrown!")
  312. logger.error(f"{e=}")
  313. msg = await ctx.send(
  314. f"An error occurred while searching for `{query}`:\n"
  315. f"```{e.exc_info[1]}```"
  316. )
  317. if msg:
  318. logger.warning(f"Message sent: An error occurred while searching for `{query}`")
  319. return e
  320. await self.results(ctx)
  321. @command()
  322. async def results(self, ctx: Context):
  323. """Show results of a prior search"""
  324. logger.debug(f"results() called")
  325. if not self.search_results:
  326. logger.info("No stored search results")
  327. msg = await ctx.send("There are no stored search results right now.")
  328. if msg:
  329. logger.warning("Message sent: There are no stored search results right now.")
  330. return
  331. embeds = []
  332. formatted_results = (
  333. f"Performed a search for `{self.search_results['id']}`.\n"
  334. "Which track would you like to play?\n"
  335. "Make your choice using the `play` command.\n\n"
  336. )
  337. for i, result in enumerate(self.search_results['entries']):
  338. result: dict
  339. if result['live_status'] == "is_upcoming":
  340. continue # skip YT Premieres
  341. title = result.get('title', '<no title found>')
  342. duration = format_time(int(result.get('duration'))) if ('duration' in result) else '?:??'
  343. uploader = result.get('channel', '<no uploader found>')
  344. views = "{:,}".format(result.get('view_count')) if ('view_count' in result) else '<no view count found>'
  345. image = result['thumbnails'][-1]['url']
  346. height = result['thumbnails'][-1]['height']
  347. width = result['thumbnails'][-1]['width']
  348. url = result['url']
  349. formatted_results += (
  350. f"{i+1}: **{title}** ({duration})\n"
  351. f"{uploader} - {views} views\n"
  352. )
  353. embeds.append(
  354. discord.Embed(
  355. title = title,
  356. url = url,
  357. type = 'image',
  358. colour = 0xff0000,
  359. ).add_field(
  360. name = "Duration",
  361. value = duration,
  362. ).add_field(
  363. name = "Views",
  364. value = views,
  365. ).add_field(
  366. name = "Uploaded by",
  367. value = uploader,
  368. ).set_thumbnail(
  369. url = image,
  370. )
  371. )
  372. msg = await ctx.send(formatted_results, embeds = embeds)
  373. if msg:
  374. logger.info("Message sent: formatted_results")
  375. async def play_next(self, ctx: Context):
  376. logger.debug("play_next() called")
  377. if not ctx.voice_client:
  378. return
  379. if ctx.voice_client.is_playing():
  380. return
  381. logger.info(f"{self.track=}")
  382. if self.repeat_mode == Music.REPEAT_NONE:
  383. if not self.q:
  384. return await ctx.send("Finished playing queue.")
  385. self.track = self.q.pop(0)
  386. logger.info("Repeat none -- popped track from queue")
  387. elif self.repeat_mode == Music.REPEAT_ONE:
  388. self.track = self.track
  389. logger.info("Repeat one -- keeping track the same")
  390. elif self.repeat_mode == Music.REPEAT_ALL:
  391. self.i += 1
  392. if self.i >= len(self.q):
  393. self.i = 0
  394. self.track = self.q[self.i]
  395. logger.info("Repeat all -- advancing pointer without popping track")
  396. logger.info(f"{self.track=}")
  397. if self.track.source.startswith('http'):
  398. # detect private or unplayable videos here
  399. try:
  400. data = ytdl.extract_info(self.track.source, download=False)
  401. logger.debug(f"{data=}")
  402. except Exception as e:
  403. logger.error("Exception thrown!")
  404. logger.error(f"{e=}")
  405. await ctx.send(
  406. f"`{self.track.source}` is unplayable -- skipping...\n"
  407. f"```{e.exc_info[1]}```"
  408. )
  409. logger.warning(f"Skipping as unplayable: {self.track.source}")
  410. return await self.play_next(ctx)
  411. player = await Player.prepare_stream(self.track, loop = self.bot.loop)
  412. else:
  413. player = await Player.prepare_file(self.track, loop = self.bot.loop)
  414. logger.info("playing Player on the voice client")
  415. self.h += [self.track]
  416. ctx.voice_client.play(
  417. player,
  418. after=lambda e: self.after(ctx)
  419. )
  420. def after(self, ctx: Context):
  421. logger.debug("after() called")
  422. if not ctx.voice_client:
  423. logger.info("no voice client -- bot was disconnected from vc?")
  424. self.track = None
  425. self.q = []
  426. asyncio.run_coroutine_threadsafe(
  427. ctx.send(f"Clearing queue after bot left VC"),
  428. self.bot.loop
  429. ).result()
  430. logger.info("Cleared queue after bot left VC")
  431. return
  432. if not self.q and self.repeat_mode == Music.REPEAT_NONE:
  433. logger.info("queue empty and not repeating")
  434. self.track = None
  435. asyncio.run_coroutine_threadsafe(
  436. ctx.send(f"Finished playing queue."),
  437. self.bot.loop
  438. ).result()
  439. logger.info("Finished playing queue.")
  440. return
  441. if self.q and ctx.voice_client and not ctx.voice_client.is_playing():
  442. logger.info("queue exists and voice client is not playing")
  443. logger.debug(f"{self.q=}")
  444. logger.info("playing next...")
  445. asyncio.run_coroutine_threadsafe(
  446. self.play_next(ctx),
  447. self.bot.loop
  448. ).result()
  449. return
  450. def check_for_numbers(self, ctx: Context):
  451. """anti numbers action"""
  452. logger.debug("check_for_numbers() called")
  453. NUMBERS = 187024083471302656
  454. PIZZA = 320294046935547905
  455. RICKY = 949503750651936828
  456. if ctx.author.id in [
  457. NUMBERS,
  458. PIZZA,
  459. ]:
  460. return False
  461. #if ctx.author.id != NUMBERS:
  462. # return False
  463. # if ctx.author.voice:
  464. # members = ctx.author.voice.channel.members
  465. # current_vc_members = {member.id for member in members}
  466. # other_members = current_vc_members - {NUMBERS} - {RICKY}
  467. # return (NUMBERS in current_vc_members) and not other_members
  468. return True
  469. async def add_to_queue(self,
  470. ctx: Context,
  471. query: str,
  472. top: bool = False
  473. ):
  474. logger.debug(f"add_to_queue({query}) called")
  475. # Check for permission to add tracks
  476. # allowed = self.check_for_numbers(ctx)
  477. # if not allowed:
  478. # logger.info(f"{ctx.author} is not allowed to add to queue")
  479. # #return await ctx.send(
  480. # #"You must be in a voice chat by yourself "
  481. # #"in order to use this command."
  482. # #)
  483. # return await ctx.send("No 💜")
  484. # Ensure we are connected to voice
  485. if not ctx.voice_client:
  486. logger.warning("no voice client")
  487. if ctx.author.voice:
  488. logger.info(f"moving voice client to {ctx.author.voice.channel}")
  489. await ctx.author.voice.channel.connect()
  490. else:
  491. msg = await ctx.send(
  492. "You are not connected to a voice channel. "
  493. "Use the `join` command with a specified channel "
  494. "or while connected to a channel before trying to "
  495. "play any tracks."
  496. )
  497. if msg:
  498. logger.info("Message sent: author not in voice, and no voice client exists")
  499. return
  500. # Guard against errors
  501. tracks = await self.get_tracks_from_query(ctx, query)
  502. if isinstance(tracks, Exception):
  503. msg = await ctx.send(
  504. f"An error occurred while trying to add `{query}` to the queue:\n"
  505. f"```{tracks}```"
  506. )
  507. if msg:
  508. logger.warning(f"Message sent: An error occurred while trying to add `{query}` to the queue")
  509. return
  510. if not tracks: # a search was performed instead
  511. return
  512. # Add track(s) to queue
  513. if top:
  514. self.q = tracks + self.q
  515. else:
  516. self.q = self.q + tracks
  517. if ctx.voice_client.is_playing():
  518. if top:
  519. msg = await ctx.send(f"Added **{len(tracks)}** track(s) to top of queue.")
  520. if msg:
  521. logger.info(f"Message sent: Added **{len(tracks)}** track(s) to top of queue.")
  522. return
  523. else:
  524. msg = await ctx.send(f"Added **{len(tracks)}** track(s) to queue.")
  525. if msg:
  526. logger.info(f"Message sent: Added **{len(tracks)}** track(s) to queue.")
  527. return
  528. # If not playing, start playing
  529. if len(self.q) == 1:
  530. msg = await ctx.send(f"Playing **{self.q[0].title}**")
  531. if msg:
  532. logger.info(f"Message sent: Playing **{self.q[0].title}**")
  533. else:
  534. msg = await ctx.send(f"Playing {len(tracks)} tracks.")
  535. if msg:
  536. logger.info(f"Message sent: Playing {len(tracks)} tracks.")
  537. await self.play_next(ctx)
  538. @command(aliases=['p', 'listen'])
  539. async def play(self, ctx: Context, *, query: str = ""):
  540. """Add track(s) to queue"""
  541. if not query:
  542. msg = await ctx.send("No query detected")
  543. if msg:
  544. logger.info("Empty .play command was issued")
  545. return
  546. logger.info(f".play {query}")
  547. return await self.add_to_queue(ctx, query, top=False)
  548. @command(aliases=['ptop', 'top'])
  549. async def playtop(self, ctx: Context, *, query: str = ""):
  550. """Add tracks to top of queue"""
  551. if not query:
  552. msg = await ctx.send("No query detected")
  553. if msg:
  554. logger.info("Empty .playtop command was issued")
  555. return
  556. logger.info(f".playtop {query}")
  557. return await self.add_to_queue(ctx, query, top=True)
  558. # TODO: repeat once, repeat all, repeat none (repeat/loop command)
  559. # TODO: move positions of songs?
  560. # TODO: cleanup command for clearing songs requested by users not in vc?
  561. # TODO: remove duplicates?
  562. # TODO: remove range of songs
  563. # TODO: restart current song
  564. # TODO: seek command? [no fuckin idea how]
  565. # TODO: skip multiple songs?
  566. # TODO: autoplay???? [???????????]
  567. # TODO: filters? bass boost? nightcore? speed? [probs not]
  568. @command(aliases=['q'])
  569. async def queue(self, ctx: Context, p: int = 1):
  570. """Show tracks up next"""
  571. logger.info(f".queue {p}" if p else ".queue")
  572. # check that there is a queue and a current track
  573. if not self.q and not self.track:
  574. msg = await ctx.send("The queue is currently empty.")
  575. if msg:
  576. logger.info("Message sent: The queue is currently empty.")
  577. return
  578. # paginate the queue to just one page
  579. full_queue = [self.track] + self.q
  580. start = self.PAGE_SIZE * (p-1)
  581. end = self.PAGE_SIZE * p
  582. queue_page = full_queue[start:end]
  583. # construct header
  584. formatted_results = f"{len(self.q)} tracks on queue.\n"
  585. total_pages = math.ceil(len(full_queue) / self.PAGE_SIZE)
  586. formatted_results += f"Page {p} of {total_pages}:\n"
  587. # construct page
  588. for i, track in enumerate(queue_page):
  589. if p == 1 and i == 0: # print nowplaying on first queue page
  590. formatted_results += "=== Currently playing ===\n"
  591. formatted_results += (
  592. f"{start+i+1}: {track}\n"
  593. )
  594. if p == 1 and i == 0: # add separator on first page for actually queued tracks
  595. formatted_results += "=== Up next ===\n"
  596. # send text to channel
  597. msg = await ctx.send(formatted_results)
  598. if msg:
  599. logger.info("Message sent: Sent queue page to channel")
  600. @command(aliases=['h'])
  601. async def history(self, ctx: Context, limit: int = 10):
  602. """Show recent actions"""
  603. logger.info(f".history {limit}" if limit else ".history")
  604. if not self.h:
  605. msg = await ctx.send("No available history in this session.")
  606. if msg:
  607. logger.info("Message sent: No available history in this session.")
  608. return
  609. page = self.h[-limit:]
  610. formatted_results = f"Last {len(page)} tracks played:\n"
  611. for i, entry in enumerate(page):
  612. formatted_results += (
  613. f"{i - len(page)}: {entry}\n"
  614. )
  615. msg = await ctx.send(formatted_results)
  616. if msg:
  617. logger.info("Message sent: Sent history page to channel")
  618. @command(aliases=['np'])
  619. async def nowplaying(self, ctx: Context):
  620. """Show currently playing track"""
  621. logger.info(".nowplaying")
  622. if not self.track:
  623. msg = await ctx.send("Nothing is currently playing")
  624. if msg:
  625. logger.info("Nothing is currently playing")
  626. return
  627. if not ctx.voice_client:
  628. msg = await ctx.send("Bot is not currently connected to a voice channel")
  629. if msg:
  630. logger.info("Bot not connected to VC")
  631. return
  632. source: Player = ctx.voice_client.source
  633. embed = discord.Embed(
  634. title=f"{self.track.title}",
  635. url=f"{self.track.original_url}" if self.track.original_url else None,
  636. ).add_field(
  637. name="Progress",
  638. value=f"{source.progress}",
  639. ).set_footer(
  640. text=f"Requested by {self.track.requester.display_name} ({self.track.requester})",
  641. icon_url=f"{self.track.requester.display_avatar.url}",
  642. )
  643. thumb = None
  644. if self.track.data and "thumbnail" in self.track.data:
  645. thumb = self.track.data['thumbnail']
  646. elif self.track.data and "thumbnails" in self.track.data:
  647. thumb = self.track.data['thumbnails'][0]['url']
  648. if thumb:
  649. embed.set_thumbnail(
  650. url=thumb
  651. )
  652. msg = await ctx.send(
  653. f"Now playing:\n{self.track}",
  654. embed = embed
  655. )
  656. if msg:
  657. logger.info(f"Message sent: Now playing: {self.track.title}")
  658. @command()
  659. async def skip(self, ctx: Context):
  660. """Start playing next track"""
  661. logger.info(".skip")
  662. if ctx.voice_client.is_playing():
  663. if self.track:
  664. msg = await ctx.send(f"Skipping: {self.track.title}")
  665. if msg:
  666. logger.info(f"Message sent: Skipping: {self.track.title}")
  667. ctx.voice_client.stop()
  668. @command()
  669. async def remove(self, ctx: Context, i: int):
  670. """Remove track at given position"""
  671. logger.info(f".remove {i}")
  672. i -= 2 # convert to zero-indexing and also the np track is popped
  673. logger.warning(f"trying to pop index {i}")
  674. logger.warning(f"{self.q[i]=}")
  675. track = self.q.pop(i)
  676. msg = await ctx.send(f"Removed: {track.title}")
  677. if msg:
  678. logger.info(f"Message sent: Removed: {track.title}")
  679. @command()
  680. async def pause(self, ctx: Context):
  681. """Pause the currently playing track"""
  682. logger.info(".pause")
  683. if ctx.voice_client.is_playing():
  684. ctx.voice_client.pause()
  685. msg = await ctx.send("Playback is paused.")
  686. if msg:
  687. logger.info("Message sent: Playback is paused.")
  688. @command()
  689. async def resume(self, ctx: Context):
  690. """Resume playback of a paused track"""
  691. logger.info(".resume")
  692. if ctx.voice_client.is_paused():
  693. ctx.voice_client.resume()
  694. msg = await ctx.send("Playback is resumed.")
  695. if msg:
  696. logger.info("Message sent: Playback is resumed.")
  697. @command()
  698. async def shuffle(self, ctx: Context):
  699. """Randomizes the current queue"""
  700. logger.info(".shuffle")
  701. if not self.q:
  702. return await ctx.send("There is no queue to shuffle")
  703. logger.debug(f"{self.track=}")
  704. logger.debug(f"{self.q=}")
  705. random.shuffle(self.q)
  706. logger.debug(f"{self.track=}")
  707. logger.debug(f"{self.q=}")
  708. msg = await ctx.send("Queue has been shuffled")
  709. if msg:
  710. logger.info("Message sent: Queue has been shuffled")
  711. @command()
  712. async def stop(self, ctx: Context):
  713. """Clear queue and stop playing"""
  714. logger.info(".stop")
  715. self.q = []
  716. self.track = None
  717. if ctx.voice_client:
  718. if ctx.voice_client.is_connected():
  719. ctx.voice_client.stop()
  720. msg = await ctx.send("Stopped playing tracks and cleared queue.")
  721. if msg:
  722. logger.info("Message sent: Stopped playing tracks and cleared queue.")
  723. @command()
  724. async def clear(self, ctx: Context):
  725. """Clear queue, but keep playing"""
  726. logger.info(".clear")
  727. self.q = []
  728. msg = await ctx.send("Queue has been cleared.")
  729. if msg:
  730. logger.info("Message sent: Queue has been cleared.")
  731. @command()
  732. async def refresh(self, ctx: Context):
  733. """Reset bot to fresh state"""
  734. logger.info(".refresh")
  735. self.q = []
  736. self.track = []
  737. self.h = []
  738. msg = await ctx.send("Music bot has been refreshed")
  739. if msg:
  740. logger.info("Message sent: Bot has been refreshed")
  741. @command(aliases=['v', 'vol'])
  742. async def volume(self, ctx: Context, volume: int):
  743. """Changes the player's volume"""
  744. logger.info(f".volume {volume}")
  745. if ctx.voice_client is None:
  746. return await ctx.send("Not connected to a voice channel.")
  747. if volume not in range(101):
  748. return await ctx.send(f"Please use an integer from 0 to 100")
  749. ctx.voice_client.source.volume = volume / 100
  750. await ctx.send(f"Changed volume to {volume}%")
  751. @command(aliases=['list'])
  752. async def catalogue(self, ctx: Context, subdirectory: str = ""):
  753. """Shows the available local files"""
  754. logger.info(f".catalogue {subdirectory}" if subdirectory else ".catalogue")
  755. if "../" in subdirectory:
  756. return await ctx.send(f"Nice try, but that won't work.")
  757. path = "."
  758. if subdirectory:
  759. path += f"/{subdirectory}"
  760. cmd = subprocess.run(
  761. f"cd sounds/normalized && find {path} -type f | sort",
  762. shell=True,
  763. stdout=subprocess.PIPE,
  764. stderr=subprocess.STDOUT
  765. )
  766. results = cmd.stdout.decode('utf-8').split('\n')[:-1]
  767. formatted_results = "```"
  768. for result in results:
  769. formatted_results += f"{result[2:]}\n"
  770. formatted_results += "```"
  771. await ctx.send(formatted_results)
  772. """
  773. Initialize youtube-dl service.
  774. """
  775. import yt_dlp as youtube_dl
  776. youtube_dl.utils.bug_reports_message = lambda: ""
  777. ytdl_format_options = {
  778. "format": "bestaudio/best",
  779. "outtmpl": ".cache/%(extractor)s-%(id)s-%(title)s.%(ext)s",
  780. "restrictfilenames": True,
  781. "noplaylist": True,
  782. "nocheckcertificate": True,
  783. "ignoreerrors": False,
  784. "logtostderr": False,
  785. "quiet": True,
  786. "no_warnings": True,
  787. "default_search": "auto",
  788. # "source_address": "0.0.0.0", # Bind to ipv4 since ipv6 addresses cause issues
  789. "extract_flat": True, # massive speedup for fetching metadata, at the cost of no upload date
  790. }
  791. username = getenv("YOUTUBE_USERNAME")
  792. password = getenv("YOUTUBE_PASSWORD")
  793. ytdl = youtube_dl.YoutubeDL(ytdl_format_options)