From da717b249a8e31a46d5d8aeb0d2dbd0f4cbf97aa Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:39:32 -0600 Subject: [PATCH 1/8] Fixes various typos in LastFM commands --- cogs/lastfm.py | 2 +- modules/lastfm.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cogs/lastfm.py b/cogs/lastfm.py index d4d51e5d..2f1375ad 100644 --- a/cogs/lastfm.py +++ b/cogs/lastfm.py @@ -1817,7 +1817,7 @@ async def server_topalbums( await self.paginated_user_stat_embed( ctx, rows, - f"Top 100 Tracks ({timeframe.display()})", + f"Top 100 Albums ({timeframe.display()})", image=LastFmImage.from_url(top_albums[0][1]["image"]), footer=f"Score calculated from top 100 albums of {len(data)} members", server_target=True, diff --git a/modules/lastfm.py b/modules/lastfm.py index 2637c8d2..0e3d3585 100644 --- a/modules/lastfm.py +++ b/modules/lastfm.py @@ -92,7 +92,7 @@ def web_format(self): def display(self): match self: case Period.OVERALL: - return "Alltime" + return "All Time" case Period.WEEK: return "Weekly" case Period.MONTH: @@ -100,7 +100,7 @@ def display(self): case Period.QUARTER: return "Quarterly" case Period.HALFYEAR: - return "6 month" + return "6 Months" case Period.YEAR: return "Yearly" From d2d84898e768810830b47d8f2de651df26064f69 Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Sun, 22 Feb 2026 03:37:26 -0600 Subject: [PATCH 2/8] Adds an option to generate fm server leaderboards by playcount --- cogs/lastfm.py | 153 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 24 deletions(-) diff --git a/cogs/lastfm.py b/cogs/lastfm.py index 2f1375ad..07851d8d 100644 --- a/cogs/lastfm.py +++ b/cogs/lastfm.py @@ -171,6 +171,21 @@ async def convert(self, _ctx: MisoContext, argument: str): ) return argument + +class ServerRankingArgument(commands.Converter): + ALIASES = { + "score": "score", + "scrobble": "scrobble", + } + + @classmethod + async def convert(cls, _ctx, argument: str): + key = argument.lower() + if key not in cls.ALIASES: + raise commands.BadArgument( + "Valid options are: `score`, `scrobble`." + ) + return cls.ALIASES[key] class StrOrNp: @@ -1622,13 +1637,25 @@ async def server_recent(self, ctx: MisoContext): await RowPaginator(content, rows).run(ctx) - @server.command(name="topartists", aliases=["ta"], usage="[timeframe]") + @server.command(name="topartists", aliases=["ta"], usage="[timeframe] ['score' | 'scrobble']") async def server_topartists( self, ctx: MisoContext, - timeframe: Annotated[Period, PeriodArgument] = Period.OVERALL, + *args: Union[ + Annotated[Period, PeriodArgument], + Annotated[str, ServerRankingArgument], + ], ): """Combined top artists of server members""" + timeframe = Period.OVERALL + mode="score" + + for arg in args: + if isinstance(arg, Period): + timeframe = arg + else: + mode = arg + data = await self.task_for_each_server_member( ctx.guild, self.api.user_get_top_artists, limit=100, period=timeframe ) @@ -1665,33 +1692,63 @@ async def server_topartists( except KeyError: artist_map[name] = {"score": score, "playcount": playcount} + if mode == "score": + sort_key = "score" + else: + sort_key = "playcount" + top_artists = sorted( - artist_map.items(), key=lambda x: x[1]["score"], reverse=True + artist_map.items(), key=lambda x: x[1][sort_key], reverse=True )[:100] + if not top_artists: + return await ctx.send("Nobody on this server has listened to anything!") + 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}**" - ) + if mode == "score": + rows.append( + f"`#{i:2}` **{artist_data['score'] / len(data):.2f}%** /" + f" **{artist_data['playcount']}** plays • **{artist}**" + ) + else: + rows.append( + f"`#{i:2}` **{artist_data['playcount']}** plays • **{artist}**" + ) + + if mode == "score": + footer = (f"Score calculated from top 100 artists of {len(data)} members") + else: + footer = (f"Ranked by scrobbles from top 100 artists of {len(data)} members") 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", + footer=footer, server_target=True, ) - @server.command(name="toptracks", aliases=["tt"], usage="[timeframe]") + @server.command(name="toptracks", aliases=["tt"], usage="[timeframe] ['score' | 'scrobble']") async def server_toptracks( self, ctx: MisoContext, - timeframe: Annotated[Period, PeriodArgument] = Period.OVERALL, + *args: Union[ + Annotated[Period, PeriodArgument], + Annotated[str, ServerRankingArgument], + ] ): """Combined top tracks of server members""" + timeframe = Period.OVERALL + mode = "score" + + for arg in args: + if isinstance(arg, Period): + timeframe = arg + else: + mode = arg + data = await self.task_for_each_server_member( ctx.guild, self.api.user_get_top_tracks, limit=100, period=timeframe ) @@ -1734,33 +1791,63 @@ async def server_toptracks( "url": track["url"], } + if mode == "score": + sort_key = "score" + else: + sort_key = "playcount" + top_tracks = sorted( - track_map.items(), key=lambda x: x[1]["score"], reverse=True + track_map.items(), key=lambda x: x[1][sort_key], reverse=True )[:100] + if not top_tracks: + return await ctx.send("Nobody on this server has listened to anything!") + 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}" - ) + if mode == "score": + rows.append( + f"`#{i:2}` **{track_data['score'] / len(data):.2f}%** /" + f" **{track_data['playcount']}** plays • {track}" + ) + else: + rows.append( + f"`#{i:2}` **{track_data['playcount']}** plays • {track}" + ) + + if mode == "score": + footer = (f"Score calculated from top 100 tracks of {len(data)} members") + else: + footer = (f"Ranked by scrobbles from top 100 tracks of {len(data)} members") await self.paginated_user_stat_embed( ctx, rows, f"Top 100 Tracks ({timeframe.display()})", image=await self.api.scrape_track_image(top_tracks[0][1]["url"]), - footer=f"Score calculated from top 100 tracks of {len(data)} members", + footer=footer, server_target=True, ) - @server.command(name="topalbums", aliases=["talb"], usage="[timeframe]") + @server.command(name="topalbums", aliases=["talb"], usage="[timeframe] ['score' | 'scrobble']") async def server_topalbums( self, ctx: MisoContext, - timeframe: Annotated[Period, PeriodArgument] = Period.OVERALL, + *args: Union[ + Annotated[Period, PeriodArgument], + Annotated[str, ServerRankingArgument], + ], ): """Combined top albums of server members""" + timeframe = Period.OVERALL + mode = "score" + + for arg in args: + if isinstance(arg, Period): + timeframe = arg + else: + mode = arg + data = await self.task_for_each_server_member( ctx.guild, self.api.user_get_top_albums, limit=100, period=timeframe ) @@ -1803,23 +1890,41 @@ async def server_topalbums( "image": album["image"][0]["#text"], } + if mode == "score": + sort_key = "score" + else: + sort_key = "playcount" + top_albums = sorted( - album_map.items(), key=lambda x: x[1]["score"], reverse=True + album_map.items(), key=lambda x: x[1][sort_key], reverse=True )[:100] + if not top_albums: + return await ctx.send("Nobody on this server has listened to anything!") + 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}" - ) + if mode == "score": + rows.append( + f"`#{i:2}` **{album_data['score'] / len(data):.2f}%** /" + f" **{album_data['playcount']}** plays • {album}" + ) + else: + rows.append( + f"`#{i:2}` **{album_data['playcount']}** plays • {album}" + ) + + if mode == "score": + footer = (f"Score calculated from top 100 albums of {len(data)} members") + else: + footer = (f"Ranked by scrobbles from top 100 albums of {len(data)} members") await self.paginated_user_stat_embed( ctx, rows, f"Top 100 Albums ({timeframe.display()})", image=LastFmImage.from_url(top_albums[0][1]["image"]), - footer=f"Score calculated from top 100 albums of {len(data)} members", + footer=footer, server_target=True, ) From bc4b4f0cf3dbcd23c22ce707785cfa5bf874d87c Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Sun, 22 Feb 2026 03:45:48 -0600 Subject: [PATCH 3/8] Prevents score miscalculation if a LastFM user has not listened to any music --- cogs/lastfm.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/cogs/lastfm.py b/cogs/lastfm.py index 07851d8d..da9a7a15 100644 --- a/cogs/lastfm.py +++ b/cogs/lastfm.py @@ -1649,6 +1649,7 @@ async def server_topartists( """Combined top artists of server members""" timeframe = Period.OVERALL mode="score" + contributors = 0 for arg in args: if isinstance(arg, Period): @@ -1674,6 +1675,8 @@ async def server_topartists( if len(artists) == 0: continue + contributors += 1 + lowest_playcount = int(artists[-1]["playcount"]) highest_playcount = int(artists[0]["playcount"]) for artist in artists: @@ -1708,7 +1711,7 @@ async def server_topartists( for i, (artist, artist_data) in enumerate(top_artists, start=1): if mode == "score": rows.append( - f"`#{i:2}` **{artist_data['score'] / len(data):.2f}%** /" + f"`#{i:2}` **{artist_data['score'] / contributors:.2f}%** /" f" **{artist_data['playcount']}** plays • **{artist}**" ) else: @@ -1717,9 +1720,9 @@ async def server_topartists( ) if mode == "score": - footer = (f"Score calculated from top 100 artists of {len(data)} members") + footer = (f"Score calculated from top 100 artists of {contributors} members") else: - footer = (f"Ranked by scrobbles from top 100 artists of {len(data)} members") + footer = (f"Ranked by scrobbles from top 100 artists of {contributors} members") await self.paginated_user_stat_embed( ctx, @@ -1742,6 +1745,7 @@ async def server_toptracks( """Combined top tracks of server members""" timeframe = Period.OVERALL mode = "score" + contributors = 0 for arg in args: if isinstance(arg, Period): @@ -1767,6 +1771,8 @@ async def server_toptracks( if len(tracks) == 0: continue + contributors += 1 + lowest_playcount = int(tracks[-1]["playcount"]) highest_playcount = int(tracks[0]["playcount"]) for track in tracks: @@ -1807,7 +1813,7 @@ async def server_toptracks( for i, (track, track_data) in enumerate(top_tracks, start=1): if mode == "score": rows.append( - f"`#{i:2}` **{track_data['score'] / len(data):.2f}%** /" + f"`#{i:2}` **{track_data['score'] / contributors:.2f}%** /" f" **{track_data['playcount']}** plays • {track}" ) else: @@ -1816,9 +1822,9 @@ async def server_toptracks( ) if mode == "score": - footer = (f"Score calculated from top 100 tracks of {len(data)} members") + footer = (f"Score calculated from top 100 tracks of {contributors} members") else: - footer = (f"Ranked by scrobbles from top 100 tracks of {len(data)} members") + footer = (f"Ranked by scrobbles from top 100 tracks of {contributors} members") await self.paginated_user_stat_embed( ctx, @@ -1841,6 +1847,7 @@ async def server_topalbums( """Combined top albums of server members""" timeframe = Period.OVERALL mode = "score" + contributors = 0 for arg in args: if isinstance(arg, Period): @@ -1866,6 +1873,8 @@ async def server_topalbums( if len(albums) == 0: continue + contributors += 1 + lowest_playcount = int(albums[-1]["playcount"]) highest_playcount = int(albums[0]["playcount"]) for album in albums: @@ -1906,7 +1915,7 @@ async def server_topalbums( for i, (album, album_data) in enumerate(top_albums, start=1): if mode == "score": rows.append( - f"`#{i:2}` **{album_data['score'] / len(data):.2f}%** /" + f"`#{i:2}` **{album_data['score'] / contributors:.2f}%** /" f" **{album_data['playcount']}** plays • {album}" ) else: @@ -1915,9 +1924,9 @@ async def server_topalbums( ) if mode == "score": - footer = (f"Score calculated from top 100 albums of {len(data)} members") + footer = (f"Score calculated from top 100 albums of {contributors} members") else: - footer = (f"Ranked by scrobbles from top 100 albums of {len(data)} members") + footer = (f"Ranked by scrobbles from top 100 albums of {contributors} members") await self.paginated_user_stat_embed( ctx, From 0feadb05cf0784bcaedbf0a22a6bedc40c148871 Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:34:13 -0600 Subject: [PATCH 4/8] Prevents issue with no music LastFM users with charts --- cogs/lastfm.py | 22 +++++++++++++++++----- docker-compose.yml | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cogs/lastfm.py b/cogs/lastfm.py index da9a7a15..c2459225 100644 --- a/cogs/lastfm.py +++ b/cogs/lastfm.py @@ -1965,6 +1965,8 @@ async def server_chart( """ timeframe = Period.WEEK size = ChartSize(3, 3) + contributors = 0 + for arg in args: if isinstance(arg, Period): timeframe = arg @@ -2001,6 +2003,8 @@ async def server_chart( if len(artists) == 0: continue + contributors += 1 + lowest_playcount = int(artists[-1]["playcount"]) highest_playcount = int(artists[0]["playcount"]) for artist in artists: @@ -2023,11 +2027,14 @@ async def server_chart( artist_map.items(), key=lambda x: x[1]["score"], reverse=True )[: size.count] - for i, (name, data) in enumerate(top_artists): + if not top_artists: + return await ctx.send("Nobody on this server has listened to anything!") + + for i, (name, artist_data) in enumerate(top_artists): chart_nodes.append( ( await self.api.get_artist_image(name), - f"
{name}
{data['score'] / len(data):.2f}%
", + f"
{name}
{artist_data['score'] / contributors:.2f}%
", ) ) if topster: @@ -2103,6 +2110,8 @@ async def server_chart( if len(albums) == 0: continue + contributors += 1 + lowest_playcount = int(albums[-1]["playcount"]) highest_playcount = int(albums[0]["playcount"]) for album in albums: @@ -2128,11 +2137,14 @@ async def server_chart( album_map.items(), key=lambda x: x[1]["score"], reverse=True )[: size.count] - for i, (name, data) in enumerate(top_albums): + if not top_albums: + return await ctx.send("Nobody on this server has listened to anything!") + + for i, (name, album_data) in enumerate(top_albums): chart_nodes.append( ( - LastFmImage.from_url(data["image"]), - f"
{name}
{data['score'] / len(data):.2f}%
", + LastFmImage.from_url(album_data["image"]), + f"{name}
{album_data['score'] / contributors:.2f}%
", ) ) if topster: diff --git a/docker-compose.yml b/docker-compose.yml index 103bd634..fe1604c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - ./sql/init:/docker-entrypoint-initdb.d:ro - ./sql/scheduled:/scripts:ro ports: - - 127.0.0.1:3306:3306 # for development + - 127.0.0.1:3307:3306 # for development environment: - MARIADB_USER=bot - MARIADB_PASSWORD=botpw From 47dfd39a10dc520982779fe0a9173a3d044c02ba Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:45:08 -0600 Subject: [PATCH 5/8] Fixed image rendering bug with fm server chart artist --- cogs/lastfm.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cogs/lastfm.py b/cogs/lastfm.py index c2459225..65d5e1cd 100644 --- a/cogs/lastfm.py +++ b/cogs/lastfm.py @@ -1441,6 +1441,9 @@ async def chart_factory( albums = [] for image, label in chart_nodes: + if image is None: + image = LastFmImage(LastFmImage.MISSING_IMAGE_HASH) + if size.width < 5: image_url = image.as_full() elif 9 > size.width >= 5: @@ -2031,9 +2034,13 @@ async def server_chart( return await ctx.send("Nobody on this server has listened to anything!") for i, (name, artist_data) in enumerate(top_artists): + image = await self.api.get_artist_image(name) + if image is None: + image = LastFmImage(LastFmImage.MISSING_IMAGE_HASH) + chart_nodes.append( ( - await self.api.get_artist_image(name), + image, f"{name}
{artist_data['score'] / contributors:.2f}%
", ) ) From 3437003b00b4c81d0243e5542bcfac2523d9a783 Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:45:46 -0600 Subject: [PATCH 6/8] Additional fixes to rendering bug --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index fe1604c3..103bd634 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - ./sql/init:/docker-entrypoint-initdb.d:ro - ./sql/scheduled:/scripts:ro ports: - - 127.0.0.1:3307:3306 # for development + - 127.0.0.1:3306:3306 # for development environment: - MARIADB_USER=bot - MARIADB_PASSWORD=botpw From fc43b57d879188292eceaf3a7b742136655d132d Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:01:20 -0600 Subject: [PATCH 7/8] Add run step to lint.yml --- .github/workflows/lint.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6c6f2af8..a4d51051 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,9 @@ jobs: - uses: actions/checkout@v4 - name: Install system dependencies - run: sudo apt install -y libasound2-dev + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev - name: Install uv uses: astral-sh/setup-uv@v6 From d219162171b249583d6e907a09d135c782878621 Mon Sep 17 00:00:00 2001 From: must108 <136145181+must108@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:53:56 -0600 Subject: [PATCH 8/8] Format --- cogs/lastfm.py | 51 ++++++++++++++++++++++++++++---------------------- cogs/roles.py | 5 +++-- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/cogs/lastfm.py b/cogs/lastfm.py index 65d5e1cd..6834e03a 100644 --- a/cogs/lastfm.py +++ b/cogs/lastfm.py @@ -171,7 +171,8 @@ async def convert(self, _ctx: MisoContext, argument: str): ) return argument - + + class ServerRankingArgument(commands.Converter): ALIASES = { "score": "score", @@ -182,9 +183,7 @@ class ServerRankingArgument(commands.Converter): async def convert(cls, _ctx, argument: str): key = argument.lower() if key not in cls.ALIASES: - raise commands.BadArgument( - "Valid options are: `score`, `scrobble`." - ) + raise commands.BadArgument("Valid options are: `score`, `scrobble`.") return cls.ALIASES[key] @@ -1640,7 +1639,9 @@ async def server_recent(self, ctx: MisoContext): await RowPaginator(content, rows).run(ctx) - @server.command(name="topartists", aliases=["ta"], usage="[timeframe] ['score' | 'scrobble']") + @server.command( + name="topartists", aliases=["ta"], usage="[timeframe] ['score' | 'scrobble']" + ) async def server_topartists( self, ctx: MisoContext, @@ -1651,10 +1652,10 @@ async def server_topartists( ): """Combined top artists of server members""" timeframe = Period.OVERALL - mode="score" + mode = "score" contributors = 0 - for arg in args: + for arg in args: if isinstance(arg, Period): timeframe = arg else: @@ -1723,9 +1724,11 @@ async def server_topartists( ) if mode == "score": - footer = (f"Score calculated from top 100 artists of {contributors} members") + footer = f"Score calculated from top 100 artists of {contributors} members" else: - footer = (f"Ranked by scrobbles from top 100 artists of {contributors} members") + footer = ( + f"Ranked by scrobbles from top 100 artists of {contributors} members" + ) await self.paginated_user_stat_embed( ctx, @@ -1736,14 +1739,16 @@ async def server_topartists( server_target=True, ) - @server.command(name="toptracks", aliases=["tt"], usage="[timeframe] ['score' | 'scrobble']") + @server.command( + name="toptracks", aliases=["tt"], usage="[timeframe] ['score' | 'scrobble']" + ) async def server_toptracks( self, ctx: MisoContext, *args: Union[ Annotated[Period, PeriodArgument], Annotated[str, ServerRankingArgument], - ] + ], ): """Combined top tracks of server members""" timeframe = Period.OVERALL @@ -1820,14 +1825,14 @@ async def server_toptracks( f" **{track_data['playcount']}** plays • {track}" ) else: - rows.append( - f"`#{i:2}` **{track_data['playcount']}** plays • {track}" - ) + rows.append(f"`#{i:2}` **{track_data['playcount']}** plays • {track}") if mode == "score": - footer = (f"Score calculated from top 100 tracks of {contributors} members") + footer = f"Score calculated from top 100 tracks of {contributors} members" else: - footer = (f"Ranked by scrobbles from top 100 tracks of {contributors} members") + footer = ( + f"Ranked by scrobbles from top 100 tracks of {contributors} members" + ) await self.paginated_user_stat_embed( ctx, @@ -1838,7 +1843,9 @@ async def server_toptracks( server_target=True, ) - @server.command(name="topalbums", aliases=["talb"], usage="[timeframe] ['score' | 'scrobble']") + @server.command( + name="topalbums", aliases=["talb"], usage="[timeframe] ['score' | 'scrobble']" + ) async def server_topalbums( self, ctx: MisoContext, @@ -1922,14 +1929,14 @@ async def server_topalbums( f" **{album_data['playcount']}** plays • {album}" ) else: - rows.append( - f"`#{i:2}` **{album_data['playcount']}** plays • {album}" - ) + rows.append(f"`#{i:2}` **{album_data['playcount']}** plays • {album}") if mode == "score": - footer = (f"Score calculated from top 100 albums of {contributors} members") + footer = f"Score calculated from top 100 albums of {contributors} members" else: - footer = (f"Ranked by scrobbles from top 100 albums of {contributors} members") + footer = ( + f"Ranked by scrobbles from top 100 albums of {contributors} members" + ) await self.paginated_user_stat_embed( ctx, diff --git a/cogs/roles.py b/cogs/roles.py index cdea96ba..9ae9751e 100644 --- a/cogs/roles.py +++ b/cogs/roles.py @@ -279,8 +279,9 @@ async def colorme(self, ctx: commands.Context, hex_color: str): # clean up any roles that are left with 0 users unused_roles = filter( - lambda r: r.id in [x[1] for x in existing_roles or []] - and len(r.members) == 0, + lambda r: ( + r.id in [x[1] for x in existing_roles or []] and len(r.members) == 0 + ), ctx.guild.roles, ) for role in unused_roles: