diff --git a/themoviedb/README.md b/themoviedb/README.md
new file mode 100644
index 00000000..24b3bbaf
--- /dev/null
+++ b/themoviedb/README.md
@@ -0,0 +1,52 @@
+Search for informations of movies and TV shows from themoviedb.org.
+
+# [p]tmdbset
+Configure TheMovieDB cog settings.
+ - Usage: `[p]tmdbset`
+ - Restricted to: `BOT_OWNER`
+## [p]tmdbset creds
+Guide to setting up the TMDB API key.
+
+This command will give you information on how to set up the API key.
+ - Usage: `[p]tmdbset creds`
+# [p]movie (Hybrid Command)
+Search for a movie.
+
+You can write the full name of the movie to get more accurate results.
+
+**Examples:**
+- `[p]movie the dark knight`
+- `[p]movie the lord of the rings`
+
+**Arguments:**
+- `` - The movie you want to search for.
+ - Usage: `[p]movie `
+ - Slash Usage: `/movie `
+ - Aliases: `movies`
+# [p]tvshow (Hybrid Command)
+Search for a TV show.
+
+You can write the full name of the TV show to get more accurate results.
+
+**Examples:**
+- `[p]tv the office`
+- `[p]tv game of thrones`
+
+**Arguments:**
+- `` - The TV show you want to search for.
+ - Usage: `[p]tvshow `
+ - Slash Usage: `/tvshow `
+ - Aliases: `tv`
+# [p]person (Hybrid Command)
+Search for a person.
+
+You can write the full name of the person to get more accurate results.
+
+**Examples:**
+- `[p]person arthur`
+- `[p]person johnny depp`
+
+**Arguments:**
+- `` - The person you want to search for.
+ - Usage: `[p]person `
+ - Slash Usage: `/person `
diff --git a/themoviedb/info.json b/themoviedb/info.json
index f40aea6d..678c12e9 100644
--- a/themoviedb/info.json
+++ b/themoviedb/info.json
@@ -3,11 +3,11 @@
"max"
],
"name": "TheMovieDB",
- "install_msg": "Thanks for installing.\nYou will need to set your API key before using this cog. See `[p]tmdbset creds`.\n for documentation.\nIf you enjoy my work, you can donate at [buymeacoffee]()\n## PLEASE NOTE:\nThis cog is in alpha phase and using Components V2 and is not fully tested or and stable enough for production use. This also require the Components V2 PR from discord.py for this cog to work.",
+ "install_msg": "Thanks for installing.\nYou will need to set your API key before using this cog. See `[p]tmdbset creds`.\n for documentation.\nIf you enjoy my work, you can donate at [buymeacoffee]().",
"description": "Search for informations of movies and TV shows from themoviedb.org.",
"short": "Search for informations of movies and TV shows from themoviedb.org.",
"hidden": false,
- "min_bot_version": "3.5.14",
+ "min_bot_version": "3.5.21",
"tags": [
"movies",
"movie",
diff --git a/themoviedb/themoviedb.py b/themoviedb/themoviedb.py
index 4e8e6d73..e6fb7635 100644
--- a/themoviedb/themoviedb.py
+++ b/themoviedb/themoviedb.py
@@ -22,14 +22,22 @@
SOFTWARE.
"""
-from typing import Dict, Optional, Union
+import asyncio
+import datetime
+import urllib.parse
+import xml.etree.ElementTree as ET
+from typing import Any, Dict, List, Optional, Union
import aiohttp
import discord
+from discord.ext import tasks
+from red_commons.logging import getLogger
from redbot.core import Config, app_commands, commands
from redbot.core.utils.views import SetApiView, SimpleMenu
-from .tmdb_utils import person_embed, search_and_display
+from .tmdb_utils import PREDEFINED_CHANNELS, fetch_tmdb, person_embed, search_and_display
+
+logger = getLogger("red.maxcogs.themoviedb")
class TheMovieDB(commands.Cog):
@@ -38,20 +46,38 @@ class TheMovieDB(commands.Cog):
"""
__author__ = "MAX"
- __version__ = "1.8.0"
- __docs__ = "https://cogs.maxapp.tv/"
+ __version__ = "2.0.0a"
+ __docs__ = "https://docs.maxapp.tv/"
def __init__(self, bot):
self.bot = bot
- self.session = aiohttp.ClientSession()
- self.config: Config = Config.get_conf(self, identifier=1234567890, force_registration=True)
- default_global: Dict[str, Union[bool, Optional[int]]] = {
- "use_box": False,
+ self.config: Config = Config.get_conf(
+ self, identifier=1111238727729911, force_registration=True
+ )
+ default_guild = {
+ "notification_channel": None,
+ "channels_status": {},
+ "ping_role": None,
}
- self.config.register_global(**default_global)
+ self.config.register_guild(**default_guild)
+ self.session = aiohttp.ClientSession()
+ self.check_for_new_trailers.start()
async def cog_unload(self) -> None:
- await self.session.close()
+ """Clean up on cog unload."""
+ self.check_for_new_trailers.cancel()
+ if hasattr(self, "session") and self.session:
+ if not self.session.closed:
+ try:
+ await asyncio.wait_for(self.session.close(), timeout=5.0)
+ logger.info(f"Session closed successfully: {self.session.closed}")
+ except asyncio.TimeoutError:
+ logger.warning("Session close timed out—forcing shutdown.")
+ except discord.HTTPException as e:
+ logger.error(f"Error closing session: {e}", exc_info=True)
+ else:
+ logger.info("Session already closed.")
+ logger.info("Cog unload complete.")
def format_help_for_context(self, ctx: commands.Context) -> str:
"""Thanks Sinbad!"""
@@ -62,26 +88,342 @@ async def red_delete_data_for_user(self, **kwargs) -> None:
"""Nothing to delete."""
return
+ async def fetch_feed(self, youtube_channel_id: str) -> Optional[str]:
+ url = f"https://www.youtube.com/feeds/videos.xml?channel_id={youtube_channel_id}"
+ try:
+ async with self.session.get(url, timeout=10) as response:
+ if response.status == 200:
+ return await response.text()
+ else:
+ logger.warning(
+ f"Failed to fetch feed for channel {youtube_channel_id}: HTTP {response.status}"
+ )
+ return None
+ except aiohttp.ClientError as e:
+ logger.error(f"Error fetching feed for channel {youtube_channel_id}: {e}")
+ return None
+
+ async def check_trailers(self, guild: discord.Guild) -> None:
+ guild_data = await self.config.guild(guild).all()
+ notification_channel_id = guild_data.get("notification_channel")
+ if not notification_channel_id:
+ return
+
+ channel_to_post = guild.get_channel(notification_channel_id)
+ if not channel_to_post:
+ return
+
+ ping_role_id = guild_data.get("ping_role")
+ ping_role = guild.get_role(ping_role_id) if ping_role_id else None
+ ping_mention = f"{ping_role.mention} " if ping_role else ""
+ channels_status = guild_data.get("channels_status", {})
+
+ enabled_channels = [
+ (key, details)
+ for key, details in PREDEFINED_CHANNELS.items()
+ if channels_status.get(key, {}).get("enabled", False)
+ ]
+ if not enabled_channels:
+ return
+
+ sem = asyncio.Semaphore(5)
+
+ async def fetch_with_sem(key, details):
+ async with sem:
+ return key, details, await self.fetch_feed(details["id"])
+
+ fetch_tasks = [fetch_with_sem(key, details) for key, details in enabled_channels]
+ results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
+
+ updates = {}
+ for result in results:
+ if isinstance(result, Exception):
+ logger.error(f"Fetch error: {result}")
+ continue
+
+ key, details, feed_data = result
+ if not feed_data:
+ failure_count = channels_status.get(key, {}).get("failure_count", 0) + 1
+ updates[key] = {"enabled": failure_count < 3, "failure_count": failure_count}
+ if failure_count >= 3:
+ try:
+ await channel_to_post.send(
+ f"Disabled notifications for **{details['name']}** due to repeated failures (HTTP 404).\n"
+ "Please contact the bot owner to resolve this issue."
+ )
+ except (discord.Forbidden, discord.HTTPException) as e:
+ logger.error(
+ f"Failed to send disable message to {channel_to_post.name} in {guild.name}: {e}"
+ )
+ continue
+ updates[key] = {"enabled": True, "failure_count": 0}
+
+ try:
+ root = ET.fromstring(feed_data)
+ entries = root.findall("{http://www.w3.org/2005/Atom}entry")
+ if not entries:
+ logger.debug(f"No entries in feed for {details['name']}")
+ continue
+
+ latest_video = entries[0]
+ video_id_elem = latest_video.find(
+ "{http://www.youtube.com/xml/schemas/2015}videoId"
+ )
+ if video_id_elem is None:
+ logger.warning(f"No video ID in latest entry for {details['name']}")
+ continue
+
+ video_id = video_id_elem.text
+ last_video_id = channels_status.get(key, {}).get("last_video_id")
+
+ published_elem = latest_video.find("{http://www.w3.org/2005/Atom}published")
+ if published_elem is None:
+ logger.warning(f"No published date in latest entry for {details['name']}")
+ continue
+
+ published_str = published_elem.text.replace("Z", "+00:00")
+ published_dt = datetime.datetime.fromisoformat(published_str)
+ published_ts = published_dt.timestamp()
+ last_published_ts = channels_status.get(key, {}).get("last_published_ts", 0)
+
+ if last_published_ts == 0:
+ updates[key]["last_published_ts"] = published_ts
+ updates[key]["last_video_id"] = video_id
+ continue
+
+ if published_ts <= last_published_ts or video_id == last_video_id:
+ logger.debug(f"No new video for {details['name']}")
+ continue
+
+ video_url_elem = latest_video.find("{http://www.w3.org/2005/Atom}link")
+ if video_url_elem is None:
+ logger.warning(f"No link in latest entry for {details['name']}")
+ continue
+
+ video_url = video_url_elem.attrib["href"]
+ # Skip YouTube Shorts
+ if "/shorts/" in video_url:
+ continue
+
+ updates[key]["last_published_ts"] = published_ts
+ updates[key]["last_video_id"] = video_id
+ author_name = (
+ root.findtext(
+ "{http://www.w3.org/2005/Atom}author/{http://www.w3.org/2005/Atom}name"
+ )
+ or details["name"]
+ )
+ message = (
+ f"{ping_mention}**{author_name}** has uploaded a new video!\n{video_url}"
+ ).strip()
+ try:
+ await channel_to_post.send(message)
+ except (discord.Forbidden, discord.HTTPException) as e:
+ logger.error(
+ f"Failed to send notification to {channel_to_post.name} in {guild.name}: {e}"
+ )
+ except ET.ParseError as e:
+ logger.error(f"Failed to parse RSS feed for {details['name']}: {e}")
+ continue
+ except discord.HTTPException as e:
+ logger.error(f"Unexpected error processing feed for {details['name']}: {e}")
+ continue
+
+ if updates:
+ try:
+ async with self.config.guild(guild).channels_status() as statuses:
+ for key, data in updates.items():
+ if key not in statuses:
+ statuses[key] = {"enabled": True, "failure_count": 0}
+ statuses[key].update(data)
+ except discord.HTTPException as e:
+ logger.error(f"Failed to update channels_status for guild {guild.id}: {e}")
+
+ @tasks.loop(minutes=5)
+ async def check_for_new_trailers(self) -> None:
+ all_guilds = await self.config.all_guilds()
+ for guild_id in all_guilds:
+ guild = self.bot.get_guild(guild_id)
+ if not guild:
+ continue
+ channel = guild.get_channel(all_guilds[guild_id].get("notification_channel"))
+ if (
+ not channel.permissions_for(guild.me).send_messages
+ or not channel.permissions_for(guild.me).embed_links
+ ):
+ logger.warning(
+ f"Bot does not have permission to send messages or embed links in the notification channel {channel.name} in guild {guild.name} (ID: {guild.id}"
+ )
+ continue
+
+ await self.check_trailers(guild)
+
+ @check_for_new_trailers.before_loop
+ async def before_check_for_new_trailers(self) -> None:
+ await self.bot.wait_until_ready()
+
@commands.group()
- @commands.is_owner()
+ @commands.admin_or_permissions(manage_guild=True)
async def tmdbset(self, ctx: commands.Context):
"""
Configure TheMovieDB cog settings.
"""
- @tmdbset.command(name="usebox")
- async def tmdbset_usebox(self, ctx: commands.Context, value: bool):
+ @tmdbset.command(name="channel")
+ async def set_channel(
+ self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None
+ ) -> None:
+ """Set or unset the channel for video notifications."""
+ guild_data = await self.config.guild(ctx.guild).all()
+ channels_status = guild_data.get("channels_status", {})
+
+ any_enabled = any(status.get("enabled", False) for status in channels_status.values())
+
+ if channel:
+ if not channel.permissions_for(ctx.me).send_messages:
+ return await ctx.send(
+ f"I don't have permission to send messages in {channel.mention}. Please choose another channel or fix permissions."
+ )
+
+ await self.config.guild(ctx.guild).notification_channel.set(channel.id)
+ msg = f"Video notifications will now be sent to {channel.mention}."
+ if not any_enabled:
+ msg += (
+ f"\nPlease enable at least one studio with `{ctx.clean_prefix}tmdbset toggle `.\n"
+ f"Use `{ctx.clean_prefix}tmdbset list` to see available studios."
+ )
+ await ctx.send(msg)
+ else:
+ await self.config.guild(ctx.guild).notification_channel.set(None)
+ msg = "video notifications have been disabled."
+ if any_enabled:
+ msg += "\n(Any enabled studios will remain configured but won't notify until a channel is set again.)"
+ await ctx.send(msg)
+
+ @tmdbset.command(name="toggle")
+ async def toggle_channel(self, ctx: commands.Context, *channel_names: str) -> None:
"""
- Set if you want to use the box in the choose of movie/tv show.
+ Toggle notifications for one or more studios, or all studios.
+
+ Use `[p]tmdbset list` to see available studios. Pass 'all' to toggle all studios,
+ or specify multiple studio names to toggle them at once.
+
+ **NOTE**:
+ Videos may include more than just trailers from movies or TV shows, they can also feature behind-the-scenes content or interviews. This is intended to keep you updated on new content from your favorite studios and channels.
- Disabled by default.
+ **Examples**:
+ - `[p]tmdbset toggle marvel`
+ - `[p]tmdbset toggle netflix sony amazon`
+ - `[p]tmdbset toggle all`
- - `True` to use the box art in the embeds.
- - `False` to not use the box art in the embeds.
+ **Arguments**:
+ - ``: One or more studio names to toggle, or 'all' to toggle all studios.
"""
- await self.config.use_box.set(value)
- await ctx.send(f"Use box art set to `{value}`.")
+ if not channel_names:
+ return await ctx.send(
+ f"Please provide at least one studio name or `all`. Use `{ctx.clean_prefix}tmdbset list` to see options."
+ )
+ if "all" in [name.lower() for name in channel_names]:
+ if len(channel_names) > 1:
+ return await ctx.send(
+ f"Cannot combine 'all' with specific studio names. Use `{ctx.clean_prefix}tmdbset toggle all` or list specific studios."
+ )
+
+ channel_keys = list(PREDEFINED_CHANNELS.keys())
+ else:
+ channel_keys = [name.lower() for name in channel_names]
+
+ valid_toggles = []
+ invalid_names = []
+ async with self.config.guild(ctx.guild).channels_status() as statuses:
+ for key in channel_keys:
+ if key not in PREDEFINED_CHANNELS:
+ invalid_names.append(key)
+ continue
+ if key not in statuses:
+ statuses[key] = {"enabled": True, "failure_count": 0}
+ else:
+ statuses[key]["enabled"] = not statuses[key].get("enabled", False)
+ statuses[key]["failure_count"] = 0
+ status = "enabled" if statuses[key]["enabled"] else "disabled"
+ valid_toggles.append(f"{PREDEFINED_CHANNELS[key]['name']} (`{key}`): **{status}**")
+
+ response = ""
+ if valid_toggles:
+ response += (
+ "Toggled notifications:\n"
+ + "\n".join(f"- {toggle}" for toggle in valid_toggles)
+ + "\n"
+ )
+ if invalid_names:
+ response += (
+ "\nInvalid studio names: "
+ + ", ".join(f"`{name}`" for name in invalid_names)
+ + f". Use `{ctx.clean_prefix}tmdbset list` to see options."
+ )
+
+ if response:
+ await ctx.send(response.strip())
+ else:
+ await ctx.send(
+ f"No valid studios toggled. Use `{ctx.clean_prefix}tmdbset list` to see options."
+ )
+
+ @tmdbset.command(name="list")
+ async def list_channels(self, ctx: commands.Context) -> None:
+ """List all available studios and their notification status."""
+ guild_data = await self.config.guild(ctx.guild).all()
+ notification_channel_id = guild_data.get("notification_channel")
+ ping_role_id = guild_data.get("ping_role")
+
+ if notification_channel_id:
+ channel = ctx.guild.get_channel(notification_channel_id)
+ msg = f"Notification Channel: {channel.mention if channel else 'Not Set'}\n"
+ else:
+ msg = "Notification Channel: Not Set\n"
+
+ if ping_role_id:
+ role = ctx.guild.get_role(ping_role_id)
+ msg += f"Ping Role: {role.mention if role else 'Not Set'}\n"
+ else:
+ msg += "Ping Role: Not Set\n"
+
+ msg += "\nAvailable Studios:\n"
+ channels_status = guild_data.get("channels_status", {})
+ for key, details in PREDEFINED_CHANNELS.items():
+ status = (
+ "Enabled" if channels_status.get(key, {}).get("enabled", False) else "Disabled"
+ )
+ msg += f"- {details['name']} (`{key}`): **{status}**\n"
+
+ pages = []
+ current_page = ""
+ for line in msg.splitlines(keepends=True):
+ if len(current_page) + len(line) > 1900:
+ pages.append(current_page)
+ current_page = line
+ else:
+ current_page += line
+ if current_page:
+ pages.append(current_page)
+ await SimpleMenu(pages, disable_after_timeout=True, timeout=120).start(ctx)
+
+ @tmdbset.command(name="role")
+ async def set_role(self, ctx: commands.Context, role: Optional[discord.Role] = None) -> None:
+ """Set or unset a role to ping for new video notifications."""
+ if role:
+ if role >= ctx.guild.me.top_role:
+ return await ctx.send("That role is higher than my highest role.")
+ if role.is_default() or role.is_everyone() or role.name == "@here":
+ return await ctx.send("Cannot set `@everyone` or `@here` as ping roles.")
+ await self.config.guild(ctx.guild).ping_role.set(role.id)
+ await ctx.send(f"New video notifications will now ping {role.mention}.")
+ else:
+ await self.config.guild(ctx.guild).ping_role.set(None)
+ await ctx.send("Ping role for video notifications has been disabled ")
+
+ @commands.is_owner()
@tmdbset.command(name="creds")
@commands.bot_has_permissions(embed_links=True)
async def tmdbset_creds(self, ctx: commands.Context):
@@ -131,7 +473,49 @@ async def movie(self, ctx: commands.Context, *, query: str):
"The bot owner has not set up the API key for TheMovieDB. "
"Please ask them to set it up."
)
- await search_and_display(ctx, query, "movie", self.config)
+ await search_and_display(ctx, query, "movie")
+
+ @movie.autocomplete("query")
+ async def movie_autocomplete(
+ self, interaction: discord.Interaction, current: str
+ ) -> List[app_commands.Choice[str]]:
+ """Autocomplete suggestions for movie search, sorted by release date."""
+ if not current:
+ return []
+
+ token = await self.bot.get_shared_api_tokens("tmdb")
+ api_key = token.get("api_key")
+ if not api_key:
+ return []
+
+ include_adult = str(getattr(interaction.channel, "nsfw", False)).lower()
+ encoded_query = urllib.parse.quote(current)
+ url = f"https://api.themoviedb.org/3/search/movie?api_key={api_key}&query={encoded_query}&page=1&include_adult={include_adult}"
+ async with aiohttp.ClientSession() as session:
+ data = await fetch_tmdb(url, session)
+
+ if not data or "results" not in data:
+ return []
+
+ def get_date(item: Dict[str, Any]) -> float:
+ date_str = item.get("release_date", "")
+ if not date_str or not isinstance(date_str, str) or len(date_str) < 4:
+ return float("-inf")
+ try:
+ year = int(date_str[:4])
+ return datetime(year=year, month=1, day=1).timestamp()
+ except (ValueError, TypeError):
+ return float("-inf")
+
+ sorted_results = sorted(data.get("results", []), key=get_date, reverse=True)
+
+ return [
+ app_commands.Choice(
+ name=f"{result.get('title', 'Unknown')} ({result.get('release_date', '')[:4] or 'N/A'})",
+ value=result.get("title", "Unknown"),
+ )
+ for result in sorted_results[:25]
+ ]
@commands.hybrid_command(aliases=["tv"])
@app_commands.describe(query="The series you want to search for.")
@@ -154,7 +538,49 @@ async def tvshow(self, ctx: commands.Context, *, query: str):
"The bot owner has not set up the API key for TheMovieDB. "
"Please ask them to set it up."
)
- await search_and_display(ctx, query, "tv", self.config)
+ await search_and_display(ctx, query, "tv")
+
+ @tvshow.autocomplete("query")
+ async def tvshow_autocomplete(
+ self, interaction: discord.Interaction, current: str
+ ) -> List[app_commands.Choice[str]]:
+ """Autocomplete suggestions for TV show search, sorted by first air date."""
+ if not current:
+ return []
+
+ token = await self.bot.get_shared_api_tokens("tmdb")
+ api_key = token.get("api_key")
+ if not api_key:
+ return []
+
+ include_adult = str(getattr(interaction.channel, "nsfw", False)).lower()
+ encoded_query = urllib.parse.quote(current)
+ url = f"https://api.themoviedb.org/3/search/tv?api_key={api_key}&query={encoded_query}&page=1&include_adult={include_adult}"
+ async with aiohttp.ClientSession() as session:
+ data = await fetch_tmdb(url, session)
+
+ if not data or "results" not in data:
+ return []
+
+ def get_date(item: Dict[str, Any]) -> float:
+ date_str = item.get("first_air_date", "")
+ if not date_str or not isinstance(date_str, str) or len(date_str) < 4:
+ return float("-inf")
+ try:
+ year = int(date_str[:4])
+ return datetime(year=year, month=1, day=1).timestamp()
+ except (ValueError, TypeError):
+ return float("-inf")
+
+ sorted_results = sorted(data.get("results", []), key=get_date, reverse=True)
+
+ return [
+ app_commands.Choice(
+ name=f"{result.get('name', 'Unknown')} ({result.get('first_air_date', '')[:4] or 'N/A'})",
+ value=result.get("name", "Unknown"),
+ )
+ for result in sorted_results[:25]
+ ]
@commands.hybrid_command()
@app_commands.describe(query="The person you want to search for.")
diff --git a/themoviedb/tmdb_utils.py b/themoviedb/tmdb_utils.py
index d1525a2f..4d44db45 100644
--- a/themoviedb/tmdb_utils.py
+++ b/themoviedb/tmdb_utils.py
@@ -31,12 +31,38 @@
import aiohttp
import discord
import orjson
+from red_commons.logging import getLogger
from redbot.core.utils.chat_formatting import box, header, humanize_list, humanize_number
from redbot.core.utils.views import SimpleMenu
-log = logging.getLogger("red.maxcogs.themoviedb.tmdb_utils")
+log = getLogger("red.maxcogs.themoviedb.tmdb_utils")
BASE_MEDIA = "https://api.themoviedb.org/3/search"
BASE_URL = "https://api.themoviedb.org/3"
+PREDEFINED_CHANNELS: Dict[str, Dict[str, str]] = {
+ "marvel": {"id": "UCvC4D8onUfXzvjTOM-dBfEA", "name": "Marvel Entertainment"},
+ "dc": {"id": "UCiifkYAs_bq1pt_zbNAzYGg", "name": "DC Official"},
+ "pixar": {"id": "UC_IRYSp4auq7hKLvziWVH6w", "name": "Pixar"},
+ "disney": {"id": "UC_5niPa-d35gg88HaS7RrIw", "name": "Disney"},
+ "disneyplus": {"id": "UCIrgJInjLS2BhlHOMDW7v0g", "name": "Disney+"},
+ "illumination": {"id": "UCq7OHvWO6Z3u-LztFdrcU-g", "name": "Illumination Entertainment"},
+ "warnerbros": {"id": "UCjmJDM5pRKbUlVIzDYYWb6g", "name": "Warner Bros. Pictures"},
+ "sony": {"id": "UCz97F7dMxBNOfGYu3rx8aCw", "name": "Sony Pictures Entertainment"},
+ "sonyanimation": {"id": "UCnLuLSV-Oi0ctqjxGgxFlmg", "name": "Sony Pictures Animation"},
+ "universal": {"id": "UCq0OueAsdxH6b8nyAspwViw", "name": "Universal Pictures"},
+ "paramount": {"id": "UCF9imwPMSGz4Vq1NiTWCC7g", "name": "Paramount Pictures"},
+ "20thcentury": {"id": "UC2-BeLxzUBSs0uSrmzWhJuQ", "name": "20th Century Studios"},
+ "lionsgate": {"id": "UCJ6nMHaJPZvsJ-HmUmj1SeA", "name": "Lionsgate Movies"},
+ "a24": {"id": "UCuPivVjnfNo4mb3Oog_frZg", "name": "A24"},
+ "hbomax": {"id": "UCx-KWLTKlB83hDI6UKECtJQ", "name": "HBO Max (formerly max)"},
+ "netflix": {"id": "UCWOA1ZGywLbqmigxE4Qlvuw", "name": "Netflix"},
+ "appletv": {"id": "UC1Myj674wRVXB9I4c6Hm5zA", "name": "Apple TV"},
+ "amazon": {"id": "UCQJWtTnAHhEG5w4uN0udnUQ", "name": "Amazon Prime Video"},
+ "mgm": {"id": "UCf5CjDJvsFvtVIhkfmKAwAA", "name": "Metro-Goldwyn-Mayer (MGM)"},
+ "crunchyroll": {
+ "id": "UC6pGDc4bFGD1_36IKv3FnYg",
+ "name": "Crunchyroll (Anime, Manga, and More)",
+ },
+}
async def fetch_tmdb(url: str, session: aiohttp.ClientSession) -> Optional[Dict[str, Any]]:
@@ -47,7 +73,7 @@ async def fetch_tmdb(url: str, session: aiohttp.ClientSession) -> Optional[Dict[
log.error(f"TMDB request failed with status: {response.status}")
return None
return orjson.loads(await response.read())
- except Exception as e:
+ except discord.HTTPException as e:
log.error(f"TMDB request error: {e}")
return None
@@ -68,8 +94,8 @@ def filter_media_results(
results: List[Dict[str, Any]], query: str, media_type: str
) -> List[Dict[str, Any]]:
"""Filter TMDB search results based on query and criteria."""
- # If you remove this, do not ever ask me to help you with this cog.
- # Google is your friend if you do not understand why this is banned.
+ # Banned for reasons of being offensive or not suitable for us norwegians to watch or discuss.
+ # Might add as default config in the future for removal if wanted to or update with more banned titles.
banned_titles = {
"22 july",
"22 july 2011",
@@ -230,8 +256,8 @@ async def build_embed(ctx, data, item_id, index, results, item_type="movie"):
return embed, view
-async def search_and_display(ctx, query: str, media_type: str, config):
- """Search TMDB and display results with a selection menu."""
+async def search_and_display(ctx, query: str, media_type: str):
+ """Search TMDB and display results with a paginated layout and selection buttons."""
await ctx.typing()
async with aiohttp.ClientSession() as session:
initial_data = await search_media(ctx, session, query, media_type)
@@ -247,12 +273,15 @@ async def search_and_display(ctx, query: str, media_type: str, config):
filtered_results = []
for data in page_results:
+ if isinstance(data, Exception):
+ log.warning(f"Page fetch failed: {data}")
+ continue
if not isinstance(data, dict) or "results" not in data:
continue
filtered_results.extend(filter_media_results(data["results"], query, media_type))
if not filtered_results:
- return await ctx.send(f"No results found for {query}")
+ return await ctx.send(f"No results found for `{query}`.")
if len(filtered_results) == 1:
data = await get_media_data(ctx, session, filtered_results[0]["id"], media_type)
@@ -265,53 +294,189 @@ async def search_and_display(ctx, query: str, media_type: str, config):
await ctx.send(embed=embed, view=view)
return
- response_in_box = await config.use_box()
- title = "What would you like to select?"
- header_text = f"{header(title, 'medium')}"
- pages = [
- f"{header_text}\n"
- + "\n".join(
- f"{i+j+1}. {r['title' if media_type == 'movie' else 'name']} "
- f"({r.get('release_date' if media_type == 'movie' else 'first_air_date', 'N/A')[:4]}) "
- f"({r.get('popularity', 0)})"
- for j, r in enumerate(filtered_results[i : i + 15])
+ title = "What would you like to select?\n-# Click on the button to views the media details."
+ header_text = f"{header(title, 'medium')}"
+
+ class MediaPaginator(discord.ui.LayoutView):
+ def __init__(self, ctx, filtered_results, media_type, items_per_page=12):
+ super().__init__(timeout=120)
+ self.ctx = ctx
+ self.filtered_results = self._sort_results(filtered_results, media_type)
+ self.media_type = media_type
+ self.session = aiohttp.ClientSession()
+ self.current_page = 0
+ self.items_per_page = items_per_page
+ self.message = None
+ self._update_content()
+
+ def _sort_results(self, results, media_type):
+ """Sort results by release date (descending, newest first)."""
+ date_key = "release_date" if media_type == "movie" else "first_air_date"
+
+ def get_date(item):
+ date_str = item.get(date_key, "0000-00-00")
+ try:
+ return datetime.strptime(date_str[:10], "%Y-%m-%d").timestamp()
+ except (ValueError, TypeError):
+ return float("-inf")
+
+ return sorted(results, key=get_date, reverse=True)
+
+ def _get_label(self, result, index):
+ """Generate display label for a media item."""
+ key_map = {
+ "movie": {"title": "title", "date": "release_date"},
+ "tv": {"title": "name", "date": "first_air_date"},
+ }
+ keys = key_map[self.media_type]
+ title = result.get(keys["title"], "Unknown")
+ date = result.get(keys["date"], "N/A")[:4]
+ popularity = round(result.get("popularity", 0), 1)
+ return f"{index + 1}. {title} ({date}) ({popularity})"
+
+ def _build_page_content(self):
+ """Build content for the current page."""
+ self.clear_items()
+ start_idx = self.current_page * self.items_per_page
+ end_idx = min(
+ (self.current_page + 1) * self.items_per_page, len(self.filtered_results)
)
- for i in range(0, len(filtered_results), 15)
- ]
-
- try:
- pages = [box(page, lang="prolog") if response_in_box else page for page in pages]
- except ImportError:
- log.warning("box formatting not available, using plain text")
- pass
+ page_results = self.filtered_results[start_idx:end_idx]
+ self.add_item(discord.ui.TextDisplay(header_text))
+
+ for i, result in enumerate(page_results):
+ label = self._get_label(result, start_idx + i)
+ section = discord.ui.Section(
+ discord.ui.TextDisplay(label), accessory=MediaButton(start_idx + i)
+ )
+ self.add_item(section)
+
+ def _add_navigation_buttons(self):
+ """Add navigation buttons if needed."""
+ if len(self.filtered_results) > self.items_per_page:
+ row = discord.ui.ActionRow()
+ if self.current_page > 0:
+ row.add_item(NavButton("prev"))
+ if (self.current_page + 1) * self.items_per_page < len(self.filtered_results):
+ row.add_item(NavButton("next"))
+ self.add_item(row)
+
+ def _update_content(self):
+ """Update the paginator's content."""
+ self._build_page_content()
+ self._add_navigation_buttons()
+
+ def _disable_all_buttons(self):
+ """Disable all buttons in the view."""
+ for item in self.children:
+ if isinstance(item, discord.ui.Section) and hasattr(item, "accessory"):
+ if isinstance(item.accessory, discord.ui.Button):
+ item.accessory.disabled = True
+ elif isinstance(item, discord.ui.ActionRow):
+ for child in item.children:
+ if isinstance(child, discord.ui.Button):
+ child.disabled = True
+
+ async def _cleanup(self):
+ """Clean up resources and update message."""
+ self._disable_all_buttons()
+ if not self.session.closed:
+ await self.session.close()
+ if self.message:
+ try:
+ await self.message.edit(content=None, view=self)
+ except discord.NotFound as e:
+ log.error(f"Message not found: {e}", exc_info=True)
+
+ async def on_timeout(self):
+ await self._cleanup()
+ super().stop()
+
+ async def interaction_check(self, interaction: discord.Interaction):
+ if interaction.user != self.ctx.author:
+ await interaction.response.send_message(
+ f"Only {self.ctx.author.mention} can use this.", ephemeral=True
+ )
+ return False
+ return True
+
+ class MediaButton(discord.ui.Button["MediaPaginator"]):
+ def __init__(self, index, label=None):
+ super().__init__(label=label or "Select", style=discord.ButtonStyle.primary)
+ self.index = index
+
+ async def _send_error(self, interaction, message, exc=None):
+ """Send an error message and log if an exception is provided."""
+ if exc:
+ log.error(
+ f"Error fetching media details for ID {self.view.filtered_results[self.index]['id']}: {exc}",
+ exc_info=True,
+ )
+ await interaction.response.send_message(message, ephemeral=True)
+
+ async def callback(self, interaction: discord.Interaction) -> None:
+ try:
+ data = await get_media_data(
+ self.view.ctx,
+ self.view.session,
+ self.view.filtered_results[self.index]["id"],
+ self.view.media_type,
+ )
+ if not data:
+ return await self._send_error(interaction, "Failed to fetch media details.")
+ except aiohttp.ClientConnectionError as e:
+ return await self._send_error(interaction, "Network error, please try again.", e)
+ except Exception as e:
+ return await self._send_error(interaction, "Error fetching media details.", e)
- menu = SimpleMenu(pages, use_select_menu=True, disable_after_timeout=True, timeout=120)
- await menu.start(ctx)
-
- try:
- msg = await ctx.bot.wait_for(
- "message",
- check=lambda m: m.author == ctx.author and m.channel == ctx.channel,
- timeout=60,
+ embed, view = await build_embed(
+ self.view.ctx,
+ data,
+ self.view.filtered_results[self.index]["id"],
+ self.index,
+ self.view.filtered_results,
+ item_type=self.view.media_type,
)
- if not msg.content.isdigit():
- return await ctx.send("Invalid input. Exiting.")
-
- index = int(msg.content) - 1
- if index < 0 or index >= len(filtered_results):
- return await ctx.send("Invalid selection. Exiting.")
- except ValueError:
- return await ctx.send("Invalid input. Exiting.")
- except asyncio.TimeoutError:
- return await ctx.send("Selection timed out. Exiting.")
-
- data = await get_media_data(ctx, session, filtered_results[index]["id"], media_type)
- if not data:
- return await ctx.send("Failed to fetch media details.")
- embed, view = await build_embed(
- ctx, data, filtered_results[index]["id"], index, filtered_results, item_type=media_type
- )
- await ctx.send(embed=embed, view=view)
+ await interaction.response.send_message(embed=embed, view=view)
+ await self.view._cleanup()
+
+ class NavButton(discord.ui.Button["MediaPaginator"]):
+ def __init__(self, direction):
+ super().__init__(
+ label="Previous" if direction == "prev" else "Next",
+ emoji="◀️" if direction == "prev" else "▶️",
+ style=discord.ButtonStyle.secondary,
+ custom_id=f"nav_{direction}",
+ )
+ self.direction = direction
+
+ async def _send_error(self, interaction, message, exc=None):
+ """Send an error message and log if an exception is provided."""
+ if exc:
+ log.error(
+ f"Error navigating page {self.view.current_page} ({self.direction}): {exc}",
+ exc_info=True,
+ )
+ await interaction.response.send_message(message, ephemeral=True)
+
+ async def callback(self, interaction: discord.Interaction) -> None:
+ try:
+ start_idx = self.view.current_page * self.view.items_per_page
+ end_idx = start_idx + self.view.items_per_page
+ if self.direction == "prev" and self.view.current_page > 0:
+ self.view.current_page -= 1
+ elif self.direction == "next" and end_idx < len(self.view.filtered_results):
+ self.view.current_page += 1
+
+ self.view._update_content()
+ await interaction.response.defer()
+ await self.view.message.edit(content=None, view=self.view)
+ except discord.HTTPException as e:
+ await self._send_error(interaction, "Error navigating, please try again.", e)
+
+ paginator = MediaPaginator(ctx, filtered_results, media_type)
+ message = await ctx.send(content="", view=paginator)
+ paginator.message = message
async def person_embed(ctx, query: str):