From e76ea48061c5caeda8a8b81ee124a771c1bd33c1 Mon Sep 17 00:00:00 2001 From: Dylan <24949824+brah@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:50:24 +1000 Subject: [PATCH] Rewrite `plex recent` as a native button View Replace the nextcord.ext.menus pagination (RecentlyAddedPageSource + NoStopButtonMenuPages) with a native nextcord.ui.View, matching the modern pattern already used by RandomMediaView. This fixes three issues inherent to the ext.menus button retrofit: - The reaction-wait internal loop disabled the buttons 60s after the message was posted, even during active use; a View resets its timeout on each interaction. - Paging from an item with a poster to one without left the previous poster attached; _show now passes attachments=[] to clear it. - The pagination buttons were drivable by anyone; interaction_check now restricts them to the command author. _show defers the component interaction before fetching the poster so a slow Tautulli image fetch can't blow the 3s ack window, snapshots the index so rapid clicks can't tear the embed, and guards the edit. ext.menus was used nowhere else, so drop NoStopButtonMenuPages from utilities.py and the now-dead nextcord-ext-menus dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- cogs/media_commands.py | 97 ++++++++++++++++++++++++++++++++++-------- requirements.txt | 1 - utilities.py | 11 ----- 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/cogs/media_commands.py b/cogs/media_commands.py index 0a0b167..7709232 100644 --- a/cogs/media_commands.py +++ b/cogs/media_commands.py @@ -7,12 +7,11 @@ from config import config import nextcord -from nextcord.ext import commands, menus +from nextcord.ext import commands import qbittorrentapi from utilities import ( UserMappings, - NoStopButtonMenuPages, prepare_thumbnail_for_embed, format_duration, ) @@ -85,19 +84,22 @@ def _format_torrent_field(dl) -> str: return f"`{bar}` {pct:.1f}%\n{speed_str} \u2022 {size_str} \u2022 ETA {eta_str}{seed_str}" -class RecentlyAddedPageSource(menus.ListPageSource): - """One item per page with a rich embed and poster thumbnail.""" +class RecentlyAddedView(nextcord.ui.View): + """Paginated view of recently added media \u2014 one item per page with Prev/Next buttons.""" - def __init__(self, data, tautulli_ip, embed_color, use_https=False, api_key=""): - super().__init__(data, per_page=1) + def __init__(self, ctx, items: list, tautulli_ip: str, embed_color: int, use_https: bool = False, api_key: str = "", timeout: float = 120): + super().__init__(timeout=timeout) + self.ctx = ctx + self.items = items self.tautulli_ip = tautulli_ip self.embed_color = embed_color self.use_https = use_https self.api_key = api_key + self.message = None + self.index = 0 + self._update_buttons(self.index) - async def format_page(self, menu, page): - item = page[0] if isinstance(page, list) else page - + def _build_embed(self, index: int, item: dict) -> nextcord.Embed: embed = nextcord.Embed( title=item["display_title"], description=item["summary"] or None, @@ -121,18 +123,79 @@ async def format_page(self, menu, page): if item["added_at"]: embed.add_field(name="Added", value=f"", inline=True) - page_num = getattr(menu, "current_page", 0) - embed.set_footer(text=f"Item {page_num + 1} of {self.get_max_pages()}") + embed.set_footer(text=f"Item {index + 1} of {len(self.items)}") + return embed - # Poster thumbnail + async def _render(self, index: int) -> tuple: + """Build the embed and optional poster file for the item at ``index``.""" + item = self.items[index] + embed = self._build_embed(index, item) thumb = item.get("thumb", "") if thumb: file, url = await prepare_thumbnail_for_embed(self.tautulli_ip, thumb, use_https=self.use_https, api_key=self.api_key) if file and url: embed.set_thumbnail(url=url) - return {"embed": embed, "file": file} + return embed, file + return embed, None - return embed + def _update_buttons(self, index: int): + """Disable Prev/Next at the ends of the list.""" + # nextcord rebinds these names to Button instances in View.__init__. + self.previous_item.disabled = index == 0 # type: ignore[attr-defined] + self.next_item.disabled = index >= len(self.items) - 1 # type: ignore[attr-defined] + + async def send_initial(self): + embed, file = await self._render(self.index) + if file: + self.message = await self.ctx.send(embed=embed, view=self, file=file) + else: + self.message = await self.ctx.send(embed=embed, view=self) + + async def _show(self, interaction: nextcord.Interaction): + # Ack first so a slow poster fetch can't blow the 3s interaction window; for a + # component interaction this defers the update with no visible change. + await interaction.response.defer() + # Snapshot the index so a rapid second click can't tear the embed across items. + index = self.index + self._update_buttons(index) + embed, file = await self._render(index) + # attachments=[] clears the previous item's poster; pass the new file if any. + kwargs = {"embed": embed, "view": self, "attachments": []} + if file: + kwargs["file"] = file + try: + await interaction.edit_original_message(**kwargs) + except nextcord.HTTPException as e: + logger.warning(f"Failed to update recent-additions page: {e}") + if file: + file.close() + + @nextcord.ui.button(emoji="\u25c0", style=nextcord.ButtonStyle.secondary) + async def previous_item(self, button: nextcord.ui.Button, interaction: nextcord.Interaction): + if self.index > 0: + self.index -= 1 + await self._show(interaction) + + @nextcord.ui.button(emoji="\u25b6", style=nextcord.ButtonStyle.secondary) + async def next_item(self, button: nextcord.ui.Button, interaction: nextcord.Interaction): + if self.index < len(self.items) - 1: + self.index += 1 + await self._show(interaction) + + async def interaction_check(self, interaction: nextcord.Interaction) -> bool: + if interaction.user != self.ctx.author: + await interaction.response.send_message("Only the command author can use these buttons.", ephemeral=True) + return False + return True + + async def on_timeout(self): + for child in self.children: + child.disabled = True + if self.message: + try: + await self.message.edit(view=self) + except nextcord.NotFound: + pass class RandomMediaView(nextcord.ui.View): @@ -297,10 +360,8 @@ async def recent(self, ctx, amount: int = None): await ctx.send("No recently added items found.") return - pages = NoStopButtonMenuPages( - source=RecentlyAddedPageSource(items, self.tautulli.tautulli_ip, self.plex_embed_color, use_https=self.tautulli.use_https, api_key=self.tautulli.api_key), - ) - await pages.start(ctx) + view = RecentlyAddedView(ctx, items, self.tautulli.tautulli_ip, self.plex_embed_color, use_https=self.tautulli.use_https, api_key=self.tautulli.api_key) + await view.send_initial() except Exception as e: logger.error(f"Failed to retrieve recent additions: {e}", exc_info=True) await ctx.send("Failed to retrieve recent additions.") diff --git a/requirements.txt b/requirements.txt index f0d0f52..fec53c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ # Discord nextcord==3.2.0 -nextcord-ext-menus==1.5.7 # HTTP / async I/O aiohttp==3.14.1 diff --git a/utilities.py b/utilities.py index c1d9003..2bd5d42 100644 --- a/utilities.py +++ b/utilities.py @@ -8,7 +8,6 @@ from typing import List, Dict, Any, Optional, Tuple import aiohttp -from nextcord.ext import menus from nextcord import File logger = logging.getLogger("plexbot.utilities") @@ -177,13 +176,3 @@ async def prepare_thumbnail_for_embed( attachment_url = "attachment://image.jpg" return file, attachment_url return None, None - - -class NoStopButtonMenuPages(menus.ButtonMenuPages, inherit_buttons=False): - def __init__(self, source, timeout=60) -> None: - super().__init__(source, timeout=timeout) - # Add the buttons we want - self.add_item(menus.MenuPaginationButton(emoji=self.PREVIOUS_PAGE)) - self.add_item(menus.MenuPaginationButton(emoji=self.NEXT_PAGE)) - # Disable buttons that are unavailable to be pressed at the start - self._disable_unavailable_buttons()