diff --git a/cogs/errorhandler.py b/cogs/errorhandler.py index 8687d66..3a5ae0f 100644 --- a/cogs/errorhandler.py +++ b/cogs/errorhandler.py @@ -245,6 +245,11 @@ async def on_command_error( case commands.NotOwner() | commands.CheckFailure(): await self.send_warning(ctx, ErrorMessages.not_allowed, error) + case commands.BadUnionArgument(): + await self.send_warning( + ctx, "\n".join(str(e) for e in error.errors), error + ) + case discord.Forbidden(): try: await self.send_error(ctx, str(error), error) diff --git a/cogs/fm.py b/cogs/fm.py index 74817a2..e8db9f6 100644 --- a/cogs/fm.py +++ b/cogs/fm.py @@ -64,7 +64,7 @@ async def convert(self, ctx: MisoContext, argument: str): return Period.OVERALL case _: raise commands.BadArgument( - f"Cannot convert `{argument}` into a timeframe" + f"Cannot convert `{argument}` into a timeframe." ) @@ -83,10 +83,7 @@ async def convert(cls, ctx: MisoContext, argument: str): case "overview" | "ov" | "profile": return cls.OVERVIEW case _: - raise commands.BadArgument(f"No such command `{argument}`") - - -# maybe possible to combine these classes so convert fn in real dataclass + raise commands.BadArgument(f"No such command `{argument}`.") @dataclass @@ -110,11 +107,35 @@ async def convert(self, ctx: MisoContext, argument: str): try: size = ChartSize(*map(lambda n: int(n), argument.split("x"))) except ValueError: - raise commands.BadArgument(f"Cannot convert `{argument}` to size") + raise commands.BadArgument( + f"Cannot convert `{argument}` into a chart size." + ) return size +class ChartOption: + options = [ + "album", + "artist", + "recent", + "notitle", + "recents", + "padded", + "topster", + ] + + async def convert(self, ctx: MisoContext, argument: str): + argument = argument.lower() + if argument not in self.options: + raise commands.BadArgument( + f"Unrecognized option `{argument}`. " + f"Valid options are: {', '.join(f'`{x}`' for x in self.options)}." + ) + + return argument + + class StrOrNp: def extract(self, data: dict): raise NotImplementedError @@ -153,7 +174,7 @@ def parse(self, argument: str): splits = argument.split(" by ") if len(splits) < 2: raise commands.BadArgument( - "Invalid format! Please use ` | `. Example: `one | `metallica" + "Invalid format! Please use ` | `. Example: `one | metallica`." ) return [s.strip() for s in splits] @@ -170,7 +191,7 @@ def parse(self, argument: str): if len(splits) < 2: raise commands.BadArgument( "Invalid format! Please use ` | `. " - "Example: `Master of Puppets | Metallica`" + "Example: `Master of Puppets | Metallica`." ) return [s.strip() for s in splits] @@ -213,7 +234,7 @@ async def voting_enabled(self, ctx: MisoContext, status: bool): ) await util.send_success( ctx, - f"Nowplaying reactions for your messages turned **{'on' if status else 'off'}**", + f"Nowplaying reactions for your messages turned **{'on' if status else 'off'}**.", ) @voting.command(name="upvote") @@ -231,7 +252,7 @@ async def voting_upvote(self, ctx: MisoContext, emoji: str): ctx.author.id, emoji, ) - await util.send_success(ctx, f"Your upvote reaction emoji is now {emoji}") + await util.send_success(ctx, f"Your upvote reaction emoji is now {emoji}.") @voting.command(name="downvote") @util.patrons_only() @@ -248,7 +269,7 @@ async def voting_downvote(self, ctx: commands.Context, emoji: str): ctx.author.id, emoji, ) - await util.send_success(ctx, f"Your downvote reaction emoji is now {emoji}") + await util.send_success(ctx, f"Your downvote reaction emoji is now {emoji}.") @fm.group(name="blacklist") @commands.has_permissions(manage_guild=True) @@ -318,7 +339,7 @@ async def set(self, ctx: MisoContext, username: str): if content is None: raise exceptions.CommandWarning( f"Last.fm profile `{username}` was not found. " - "Make sure you gave the username **without brackets**" + "Make sure you gave the username **without brackets**." ) await self.bot.db.execute( @@ -350,7 +371,9 @@ async def unset(self, ctx: MisoContext): ctx.author.id, None, ) - await ctx.send(":broken_heart: Removed your Last.fm username from the database") + await ctx.send( + ":broken_heart: Removed your Last.fm username from the database." + ) @fm.command() async def profile(self, ctx: MisoContext): @@ -896,35 +919,31 @@ async def album_cover(self, ctx: MisoContext): @fm.command( aliases=["collage"], - usage="['album' | 'artist' | 'recent'] [timeframe] " - "[[width]x[height]] ['notitle' | 'topster'] ['padded']", + usage=( + "[timeframe] [[width]x[height]] " + "['album' | 'artist' | 'recent' | 'notitle' | 'topster' | 'padded']" + ), ) async def chart( self, ctx: MisoContext, *args: Union[ - Literal[ - "album", - "artist", - "recent", - "notitle", - "recents", - "padded", - "topster", - ], Annotated[Period, PeriodArgument], Annotated[ChartSize, ChartSizeArgument], + ChartOption, ], ): """ Collage of your top albums or artists Usage: - >fm chart ['album' | 'artist' | 'recent'] [timeframe] - [[width]x[height]] ['notitle' | 'topster'] ['padded'] + >fm chart [timeframe] [[width]x[height]] + ['album' | 'artist' | 'recent' | 'notitle' | 'topster' | 'padded']" + + Defaults to 3x3 weekly albums chart. Examples: - >fm chart (defaults to 3x3 weekly albums) + >fm chart >fm chart 5x5 month >fm chart artist >fm chart 4x5 year notitle @@ -1192,12 +1211,233 @@ async def server_nowplaying(self, ctx: MisoContext): icon_url=ctx.guild.icon, ) .set_footer( - text=f"{len(rows)} / {len(data)} Members are listening to music" + text=f"{len(rows)} / {len(data)} Members are listening to music right now" ) ) await RowPaginator(content, rows).run(ctx) + @server.command(name="recent", aliases=["re", "recents"]) + async def server_recent(self, ctx: MisoContext): + """What this server has recently listened to""" + data = await self.task_for_each_server_member( + ctx.guild, self.api.user_get_recent_tracks + ) + + if data is None: + return await ctx.send( + "Nobody on this server has connected their Last.fm account yet!" + ) + + rows = [] + for member_data, member in data: + suffix = "" + if member_data["nowplaying"]: + suffix = " :musical_note:" + + artist_name = member_data["artist"]["#text"] + track_name = member_data["name"] + + rows.append( + f"{util.displayname(member)} | **{escape_markdown(artist_name)}** " + f"— ***{escape_markdown(track_name)}***{suffix}" + ) + + if not rows: + return await ctx.send("Nobody on this server has listened to anything!") + + content = ( + discord.Embed(color=int(self.LASTFM_RED, 16)) + .set_author( + name=f"What has {ctx.guild.name} been listening to?", + icon_url=ctx.guild.icon, + ) + .set_footer( + text=f"{len(rows)} / {len(data)} Members are listening to music right now" + ) + ) + + await RowPaginator(content, rows).run(ctx) + + @server.command(name="topartists", aliases=["ta"], usage="[timeframe]") + async def server_topartists( + self, + ctx: MisoContext, + timeframe: Annotated[Period, PeriodArgument] = Period.OVERALL, + ): + """Combined top artists of server members""" + data = await self.task_for_each_server_member( + ctx.guild, self.api.user_get_top_artists, limit=100, period=timeframe + ) + + if data is None: + return await ctx.send( + "Nobody on this server has connected their Last.fm account yet!" + ) + + artist_map = {} + for member_data, member in data: + artists = member_data["artist"] + lowest_playcount = int(artists[-1]["playcount"]) + highest_playcount = int(artists[0]["playcount"]) + for artist in artists: + playcount = int(artist["playcount"]) + score = playcount_mapped( + playcount, + input_start=lowest_playcount, + input_end=highest_playcount, + ) + + name = artist["name"] + + try: + artist_map[name]["score"] += score + artist_map[name]["playcount"] += playcount + except KeyError: + artist_map[name] = {"score": score, "playcount": playcount} + + top_artists = sorted( + artist_map.items(), key=lambda x: x[1]["score"], reverse=True + )[:100] + + rows = [] + for i, (artist, artist_data) in enumerate(top_artists, start=1): + rows.append( + f"`#{i:2}` **{artist_data['score'] / len(data):.2f}%** /" + f" **{artist_data['playcount']}** plays • **{artist}**" + ) + + await self.paginated_user_stat_embed( + ctx, + rows, + f"Top 100 Artists ({timeframe.display()})", + image=await self.api.get_artist_image(top_artists[0][0]), + footer=f"Score calculated from top 100 artists of {len(data)} members", + server_target=True, + ) + + @server.command(name="toptracks", aliases=["tt"], usage="[timeframe]") + async def server_toptracks( + self, + ctx: MisoContext, + timeframe: Annotated[Period, PeriodArgument] = Period.OVERALL, + ): + """Combined top tracks of server members""" + data = await self.task_for_each_server_member( + ctx.guild, self.api.user_get_top_tracks, limit=100, period=timeframe + ) + + if data is None: + return await ctx.send( + "Nobody on this server has connected their Last.fm account yet!" + ) + + track_map = {} + for member_data, member in data: + tracks = member_data["track"] + lowest_playcount = int(tracks[-1]["playcount"]) + highest_playcount = int(tracks[0]["playcount"]) + for track in tracks: + playcount = int(track["playcount"]) + score = playcount_mapped( + playcount, + input_start=lowest_playcount, + input_end=highest_playcount, + ) + + name = ( + f"**{escape_markdown(track['artist']['name'])}** — " + f"***{escape_markdown(track['name'])}***" + ) + try: + track_map[name]["score"] += score + track_map[name]["playcount"] += playcount + except KeyError: + track_map[name] = {"score": score, "playcount": playcount} + + top_tracks = sorted( + track_map.items(), key=lambda x: x[1]["score"], reverse=True + )[:100] + + rows = [] + for i, (track, track_data) in enumerate(top_tracks, start=1): + rows.append( + f"`#{i:2}` **{track_data['score'] / len(data):.2f}%** /" + f" **{track_data['playcount']}** plays • {track}" + ) + + await self.paginated_user_stat_embed( + ctx, + rows, + f"Top 100 Tracks ({timeframe.display()})", + image=await self.api.get_artist_image( + top_tracks[0][0].split(" — ")[0].strip("*") + ), + footer=f"Score calculated from top 100 tracks of {len(data)} members", + server_target=True, + ) + + @server.command(name="topalbums", aliases=["talb"], usage="[timeframe]") + async def server_topalbums( + self, + ctx: MisoContext, + timeframe: Annotated[Period, PeriodArgument] = Period.OVERALL, + ): + """Combined top albums of server members""" + data = await self.task_for_each_server_member( + ctx.guild, self.api.user_get_top_albums, limit=100, period=timeframe + ) + + if data is None: + return await ctx.send( + "Nobody on this server has connected their Last.fm account yet!" + ) + + album_map = {} + for member_data, member in data: + albums = member_data["album"] + lowest_playcount = int(albums[-1]["playcount"]) + highest_playcount = int(albums[0]["playcount"]) + for album in albums: + playcount = int(album["playcount"]) + score = playcount_mapped( + playcount, + input_start=lowest_playcount, + input_end=highest_playcount, + ) + + name = ( + f"**{escape_markdown(album['artist']['name'])}** — " + f"***{escape_markdown(album['name'])}***" + ) + try: + album_map[name]["score"] += score + album_map[name]["playcount"] += playcount + except KeyError: + album_map[name] = {"score": score, "playcount": playcount} + + top_albums = sorted( + album_map.items(), key=lambda x: x[1]["score"], reverse=True + )[:100] + + rows = [] + for i, (album, album_data) in enumerate(top_albums, start=1): + rows.append( + f"`#{i:2}` **{album_data['score'] / len(data):.2f}%** /" + f" **{album_data['playcount']}** plays • {album}" + ) + + await self.paginated_user_stat_embed( + ctx, + rows, + f"Top 100 Tracks ({timeframe.display()})", + image=await self.api.get_artist_image( + top_albums[0][0].split(" — ")[0].strip("*") + ), + footer=f"Score calculated from top 100 albums of {len(data)} members", + server_target=True, + ) + async def user_ranking( self, ctx: MisoContext, playcount_fn: Callable, ranking_of: str ): @@ -1447,6 +1687,7 @@ async def paginated_user_stat_embed( footer: Optional[str] = None, title_url: Optional[str] = None, content: Optional[discord.Embed] = None, + server_target: Optional[bool] = False, **kwargs, ): if content is None: @@ -1456,11 +1697,18 @@ async def paginated_user_stat_embed( content.color = await self.image_color(image) content.set_thumbnail(url=image.as_full()) - content.set_author( - name=f"{util.displayname(ctx.lfm.target_user, escape=False)} | {title}", - icon_url=ctx.lfm.target_user.display_avatar.url, - url=title_url, - ) + if server_target: + content.set_author( + name=f"{ctx.guild.name} | {title}", + icon_url=ctx.guild.icon.url if ctx.guild.icon is not None else None, + url=title_url, + ) + else: + content.set_author( + name=f"{util.displayname(ctx.lfm.target_user, escape=False)} | {title}", + icon_url=ctx.lfm.target_user.display_avatar.url, + url=title_url, + ) if footer: content.set_footer(text=footer) @@ -1605,3 +1853,17 @@ def raise_no_artist_plays(artist: str, timeframe: Period): async def task_wrapper(task: asyncio.Future, ref: Any): result = await task return result, ref + + +def playcount_mapped( + x: int, + input_start: int, + input_end: int, + output_start: int = 1, + output_end: int = 100, +): + score = (x - input_start) / (input_end - input_start) * ( + output_end - output_start + ) + output_start + + return score diff --git a/main.py b/main.py index 3e80095..e8aedd6 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import logging # noqa: F401 import os +import signal import sys import uvloop @@ -88,5 +89,15 @@ def main(): bot.run(TOKEN, log_handler=None) +# Docker by default sends a SIGTERM to a container +# and waits 10 seconds for it to stop before killing it with a SIGKILL. +# This makes ctrl-c work as normal even in a docker container. + + +def handle_sigterm(*args): + raise KeyboardInterrupt(*args) + + if __name__ == "__main__": + signal.signal(signal.SIGTERM, handle_sigterm) main() diff --git a/modules/util.py b/modules/util.py index 2d40f9e..971a204 100644 --- a/modules/util.py +++ b/modules/util.py @@ -689,12 +689,13 @@ async def color_from_image_url( async def rgb_from_image_url(session: aiohttp.ClientSession, url: str) -> Rgb | None: try: async with session.get(url) as response: + response.raise_for_status() image = Image.open(io.BytesIO(await response.read())) colors = await asyncio.get_running_loop().run_in_executor( None, lambda: colorgram.extract(image, 1) ) return colors[0].rgb - except aiohttp.InvalidURL: + except (aiohttp.InvalidURL, aiohttp.ClientError): return None