Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 3 additions & 9 deletions cogs/media_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
40 changes: 20 additions & 20 deletions cogs/plex_data.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)'."""
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
15 changes: 6 additions & 9 deletions cogs/recommendations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}.")
Expand Down Expand Up @@ -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")
Expand Down
65 changes: 37 additions & 28 deletions cogs/server_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import asyncio
import logging
from datetime import timedelta

import aiohttp
import nextcord
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
5 changes: 1 addition & 4 deletions cogs/visualizations.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
3 changes: 1 addition & 2 deletions config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 1 addition & 3 deletions errors/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading