diff --git a/.dockerignore b/.dockerignore index fbebab9..3dd324c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ ** !src/ !*cookies.txt -!requirements.txt \ No newline at end of file +!requirements.txt diff --git a/.env.template b/.env.template index 525cbe4..170fe90 100644 --- a/.env.template +++ b/.env.template @@ -2,4 +2,4 @@ DISCORD_BOT_TOKEN= DISCORD_CMD_PREFIX= DISCORD_LOG_CHANNEL_ID= DISCORD_LOG_CHANNEL_CLEAR_ON_STARTUP=False -YOUTUBE_COOKIES=youtube.com_cookies.txt \ No newline at end of file +YOUTUBE_COOKIES=youtube.com_cookies.txt diff --git a/.gitignore b/.gitignore index 4801fc7..9c0fcf3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ __pycache__ .env docker.env .coverage -*cookies.txt \ No newline at end of file +*cookies.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cbb136a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: double-quote-string-fixer + - repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v1.6.0 + hooks: + - id: autopep8 + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort diff --git a/.pylintrc b/.pylintrc index ff49dca..525ab3d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -21,4 +21,4 @@ indent-string=' ' max-line-length=160 [SIMILARITIES] -ignore-comments = no \ No newline at end of file +ignore-comments = no diff --git a/pyproject.toml b/pyproject.toml index c4d966d..7c164d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ [tool.autopep8] indent-size=2 max-line-length=160 -ignore = ["E402"] \ No newline at end of file +ignore = ["E402"] diff --git a/requirements.txt b/requirements.txt index a667e18..efbfe35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,19 +4,25 @@ async-timeout==3.0.1 attrs==21.2.0 autopep8==1.5.7 cffi==1.14.6 +cfgv==3.3.1 chardet==4.0.0 coverage==5.5 discord==1.7.3 discord.py==1.7.3 +distlib==0.3.4 +filelock==3.6.0 +identify==2.4.12 idna==3.2 iniconfig==1.1.1 isort==5.9.3 lazy-object-proxy==1.6.0 mccabe==0.6.1 multidict==5.1.0 +nodeenv==1.6.0 packaging==21.0 platformdirs==2.3.0 pluggy==1.0.0 +pre-commit==2.18.1 py==1.10.0 pycodestyle==2.7.0 pycparser==2.20 @@ -28,9 +34,11 @@ pytest-asyncio==0.16.0 pytest-cov==2.12.1 pytest-mock==3.6.1 python-dotenv==0.19.0 +PyYAML==6.0 six==1.16.0 toml==0.10.2 typing-extensions==3.10.0.2 +virtualenv==20.14.1 wrapt==1.12.1 yarl==1.6.3 youtube-dl==2021.12.17 diff --git a/src/bot.py b/src/bot.py index ac22c24..c2307e8 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,18 +1,18 @@ +import asyncio +import logging import os import sys -import asyncio from dataclasses import dataclass from distutils.util import strtobool -import logging import discord +import youtube_dl from discord.ext import commands, tasks from dotenv import load_dotenv -import youtube_dl from error import ErrorHandler -from message import NowPlayingMessage, QueuedMessage, ErrorMessage -from log import create_logger, DiscordLogger +from log import DiscordLogger, create_logger +from message import ErrorMessage, NowPlayingMessage, QueuedMessage load_dotenv() @@ -55,10 +55,10 @@ class Music(commands.Cog): # pylint: disable=no-member self._handle_playback.start() - self.logger = logger if logger else create_logger("bot") - if log_channel_id := os.environ.get("DISCORD_LOG_CHANNEL_ID", None): - clear_on_startup = os.environ.get("DISCORD_LOG_CHANNEL_CLEAR_ON_STARTUP", False) - if clear_on_startup == "": + self.logger = logger if logger else create_logger('bot') + if log_channel_id := os.environ.get('DISCORD_LOG_CHANNEL_ID', None): + clear_on_startup = os.environ.get('DISCORD_LOG_CHANNEL_CLEAR_ON_STARTUP', False) + if clear_on_startup == '': clear_on_startup = False if isinstance(clear_on_startup, str): clear_on_startup = bool(strtobool(clear_on_startup)) @@ -70,7 +70,7 @@ class Music(commands.Cog): def _next(self): """Trigger playback of next song.""" - self.logger.info("Song finished. Triggering playback of next song") + self.logger.info('Song finished. Triggering playback of next song') self._queue.task_done() self._current_skip_message = None if self._queue_lock.locked(): @@ -80,11 +80,11 @@ class Music(commands.Cog): async def _handle_playback(self): while True: try: - self.logger.info("Waiting for queue lock to acquire ...") + self.logger.info('Waiting for queue lock to acquire ...') await self._queue_lock.acquire() - self.logger.info("Queue lock acquired! Waiting for song queue to return a song ...") + self.logger.info('Queue lock acquired! Waiting for song queue to return a song ...') ctx, song = await self._queue.get() - self.logger.info("Queue returned a song!") + self.logger.info('Queue returned a song!') if ctx.voice_client is None: # Bot is no longer in a voice channel. # This could be the case because a stop command was issued. @@ -96,7 +96,7 @@ class Music(commands.Cog): def after(err): if err: - self.logger.error("Player error: %s", err) + self.logger.error('Player error: %s', err) self._next() self.logger.info('Now playing song "%s"', song.title) ctx.voice_client.play(audio, after=after) @@ -105,14 +105,14 @@ class Music(commands.Cog): await self._add_skip_button(msg) # pylint: disable=broad-except except Exception as err: - self.logger.error("Error during playback: %s", err) + self.logger.error('Error during playback: %s', err) if ctx: embed = ErrorMessage(str(err)) await ctx.send(embed=embed) self._next() async def _add_skip_button(self, msg): - await msg.add_reaction("⏭️") + await msg.add_reaction('⏭️') self._current_skip_message = msg @commands.Cog.listener() @@ -120,7 +120,7 @@ class Music(commands.Cog): if not user.bot \ and self._current_skip_message \ and reaction.message.id == self._current_skip_message.id \ - and reaction.emoji == "⏭️": + and reaction.emoji == '⏭️': voice_client = reaction.message.guild.voice_client self._skip(voice_client) @@ -148,10 +148,10 @@ class Music(commands.Cog): def _skip(self, voice_client): """Skip to next song.""" if voice_client is None or not voice_client.is_playing(): - raise commands.CommandError("No song playing") + raise commands.CommandError('No song playing') # This skips to next song because the bot does not differentiate between # a song stopping because it is finished or because it was manually stopped. - self.logger.info("Skipping song") + self.logger.info('Skipping song') voice_client.stop() @commands.command() @@ -161,37 +161,37 @@ class Music(commands.Cog): @commands.command() async def stop(self, ctx): - self.logger.info("Stopping playback") + self.logger.info('Stopping playback') await ctx.voice_client.disconnect() @play.before_invoke async def ensure_voice(self, ctx): if ctx.voice_client is None: if ctx.author.voice: - self.logger.info("Connecting to voice channel ...") + self.logger.info('Connecting to voice channel ...') await ctx.author.voice.channel.connect() - self.logger.info("Connected") + self.logger.info('Connected') else: - raise commands.CommandError("Author not connected to a voice channel") + raise commands.CommandError('Author not connected to a voice channel') -if __name__ == "__main__": - prefix = os.environ.get("DISCORD_CMD_PREFIX", "!") - logger = create_logger("bot") +if __name__ == '__main__': + prefix = os.environ.get('DISCORD_CMD_PREFIX', '!') + logger = create_logger('bot') bot = commands.Bot(command_prefix=commands.when_mentioned_or(prefix), description='Relatively simple music bot example') @bot.event async def on_ready(): - logger.info("Logged in as %s (%s)", bot.user, bot.user.id) + logger.info('Logged in as %s (%s)', bot.user, bot.user.id) logger.info('------') bot.add_cog(Music(bot, logger=logger)) bot.add_cog(ErrorHandler(bot, logger=logger)) - token = os.environ.get("DISCORD_BOT_TOKEN", None) + token = os.environ.get('DISCORD_BOT_TOKEN', None) if not token: - logger.warning("Discord bot token not found") + logger.warning('Discord bot token not found') sys.exit(1) bot.run(token) diff --git a/src/error.py b/src/error.py index 17249e8..3764508 100644 --- a/src/error.py +++ b/src/error.py @@ -20,16 +20,16 @@ class ErrorHandler(commands.Cog): self.logger.error(error) return if isinstance(error, commands.MissingPermissions): - message = "You are missing the required permissions to run this command!" + message = 'You are missing the required permissions to run this command!' elif isinstance(error, commands.UserInputError): - message = "Something about your input was wrong, please check your input and try again!" + message = 'Something about your input was wrong, please check your input and try again!' elif isinstance(error, commands.CommandError): message = str(error) else: - message = "Oh no! Something went wrong while running the command!" + message = 'Oh no! Something went wrong while running the command!' ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') - message = ansi_escape.sub("", message) + message = ansi_escape.sub('', message) self.logger.error('Error during command "%s": %s', command_name, message) embed = ErrorMessage(message, command_name=command_name) diff --git a/src/log.py b/src/log.py index 1a94c77..e213a57 100644 --- a/src/log.py +++ b/src/log.py @@ -1,14 +1,13 @@ import asyncio import logging -from logging import LogRecord -import sys import os +import sys from datetime import datetime +from logging import LogRecord from discord import TextChannel from discord.ext.commands import Bot as DiscordBot - __LOG_FORMAT__ = '%(asctime)s %(name)-5s %(levelname)-8s %(message)s' __DATE_FORMAT__ = '%Y-%m-%d %H:%M:%S' @@ -21,8 +20,8 @@ def create_logger(name: str) -> logging.Logger: stream_handler = logging.StreamHandler(stream=sys.stdout) COMMIT = os.getenv('GIT_COMMIT') or 'no-commit' - timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - log_path = f"{COMMIT}_{timestamp}.log" + timestamp = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') + log_path = f'{COMMIT}_{timestamp}.log' file_handler = logging.FileHandler(log_path) diff --git a/src/message.py b/src/message.py index 61908e7..ca8ddf4 100644 --- a/src/message.py +++ b/src/message.py @@ -5,7 +5,7 @@ import discord class BotMessage(discord.Embed): def __init__(self, **kwargs): - title = kwargs.pop("title", None)[:256] + title = kwargs.pop('title', None)[:256] if title is not None: # Max embed title length is 256 title = title[:256] @@ -21,7 +21,7 @@ class ErrorMessage(BotMessage): if command_name: title = f'Error during command "{command_name}"' description = None - if match := re.search(r"(?P\w+Error): ?(ERROR: ?)?(?P.*): ?Traceback", message): + if match := re.search(r'(?P\w+Error): ?(ERROR: ?)?(?P.*): ?Traceback', message): description = f"{match.group('error')}: {match.group('message')}" super().__init__( title=title, @@ -33,7 +33,7 @@ class ErrorMessage(BotMessage): class NowPlayingMessage(BotMessage): def __init__(self, title, url): super().__init__( - title=f"Now playing: {title}", + title=f'Now playing: {title}', description=url, color=discord.Color.green() ) @@ -42,7 +42,7 @@ class NowPlayingMessage(BotMessage): class QueuedMessage(BotMessage): def __init__(self, title, url): super().__init__( - title=f"Queued: {title}", + title=f'Queued: {title}', description=url, color=discord.Color.blue() ) diff --git a/test/conftest.py b/test/conftest.py index 5413a8f..9c85a27 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,9 +1,9 @@ import asyncio +import glob +import os import sys from pathlib import Path from unittest.mock import Mock -import glob -import os import pytest from pytest_mock import MockerFixture @@ -15,7 +15,7 @@ sys.path.insert(0, SRC_PATH) from bot import Music -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope='session', autouse=True) def global_teardown(): yield logs = glob.glob('*.log') @@ -40,7 +40,7 @@ def mbot(bot): @pytest.fixture def ctx(mocker: MockerFixture): ctx_mock = mocker.patch('discord.ext.commands.Context', autospec=True) - ctx_mock.voice_client.stop = lambda: ctx_mock.voice_client.play.call_args.kwargs["after"](None) + ctx_mock.voice_client.stop = lambda: ctx_mock.voice_client.play.call_args.kwargs['after'](None) return ctx_mock diff --git a/test/test_bot.py b/test/test_bot.py index 1bcd7b4..30ba2f7 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -1,5 +1,5 @@ import asyncio -from unittest.mock import Mock, AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from discord.ext import commands @@ -11,7 +11,7 @@ async def test_bot_ensure_voice(mbot, ctx): ctx.voice_client = None ctx.author.voice = AsyncMock() await mbot.ensure_voice(ctx) - assert ctx.author.voice.channel.connect.call_count == 1, "Did not connect to voice channel of author" + assert ctx.author.voice.channel.connect.call_count == 1, 'Did not connect to voice channel of author' ctx.reset_mock(return_value=True) # TEST: Error if author not inside a channel @@ -22,7 +22,7 @@ async def test_bot_ensure_voice(mbot, ctx): def mock_ytdl_extract_info(ytdl, url, title): - ytdl.extract_info.return_value = {"entries": [{"url": url, "title": title}]} + ytdl.extract_info.return_value = {'entries': [{'url': url, 'title': title}]} def mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio): @@ -38,66 +38,66 @@ async def test_bot_playback(mbot, ctx): # TEST: First song queued is immediately played ctx.voice_client.is_playing.return_value = False - url = "https://www.youtube.com/watch?v=Wr9LZ1hAFpQ" - title = "In Flames - Deliver Us (Official Video)" + url = 'https://www.youtube.com/watch?v=Wr9LZ1hAFpQ' + title = 'In Flames - Deliver Us (Official Video)' mock_ytdl_extract_info(ytdl, url, title) deliver_us_audio = mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio) query = 'in flames deliver us' # pylint: disable=too-many-function-args await mbot.play(mbot, ctx, query=query) assert \ - ytdl.extract_info.call_args.args == (query,) and ytdl.extract_info.call_args.kwargs == {"download": False}, \ - f"ytdl.extract_info was not called with {query}, {{ download: False }}" + ytdl.extract_info.call_args.args == (query,) and ytdl.extract_info.call_args.kwargs == {'download': False}, \ + f'ytdl.extract_info was not called with {query}, {{ download: False }}' assert \ ffmpeg_pcm_audio.call_args is None, \ - f"FFmpegPCMAudio was immediately called with {url} instead of being queued" + f'FFmpegPCMAudio was immediately called with {url} instead of being queued' assert \ ctx.voice_client.play.call_args is None, \ - "Did immediately playback audio instead of being queued" + 'Did immediately playback audio instead of being queued' assert \ ctx.send.call_args is None, \ "Did immediately send 'Now playing:' message of being queued" await asyncio.sleep(0) assert \ ffmpeg_pcm_audio.call_args.args == (url,), \ - f"FFmpegPCMAudio was not called with {url}" + f'FFmpegPCMAudio was not called with {url}' assert \ ctx.voice_client.play.call_args.args == (deliver_us_audio,), \ - "Did not playback correct audio" - embed = ctx.send.call_args.kwargs["embed"] - assert embed.title == f"Now playing: {title}", "Did not send 'Now playing:' message" + 'Did not playback correct audio' + embed = ctx.send.call_args.kwargs['embed'] + assert embed.title == f'Now playing: {title}', "Did not send 'Now playing:' message" # TEST: Following songs are put inside a queue ctx.voice_client.is_playing.return_value = True - url = "https://www.youtube.com/watch?v=pMDcYX2wRSg" - title = "Three Days Grace - Time of Dying (lyrics)" + url = 'https://www.youtube.com/watch?v=pMDcYX2wRSg' + title = 'Three Days Grace - Time of Dying (lyrics)' mock_ytdl_extract_info(ytdl, url, title) time_of_dying_audio = mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio) # pylint: disable=too-many-function-args - query = "three days grace time of dying" + query = 'three days grace time of dying' await mbot.play(mbot, ctx, query=query) assert \ - ytdl.extract_info.call_args.args == (query,) and ytdl.extract_info.call_args.kwargs == {"download": False}, \ - f"ytdl.extract_info was not called with {query}, {{ download: False }}" + ytdl.extract_info.call_args.args == (query,) and ytdl.extract_info.call_args.kwargs == {'download': False}, \ + f'ytdl.extract_info was not called with {query}, {{ download: False }}' assert \ not ffmpeg_pcm_audio.call_args.args == (url,), \ - f"FFmpegPCMAudio was immediately called with {url} instead of being queued" + f'FFmpegPCMAudio was immediately called with {url} instead of being queued' assert \ not ctx.voice_client.play.call_args.args == (time_of_dying_audio,), \ - "Did immediately playback audio instead of being queued" - embed = ctx.send.call_args.kwargs["embed"] - assert embed.title == f"Queued: {title}", "Did not send 'Queued:' message" + 'Did immediately playback audio instead of being queued' + embed = ctx.send.call_args.kwargs['embed'] + assert embed.title == f'Queued: {title}', "Did not send 'Queued:' message" await asyncio.sleep(0) # Assert that there is still no playback because previous song is not finished yet assert \ not ffmpeg_pcm_audio.call_args.args == (url,), \ - f"FFmpegPCMAudio was called with {url} before previous song finished" + f'FFmpegPCMAudio was called with {url} before previous song finished' # Execute callback for song finish event - ctx.voice_client.play.call_args.kwargs["after"](None) + ctx.voice_client.play.call_args.kwargs['after'](None) await asyncio.sleep(0) assert \ ctx.voice_client.play.call_args.args == (time_of_dying_audio,), \ - "Did not queue next song" + 'Did not queue next song' @pytest.mark.asyncio @@ -109,8 +109,8 @@ async def test_bot_skip(mbot, ctx): # Queue first song ctx.voice_client.is_playing.return_value = False - url = "https://www.youtube.com/watch?v=Wr9LZ1hAFpQ" - title = "In Flames - Deliver Us (Official Video)" + url = 'https://www.youtube.com/watch?v=Wr9LZ1hAFpQ' + title = 'In Flames - Deliver Us (Official Video)' mock_ytdl_extract_info(ytdl, url, title) deliver_us_audio = mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio) query = 'in flames deliver us' @@ -119,28 +119,28 @@ async def test_bot_skip(mbot, ctx): await asyncio.sleep(0) assert \ ctx.voice_client.play.call_args.args == (deliver_us_audio,), \ - "Did not playback correct audio" + 'Did not playback correct audio' # Queue second song ctx.voice_client.is_playing.return_value = True - url = "https://www.youtube.com/watch?v=pMDcYX2wRSg" - title = "Three Days Grace - Time of Dying (lyrics)" + url = 'https://www.youtube.com/watch?v=pMDcYX2wRSg' + title = 'Three Days Grace - Time of Dying (lyrics)' mock_ytdl_extract_info(ytdl, url, title) time_of_dying_audio = mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio) # pylint: disable=too-many-function-args - query = "three days grace time of dying" + query = 'three days grace time of dying' await mbot.play(mbot, ctx, query=query) await asyncio.sleep(0) assert \ not ctx.voice_client.play.call_args.args == (time_of_dying_audio,), \ - "Did immediately playback audio instead of being queued" + 'Did immediately playback audio instead of being queued' # Now skip first song await mbot.skip(mbot, ctx) await asyncio.sleep(0) assert \ ctx.voice_client.play.call_args.args == (time_of_dying_audio,), \ - "Did not skip song" + 'Did not skip song' # TEST: Error if no song playing ctx.voice_client.is_playing.return_value = False