From a9fdf336033ab5fd465b567dc35fd4f0e00cf3f5 Mon Sep 17 00:00:00 2001 From: Dylan <24949824+brah@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:13:50 +1000 Subject: [PATCH 1/3] Fix resilience and lifecycle bugs - media_cache: fix a self-deadlock where update_cache held the non-reentrant cache_lock and then re-acquired it via save_cache_to_disk. Split out a lock-free _write_items_to_disk that the lock holder calls directly. - plexbot: move one-time init out of on_ready (which fires on every gateway reconnect, leaking sessions and re-crawling the cache) into setup_hook. Centralize session teardown in PlexBot.close(), which now stops the gateway before closing the shared Tautulli/TMDB sessions so background tasks can't use (or silently re-open) a closed session. - server_commands: run the git version check off the event loop via asyncio.gather(asyncio.to_thread(...)), once at startup; cancel the presence task on unload instead of closing the shared session; don't treat the "unknown" git sentinel as an outdated version. - media_commands: stop closing the shared Tautulli/TMDB session in cog_unload (the bot owns it now). - plex_data/recommendations: route Tautulli responses through check_response/get_response_data, guard against a null "data" payload, and use stdlib zoneinfo instead of pytz. Co-Authored-By: Claude Opus 4.8 (1M context) --- cogs/media_commands.py | 12 ++------ cogs/plex_data.py | 40 ++++++++++++------------- cogs/recommendations.py | 15 ++++------ cogs/server_commands.py | 65 +++++++++++++++++++++++------------------ media_cache.py | 55 +++++++++++++++++++--------------- plexbot.py | 59 +++++++++++++++++++++++++++---------- 6 files changed, 141 insertions(+), 105 deletions(-) diff --git a/cogs/media_commands.py b/cogs/media_commands.py index f34ced9..d7abf19 100644 --- a/cogs/media_commands.py +++ b/cogs/media_commands.py @@ -4,19 +4,15 @@ import difflib import logging import random -from io import BytesIO from config import config -import aiohttp import nextcord -from nextcord import File from nextcord.ext import commands, menus import qbittorrentapi from utilities import ( UserMappings, NoStopButtonMenuPages, - fetch_plex_image, prepare_thumbnail_for_embed, ) from tautulli_wrapper import Tautulli, TMDB @@ -272,11 +268,9 @@ def __init__(self, bot): self.plex_image = config.get("ui", "plex_image") logger.info("MediaCommands cog initialized.") - def cog_unload(self): - """Clean up resources when the cog is unloaded.""" - self.bot.loop.create_task(self.tautulli.close()) - if self.tmdb: - self.bot.loop.create_task(self.tmdb.close()) + # Note: the Tautulli/TMDB clients are shared across all cogs and owned by the + # bot (PlexBot.close()). A cog must not close them on unload, or it would tear + # down the session other cogs are still using. @commands.command() async def recent(self, ctx, amount: int = None): diff --git a/cogs/plex_data.py b/cogs/plex_data.py index 2a6733f..a48fb98 100644 --- a/cogs/plex_data.py +++ b/cogs/plex_data.py @@ -1,10 +1,10 @@ # cogs/plex_data.py import logging -import asyncio -import pytz -import tzlocal import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +import tzlocal import pandas as pd from typing import Optional, Dict, List, Any, Tuple @@ -30,24 +30,24 @@ def __init__(self, bot): self.media_cache: MediaCache = bot.shared_resources.get("media_cache") self.timezone = None # Timezone will be fetched from Tautulli or local timezone - async def get_tautulli_timezone(self) -> pytz.timezone: + async def get_tautulli_timezone(self) -> datetime.tzinfo: """Retrieve the timezone from Tautulli settings.""" response = await self.tautulli.api_call("get_settings") - if response["response"]["result"] != "success": + if not Tautulli.check_response(response): logger.warning("Failed to retrieve Tautulli settings. Using local timezone.") return tzlocal.get_localzone() - else: - settings = response["response"]["data"] - timezone_str = settings.get("default_timezone") - if timezone_str: - try: - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - logger.warning(f"Unknown timezone '{timezone_str}'. Using local timezone.") - return tzlocal.get_localzone() - else: - logger.warning("Timezone not found in Tautulli settings. Using local timezone.") - return tzlocal.get_localzone() + + settings = Tautulli.get_response_data(response, {}) + timezone_str = settings.get("default_timezone") + if not timezone_str: + logger.warning("Timezone not found in Tautulli settings. Using local timezone.") + return tzlocal.get_localzone() + + try: + return ZoneInfo(timezone_str) + except (ZoneInfoNotFoundError, ValueError): + logger.warning(f"Unknown timezone '{timezone_str}'. Using local timezone.") + return tzlocal.get_localzone() def get_utc_offset_str(self) -> str: """Returns a string representation of the UTC offset, e.g., '(UTC+10)'.""" @@ -108,12 +108,12 @@ async def fetch_watch_history_with_genres( } response = await self.tautulli.get_history(params=params) - if response["response"]["result"] != "success": + if not Tautulli.check_response(response): logger.error("Failed to retrieve watch history from Tautulli.") await ctx.send("Failed to retrieve watch history.") return [] - history_entries = response["response"]["data"]["data"] + history_entries = (Tautulli.get_response_data(response, {}) or {}).get("data", []) # Filter data by date range cutoff_timestamp = pd.Timestamp.now(tz=self.timezone) - pd.Timedelta(days=days) @@ -136,7 +136,7 @@ async def fetch_watch_history_with_genres( timestamp = entry.get("started") if timestamp: entry_time = ( - pd.to_datetime(timestamp, unit="s").tz_localize(pytz.utc).astimezone(self.timezone) + pd.to_datetime(timestamp, unit="s").tz_localize("UTC").astimezone(self.timezone) ) if entry_time < cutoff_timestamp: continue # Skip entries older than the cutoff diff --git a/cogs/recommendations.py b/cogs/recommendations.py index 1363ce3..a12bb16 100644 --- a/cogs/recommendations.py +++ b/cogs/recommendations.py @@ -7,13 +7,10 @@ from nextcord.ext import commands from config import config -from utilities import UserMappings, fetch_plex_image, prepare_thumbnail_for_embed +from utilities import UserMappings, prepare_thumbnail_for_embed from tautulli_wrapper import Tautulli from media_cache import MediaCache -import aiohttp -from io import BytesIO -from nextcord import File # Configure logging for this module logger = logging.getLogger("plexbot.recommendations") @@ -66,12 +63,12 @@ async def recommend(self, ctx, member: nextcord.Member = None): } response = await self.tautulli.get_history(params=params) - if response["response"]["result"] != "success": + if not Tautulli.check_response(response): await ctx.send("Failed to retrieve watch history from Plex.") logger.error("Failed to retrieve watch history from Tautulli.") return - history_entries = response["response"]["data"]["data"] + history_entries = (Tautulli.get_response_data(response, {}) or {}).get("data", []) if not history_entries: await ctx.send(f"No watch history found for {member.display_name}.") @@ -313,11 +310,11 @@ async def get_watched_users(self, rating_key, exclude_user=None, return_count=Fa """Retrieve a list of Discord usernames who have watched the media item.""" user_stats_response = await self.tautulli.get_item_user_stats(rating_key) - if user_stats_response["response"]["result"] != "success": + if not Tautulli.check_response(user_stats_response): logger.error(f"Failed to retrieve user stats for rating_key {rating_key}.") - return [] if not return_count else 0 + return 0 if return_count else [] - user_stats = user_stats_response["response"]["data"] + user_stats = Tautulli.get_response_data(user_stats_response, []) or [] watched_users = [] for user_stat in user_stats: plex_username = user_stat.get("username") diff --git a/cogs/server_commands.py b/cogs/server_commands.py index 36b2cc1..4916bcb 100644 --- a/cogs/server_commands.py +++ b/cogs/server_commands.py @@ -2,7 +2,6 @@ import asyncio import logging -from datetime import timedelta import aiohttp import nextcord @@ -26,15 +25,50 @@ def __init__(self, bot): self.tautulli: Tautulli = bot.shared_resources.get("tautulli") self.plex_embed_color = config.get("ui", "plex_embed_color", 0xE5A00D) self.plex_image = config.get("ui", "plex_image") + self._status_task = None self.bot.loop.create_task(self.initialize()) async def initialize(self): await self.bot.wait_until_ready() self.tautulli.initialize() - self.bot.loop.create_task(self.status_task()) + await self._log_startup_info() + self._status_task = self.bot.loop.create_task(self.status_task()) + + async def _log_startup_info(self): + """Log Tautulli connectivity and the bot's git version, once, at startup.""" + try: + r = await self.tautulli.get_home_stats() + + # These shell out to git (and the "latest" check hits the network), + # so run them in threads — concurrently — to avoid blocking the event loop. + local_commit, latest_commit = await asyncio.gather( + asyncio.to_thread(get_git_revision_short_hash), + asyncio.to_thread(get_git_revision_short_hash_latest), + ) + # The git helpers return the sentinel "unknown" on failure (e.g. offline), + # which is truthy — exclude it so a failed fetch isn't read as "outdated". + up_to_date = "" + known = local_commit not in ("", "unknown") and latest_commit not in ("", "unknown") + if known and local_commit != latest_commit: + up_to_date = "Version outdated. Consider running git pull" + + if Tautulli.check_response(r): + logger.info(f"Logged in as {self.bot.user}") + logger.info("Connection to Tautulli successful") + logger.info( + f"Current PlexBot version ID: {local_commit or 'unknown'}; " + f"latest: {latest_commit or 'unknown'}; {up_to_date}" + ) + else: + logger.critical("Connection to Tautulli failed") + except Exception as e: + logger.error(f"Error during startup info logging: {e}") def cog_unload(self): - self.bot.loop.create_task(self.tautulli.close()) + # Stop the background presence loop. The shared Tautulli client is owned by + # the bot (PlexBot.close()) and must not be closed here. + if self._status_task is not None: + self._status_task.cancel() async def status_task(self): """Background task to update the bot's presence.""" @@ -66,31 +100,6 @@ async def status_task(self): await asyncio.sleep(15) # Control how often to update the status - @commands.Cog.listener() - async def on_ready(self): - try: - r = await self.tautulli.get_home_stats() - status = r.get("response", {}).get("result") if r else None - - local_commit = get_git_revision_short_hash() - latest_commit = get_git_revision_short_hash_latest() - up_to_date = "" - if local_commit and latest_commit: - up_to_date = ( - "Version outdated. Consider running git pull" if local_commit != latest_commit else "" - ) - - if status == "success": - logger.info(f"Logged in as {self.bot.user}") - logger.info("Connection to Tautulli successful") - logger.info( - f"Current PlexBot version ID: {local_commit if local_commit else 'unknown'}; latest: {latest_commit if latest_commit else 'unknown'}; {up_to_date}" - ) - else: - logger.critical(f"Connection to Tautulli failed, result {status}") - except Exception as e: - logger.error(f"Error during bot initialization: {e}") - @commands.command() async def status(self, ctx): """Displays the status of the Plex server and other related information.""" diff --git a/media_cache.py b/media_cache.py index d55069c..9fb2bd3 100644 --- a/media_cache.py +++ b/media_cache.py @@ -7,7 +7,7 @@ import random from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Any, Set +from typing import Dict, List, Optional, Set import aiofiles # Configure logging @@ -112,8 +112,11 @@ async def update_cache(self) -> None: self.media_items = {str(item["rating_key"]): item for item in new_items} self.last_updated = datetime.now() - # Save the updated cache to disk - await self.save_cache_to_disk() + # We already hold cache_lock here, so snapshot and write directly. + # Calling save_cache_to_disk() would re-acquire the same non-reentrant + # asyncio.Lock from this task and deadlock. + items_snapshot = list(self.media_items.values()) + await self._write_items_to_disk(items_snapshot) logger.info(f"Media cache updated with {len(self.media_items)} items") else: logger.warning("No media items found during update") @@ -300,44 +303,50 @@ async def load_cache_from_disk(self) -> None: self.media_items = {} async def save_cache_to_disk(self) -> None: - """Save the media cache to disk with improved efficiency.""" + """Snapshot the cache under the lock, then write it to disk. + + Use this from callers that do NOT already hold ``cache_lock``. + """ + async with self.cache_lock: + # Snapshot inside the lock to avoid holding it during slow file I/O. + # The empty-cache guard lives in _write_items_to_disk (the single writer). + items_list = list(self.media_items.values()) + + await self._write_items_to_disk(items_list) + + async def _write_items_to_disk(self, items_list: List[Dict]) -> None: + """Atomically write a snapshot of media items to disk. + + This performs no locking; the caller passes a consistent snapshot. It is + therefore safe to call whether or not ``cache_lock`` is held, which is why + ``update_cache`` (which holds the lock) calls it directly — re-acquiring a + non-reentrant ``asyncio.Lock`` from the same task would deadlock. + """ self.cache_file_path.parent.mkdir(parents=True, exist_ok=True) + if not items_list: + logger.warning("Attempted to write empty cache to disk") + return try: logger.info(f"Saving media cache to {self.cache_file_path}") - async with self.cache_lock: - if not self.media_items: - logger.warning("Attempted to save empty cache to disk") - return - # Use a temporary file for safety - temp_file = self.cache_file_path.with_suffix(".tmp") + # Use a temporary file for safety, then atomically replace. + temp_file = self.cache_file_path.with_suffix(".tmp") - # Convert to a list first to avoid holding the lock during file writes - items_list = list(self.media_items.values()) - - # Release the lock before file operations - # Write in chunks to avoid memory issues + # Write in chunks, yielding to the event loop periodically. chunk_size = 100 async with aiofiles.open(temp_file, "w", encoding="utf-8") as f: - # Write opening bracket await f.write("[\n") - - # Write items in chunks for i, item in enumerate(items_list): json_str = json.dumps(item, ensure_ascii=False) if i < len(items_list) - 1: json_str += "," await f.write(json_str + "\n") - - # Periodically yield control back to the event loop if i % chunk_size == 0 and i > 0: await asyncio.sleep(0) - - # Write closing bracket await f.write("]\n") - # Atomically replace the cache file (os.replace is atomic on most platforms) + # os.replace is atomic on most platforms. if temp_file.exists(): os.replace(str(temp_file), str(self.cache_file_path)) diff --git a/plexbot.py b/plexbot.py index 636838d..8f60a85 100644 --- a/plexbot.py +++ b/plexbot.py @@ -17,7 +17,7 @@ # Import our new configuration and error handling systems from config import config -from errors import ErrorHandler, PlexBotError +from errors import ErrorHandler from tautulli_wrapper import Tautulli, TMDB from media_cache import MediaCache @@ -115,6 +115,43 @@ async def load_cogs(bot): logger.exception(f"Failed to load cog {cog_name}: {e}") +class PlexBot(commands.Bot): + """Bot subclass that owns one-time async initialization and graceful teardown. + + All setup lives in ``setup_hook`` rather than ``on_ready`` because ``on_ready`` + can fire multiple times (on every gateway reconnect/resume); doing heavy + initialization there would re-create clients, leak aiohttp sessions, and + re-populate the media cache on every reconnect. + """ + + async def setup_hook(self) -> None: + """Run once, after login but before connecting to the gateway.""" + self.shared_resources = await initialize_resources() + logger.info("Shared resources initialized") + await load_cogs(self) + + async def close(self) -> None: + """Shut down the gateway first, then close shared aiohttp sessions. + + super().close() stops the gateway and lets background loops (e.g. + ServerCommands.status_task, gated on is_closed()) observe shutdown, so we + don't tear the shared Tautulli/TMDB sessions down while a task is still + mid-request — which would otherwise error or silently re-open a new, + never-closed session via Tautulli._ensure_session(). + """ + await super().close() + + logger.info("Closing shared resources...") + resources = getattr(self, "shared_resources", None) or {} + for name in ("tautulli", "tmdb"): + client = resources.get(name) + if client is not None: + try: + await client.close() + except Exception as e: + logger.error(f"Error closing {name} client during shutdown: {e}") + + def main(): """Main entry point for the bot.""" logger.info("Starting PlexBot...") @@ -137,27 +174,17 @@ def main(): # Initialize bot with the prefix from configuration bot_prefix = config.get("core", "prefix") - bot = commands.Bot(command_prefix=[bot_prefix, bot_prefix.title()], intents=intents, help_command=None) + bot = PlexBot(command_prefix=[bot_prefix, bot_prefix.title()], intents=intents, help_command=None) - # Create the error handler + # Create and register the error handler (only adds a listener; no gateway needed). error_handler = ErrorHandler(bot) + error_handler.setup() + logger.info("Error handler initialized.") @bot.event async def on_ready(): - """Event triggered when the bot is ready to start.""" + """Triggered whenever the gateway (re)connects. Keep this idempotent and cheap.""" logger.info(f"Logged in as {bot.user} (ID: {bot.user.id})") - - # Initialize the error handler - error_handler.setup() - logger.info("Error handler initialized.") - - # Initialize shared resources asynchronously after bot is ready - bot.shared_resources = await initialize_resources() - logger.info("Shared resources initialized") - - # Dynamically load all cogs from the 'cogs' directory - await load_cogs(bot) - logger.info(f"PlexBot is now ready to serve {len(bot.guilds)} servers") # Run the bot From 58d7cb995178938f5ae3dfab3a09af5d3d592602 Mon Sep 17 00:00:00 2001 From: Dylan <24949824+brah@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:14:02 +1000 Subject: [PATCH 2/3] Remove dead code and add linting/CI - Delete the unused utilities.Config class and MyEmbedDescriptionPageSource, and drop unused imports across the package (all caught by ruff F401). - Add a conservative ruff config (F, E9) plus a CI workflow that runs ruff and a compileall smoke test; bump the black/ruff target to py312. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 29 ++++++++++++++++ cogs/visualizations.py | 5 +-- config/__init__.py | 3 +- errors/__init__.py | 4 +-- migration.py | 1 - pyproject.toml | 13 +++++++- utilities.py | 71 ++-------------------------------------- 7 files changed, 46 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7acae9d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint & syntax smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install "ruff==0.15.17" + + - name: Ruff lint + run: ruff check . + + - name: Compile all sources (syntax smoke test) + run: >- + python -m compileall -q + plexbot.py media_cache.py tautulli_wrapper.py utilities.py migration.py + config errors cogs diff --git a/cogs/visualizations.py b/cogs/visualizations.py index 3aef840..1dc3ebc 100644 --- a/cogs/visualizations.py +++ b/cogs/visualizations.py @@ -1,21 +1,18 @@ # cogs/visualizations.py import logging -import asyncio from io import BytesIO -from typing import Optional, Dict, List, Any, Union +from typing import Optional import nextcord from nextcord.ext import commands import matplotlib.pyplot as plt import seaborn as sns import pandas as pd -import pytz from config import config from media_cache import MediaCache from tautulli_wrapper import Tautulli -from utilities import UserMappings # Configure logging for this module logger = logging.getLogger("plexbot.visualizations") diff --git a/config/__init__.py b/config/__init__.py index 7f89add..b8bc221 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -2,9 +2,8 @@ import json import logging -import os from pathlib import Path -from typing import Any, Dict, Optional, Union, List, TypeVar, Generic +from typing import Any, Dict, Optional, List, TypeVar, Generic # Configure logging logger = logging.getLogger("plexbot.config") diff --git a/errors/__init__.py b/errors/__init__.py index 2716abb..2e33cd1 100644 --- a/errors/__init__.py +++ b/errors/__init__.py @@ -1,10 +1,8 @@ # errors/__init__.py import logging -import traceback -import sys from enum import Enum -from typing import Dict, Optional, Any, Union, Callable, TypeVar, Awaitable +from typing import Dict, Optional import nextcord from nextcord.ext import commands diff --git a/migration.py b/migration.py index 1761056..a5787b7 100644 --- a/migration.py +++ b/migration.py @@ -8,7 +8,6 @@ import importlib.util import json import logging -import os import shutil import sys from pathlib import Path diff --git a/pyproject.toml b/pyproject.toml index b34ee95..71b9176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.black] line-length = 108 -target-version = ['py38', 'py39', 'py310'] +target-version = ['py312'] skip-string-normalization = false skip-magic-trailing-comma = false include = '\.pyi?$' @@ -17,3 +17,14 @@ exclude = ''' | dist )/ ''' + +[tool.ruff] +line-length = 108 +target-version = "py312" + +[tool.ruff.lint] +# Conservative, high-signal ruleset: +# F -> pyflakes (unused imports, undefined names, f-string mistakes) +# E9 -> syntax / IndentationError / IO errors +# Broaden this (e.g. add "B", "I", "UP") once the codebase is clean for them. +select = ["E9", "F"] diff --git a/utilities.py b/utilities.py index 822a098..d3be07f 100644 --- a/utilities.py +++ b/utilities.py @@ -1,6 +1,5 @@ # utilities.py -import asyncio import json import logging import subprocess @@ -9,7 +8,6 @@ from typing import List, Dict, Any, Optional, Tuple import aiohttp -import nextcord from nextcord.ext import menus from nextcord import File @@ -17,44 +15,8 @@ logger.setLevel(logging.INFO) -class Config: - _config_data = None - - @classmethod - def load_config(cls, filename: str = "config.json") -> Dict[str, Any]: - """Load the configuration data from a JSON file.""" - if cls._config_data is None: - try: - with open(filename, "r", encoding="utf-8") as f: - cls._config_data = json.load(f) - logger.info("Configuration loaded successfully.") - except Exception as e: - logger.exception(f"Failed to load configuration: {e}") - cls._config_data = {} - return cls._config_data - - @classmethod - def get(cls, key: str, default: Any = None) -> Any: - """Get a configuration value.""" - config = cls.load_config() - return config.get(key, default) - - @classmethod - def save_config(cls, data: Dict[str, Any], filename: str = "config.json") -> None: - """Save the configuration data to a JSON file.""" - try: - with open(filename, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - cls._config_data = data - logger.info("Configuration saved successfully.") - except Exception as e: - logger.exception(f"Failed to save configuration: {e}") - - @classmethod - def reload_config(cls, filename: str = "config.json") -> Dict[str, Any]: - """Reload the configuration data from the JSON file.""" - cls._config_data = None - return cls.load_config(filename) +# Bot configuration lives in the `config` package (config/__init__.py). +# The old utilities.Config JSON reader was removed as dead code. class UserMappings: @@ -227,32 +189,3 @@ def __init__(self, source, timeout=60) -> None: self.add_item(menus.MenuPaginationButton(emoji=self.NEXT_PAGE)) # Disable buttons that are unavailable to be pressed at the start self._disable_unavailable_buttons() - - -class MyEmbedDescriptionPageSource(menus.ListPageSource): - def __init__(self, data, tautulli_ip): - super().__init__(data, per_page=2) - self.tautulli_ip = tautulli_ip - - async def format_page(self, menu, entries): - from config import config - embed = nextcord.Embed(title="Recently Added", color=config.get("ui", "plex_embed_color", 0xE5A00D)) - embed.set_footer(text=f"Page {menu.current_page + 1}/{self.get_max_pages()}") - - file = None - attachment_url = None - - for entry in entries: - embed.add_field(name="\u200b", value=entry["description"], inline=False) - thumb_key = entry.get("thumb_key", "") - - if thumb_key and not attachment_url: - file, attachment_url = await prepare_thumbnail_for_embed( - self.tautulli_ip, thumb_key, 200, 400 - ) - - if attachment_url: - embed.set_image(url=attachment_url) - return {"embed": embed, "file": file} - - return embed From c6d8afd8ea978348eae1b1326e5da216d10c9f21 Mon Sep 17 00:00:00 2001 From: Dylan <24949824+brah@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:14:13 +1000 Subject: [PATCH 3/3] Overhaul dependencies and require Python 3.12 - requirements.txt: declare only directly-imported deps. Add aiohttp and matplotlib (previously only transitive), drop the unused requests/numpy and the bogus "datetime" PyPI package, pin everything to current stable, and add tzdata so stdlib zoneinfo has an IANA database on platforms without a system copy (Windows, slim containers). - Document the real Python floor (3.12, required by nextcord 3.x). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- requirements.txt | 31 ++++++++++++++++++++++--------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ba97b9c..01e6b70 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Discord bot that interfaces with your Plex server through Tautulli's API, offe ### Requirements -- **Python 3.8+** +- **Python 3.12+** (required by nextcord 3.2.0) - A working **Tautulli** setup ([Tautulli GitHub](https://github.com/Tautulli/Tautulli)) - **Discord Bot Token** ([Discord Developer Portal](https://discord.com/developers/applications)) - Optional: **qBittorrent** for download tracking diff --git a/requirements.txt b/requirements.txt index 3310873..f0d0f52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,24 @@ -nextcord==3.0.1 +# Direct dependencies only (transitive deps like numpy, requests are pulled in +# automatically). Requires Python 3.12+ (nextcord 3.2.0 floor). + +# Discord +nextcord==3.2.0 nextcord-ext-menus==1.5.7 -qbittorrent-api==2025.2.0 -requests==2.32.3 -aiofiles==24.1.0 -pandas==2.2.3 -numpy==2.1.2 + +# HTTP / async I/O +aiohttp==3.14.1 +aiofiles==25.1.0 + +# External service clients +qbittorrent-api==2026.6.0 + +# Data & visualization +pandas==3.0.3 +matplotlib==3.11.0 seaborn==0.13.2 -pytz==2025.1 -tzlocal==5.3.1 -datetime==5.5 \ No newline at end of file + +# Timezones: zone lookups use stdlib zoneinfo, which needs an IANA database — +# tzdata supplies it on platforms without a system copy (Windows, slim containers); +# tzlocal detects the local zone. +tzdata==2026.2 +tzlocal==5.4