├── .env.example ├── .github └── workflows │ └── build-docker-image.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cogs ├── plex_core.py ├── sabnzbd.py └── uptime.py ├── data ├── config.json └── user_mapping.json ├── entrypoint.sh ├── main.py └── requirements.txt /.env.example: -------------------------------------------------------------------------------- 1 | # Discord Bot Configuration 2 | DISCORD_TOKEN= 3 | CHANNEL_ID= 4 | DISCORD_AUTHORIZED_USERS= 5 | 6 | # Plex Configuration 7 | PLEX_URL= 8 | PLEX_TOKEN= 9 | 10 | # SABnzbd Configuration 11 | SABNZBD_URL= 12 | SABNZBD_API_KEY= 13 | 14 | # Uptime Kuma Configuration 15 | UPTIME_URL= 16 | UPTIME_USERNAME= 17 | UPTIME_PASSWORD= 18 | UPTIME_MONITOR_ID= -------------------------------------------------------------------------------- /.github/workflows/build-docker-image.yaml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image 2 | 3 | permissions: 4 | contents: read 5 | packages: write 6 | id-token: write 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | release: 13 | types: 14 | - published 15 | workflow_dispatch: 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Docker Login 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Docker Metadata action 35 | id: docker_meta 36 | uses: docker/metadata-action@v5 37 | with: 38 | images: ghcr.io/${{ github.repository }} 39 | tags: | 40 | type=sha 41 | type=semver,pattern={{version}} 42 | type=ref,event=branch,prefix=main- 43 | flavor: | 44 | latest=${{ github.event_name == 'release' }} 45 | 46 | - name: Build and push Docker images 47 | uses: docker/build-push-action@v5 48 | with: 49 | push: true 50 | tags: ${{ steps.docker_meta.outputs.tags }} 51 | labels: ${{ steps.docker_meta.outputs.labels }} 52 | cache-from: type=gha 53 | cache-to: type=gha,mode=max 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .env 3 | __pycache__/ 4 | data/dashboard_message_id.json 5 | /.venv -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | RUN mkdir -p /app/defaults && cp -r /app/data/* /app/defaults/ 10 | 11 | RUN chmod +x entrypoint.sh 12 | 13 | ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"] 14 | 15 | CMD ["python", "main.py"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LEGACY 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlexWatch - Your Plex Dashboard in Discord 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) 4 | [![Version](https://img.shields.io/github/release/nichtlegacy/PlexWatch.svg?style=flat-square)](https://github.com/nichtlegacy/PlexWatch/releases/latest) 5 | ![Python](https://img.shields.io/badge/python-3.8+-yellow.svg) 6 | ![Discord.py](https://img.shields.io/badge/discord.py-2.0+-blueviolet.svg) 7 | ![Plex](https://img.shields.io/badge/plex-compatible-orange.svg) 8 | 9 | PlexWatch is a Discord bot that brings your Plex media server to life with a real-time dashboard. Monitor active streams, track SABnzbd downloads, and check server uptime—all directly in your Discord server. Designed for Plex enthusiasts, PlexWatch delivers a sleek, embed-based interface to keep you informed about your media ecosystem. 10 | 11 | 12 | ## Features 13 | 14 | - **Plex Monitoring**: Displays active streams with details like title, user, progress, quality, and player info (up to 8 streams). 15 | - **SABnzbd Integration**: Tracks ongoing downloads with progress, speed, and size. 16 | - **Uptime Tracking**: Shows server uptime over 24h, 7d, and 30d with percentage and duration. 17 | - **Customizable Dashboard**: Updates every minute with a clean Discord embed, fully configurable via JSON. 18 | - **Bot Presence**: Reflects Plex status and stream count in the bot's Discord status. 19 | - **Logging**: Detailed logs for debugging and tracking bot activity. 20 | 21 | 22 | ## Screenshots 23 | 24 | Here’s how PlexWatch looks in action: 25 | 26 | - **Dashboard Example**: 27 | ![PlexWatch Dashboard](https://i.imgur.com/vAVrjvh.png) 28 | 29 | - **Server Offline Status**: 30 | ![PlexWatch Dashboard](https://i.imgur.com/QSiFpWP.png) 31 | 32 | 33 | ## Project Structure 34 | 35 | ``` 36 | 📦 PlexWatch 37 | ├─ /cogs # Bot extensions (cogs) for modular functionality 38 | │ ├─ plex_core.py # Core Plex monitoring and dashboard logic 39 | │ ├─ sabnzbd.py # SABnzbd download tracking 40 | │ └─ uptime.py # Server uptime monitoring 41 | ├─ /data # Configuration and state files 42 | │ ├─ config.json # Bot settings (e.g., dashboard config, Plex sections) 43 | │ ├─ dashboard_message_id.json # Stores the ID of the dashboard message 44 | │ └─ user_mapping.json # Maps Plex usernames to display names 45 | ├─ /logs # Log files for debugging 46 | │ └─ plexwatch_debug.log # Rotated debug logs (updated daily, 7-day backup) 47 | ├─ .env # Environment variables (private, not tracked) 48 | ├─ .env.example # Template for .env configuration 49 | ├─ .gitignore # Git ignore rules (e.g., logs, .env) 50 | ├─ main.py # Entry point for the bot 51 | ├─ README.md # This file 52 | └─ requirements.txt # Python dependencies 53 | ``` 54 | 55 | 56 | ## Setup 57 | 58 | ### Prerequisites 59 | - Python 3.8+ 60 | - A Plex Media Server with API access 61 | - SABnzbd (optional, for download tracking) 62 | - Uptime Kuma (optional, for uptime monitoring) 63 | - A Discord bot token 64 | 65 | ### Installation local 66 | 1. **Clone the Repository**: 67 | ```bash 68 | git clone https://github.com/nichtlegacy/PlexWatch.git 69 | cd PlexWatch 70 | ``` 71 | 72 | 2. **Install Dependencies**: 73 | ```bash 74 | pip install -r requirements.txt 75 | ``` 76 | 77 | 3. **Configure Environment Variables**: 78 | - Copy `.env.example` to `.env`: 79 | ```bash 80 | cp .env.example .env 81 | ``` 82 | - Edit `.env` with your details (see below). 83 | 84 | 4. **Run the Bot**: 85 | ```bash 86 | python main.py 87 | ``` 88 | 89 | ### Installation docker 90 | 1. **Install the container and edit the required environment variables:** 91 | - See the full list of available envs further below. 92 | - Make sure to mount the volume for persistent config changes. 93 | ``` 94 | services: 95 | plexwatch: 96 | image: ghcr.io/nichtlegacy/plexwatch:latest 97 | container_name: plexwatch 98 | environment: 99 | - RUNNING_IN_DOCKER=true 100 | - DISCORD_TOKEN=your_discord_bot_token 101 | - DISCORD_AUTHORIZED_USERS=123456789012345678,987654321098765432 102 | - PLEX_URL=https://your-plex-server:32400 103 | - PLEX_TOKEN=your_plex_token 104 | - CHANNEL_ID=your_discord_channel_id 105 | 106 | # Optional 107 | # - SABNZBD_URL=http://192.168.1.1:8282 108 | # - SABNZBD_API_KEY=your_sabnzbd_api_key 109 | 110 | # Optional 111 | # - UPTIME_URL=http://192.168.1.1:3001 112 | # - UPTIME_USERNAME=your_kuma_username 113 | # - UPTIME_PASSWORD=your_kuma_password 114 | # - UPTIME_MONITOR_ID=your_monitor_id 115 | 116 | volumes: 117 | - ./plexwatch:/app/data 118 | restart: unless-stopped 119 | ``` 120 | 121 | 2. **Start the container to run the bot** 122 | 123 | ### Environment Variables (`.env`) 124 | The `.env` file stores sensitive configuration. Use the following format: 125 | 126 | ``` 127 | DISCORD_TOKEN=your_discord_bot_token 128 | DISCORD_AUTHORIZED_USERS=123456789012345678,987654321098765432 # Comma-separated user IDs 129 | PLEX_URL=https://your-plex-server:32400 130 | PLEX_TOKEN=your_plex_token 131 | CHANNEL_ID=your_discord_channel_id 132 | SABNZBD_URL=http://your-sabnzbd-server:8080 133 | SABNZBD_API_KEY=your_sabnzbd_api_key 134 | UPTIME_URL=https://your-uptime-kuma-server:3001 135 | UPTIME_USERNAME=your_uptime_kuma_username 136 | UPTIME_PASSWORD=your_uptime_kuma_password 137 | UPTIME_MONITOR_ID=your_monitor_id 138 | ``` 139 | 140 | - `DISCORD_TOKEN`: Your Discord bot token from the [Discord Developer Portal](https://discord.com/developers/applications). 141 | - `DISCORD_AUTHORIZED_USERS`: List of user IDs allowed to manage cogs (e.g., `!load`, `!unload`). 142 | - `PLEX_URL`: URL to your Plex server (include protocol and port). 143 | - `PLEX_TOKEN`: Your Plex API token (see [Plex Support](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)). 144 | - `CHANNEL_ID`: Discord channel ID where the dashboard embed appears. 145 | - `SABNZBD_URL` & `SABNZBD_API_KEY`: Optional, for SABnzbd integration (get API key from SABnzbd settings). 146 | - `UPTIME_URL`: Optional, URL to your Uptime Kuma server (e.g., https://uptime.example.com:3001) 147 | - `UPTIME_USERNAME`: Optional, Username for your Uptime Kuma instance 148 | - `UPTIME_PASSWORD`: Optional, Password for your Uptime Kuma instance 149 | - `UPTIME_MONITOR_ID`: Optional, The specific monitor ID from Uptime Kuma to track server uptime 150 | 151 | 152 | ## Configuration 153 | 154 | PlexWatch is customized via `/data/config.json`. Below is the structure with example values based on your setup: 155 | 156 | ```json 157 | { 158 | "dashboard": { 159 | "name": "Your Plex Dashboard", 160 | "icon_url": "https://example.com/icon.png", 161 | "footer_icon_url": "https://example.com/icon.png" 162 | }, 163 | "plex_sections": { 164 | "show_all": false, 165 | "sections": { 166 | "Movies": { 167 | "display_name": "Movies", 168 | "emoji": "🎥", 169 | "show_episodes": false 170 | }, 171 | "Shows": { 172 | "display_name": "Shows", 173 | "emoji": "📺", 174 | "show_episodes": true 175 | }, 176 | "Documentaries": { 177 | "display_name": "Documentaries", 178 | "emoji": "📚", 179 | "show_episodes": false 180 | } 181 | } 182 | }, 183 | "presence": { 184 | "sections": [ 185 | { 186 | "section_title": "Movies", 187 | "display_name": "Movies", 188 | "emoji": "🎥" 189 | }, 190 | { 191 | "section_title": "Shows", 192 | "display_name": "Shows", 193 | "emoji": "📺" 194 | } 195 | ], 196 | "offline_text": "🔴 Server Offline!", 197 | "stream_text": "{count} active Stream{s} 🟢" 198 | }, 199 | "cache": { 200 | "library_update_interval": 900 201 | }, 202 | "sabnzbd": { 203 | "keywords": ["AC3", "DL", "German", "1080p", "2160p", "4K", "GERMAN", "English"] 204 | } 205 | } 206 | ``` 207 | 208 | ### Configuration Details 209 | - **`dashboard`**: 210 | - `name`: Title of the Discord embed (e.g., "LEGACYVault Dashboard"). 211 | - `icon_url`: URL to the dashboard icon (displayed in author and thumbnail). 212 | - `footer_icon_url`: URL to the footer icon. 213 | 214 | - **`plex_sections`**: 215 | - `show_all`: If `true`, all Plex library sections are shown; if `false`, only listed sections are included. 216 | - `sections`: Defines displayed Plex libraries. 217 | - Keys match your Plex library titles (e.g., "Movies", "Shows"). 218 | - `display_name`: Name shown in the dashboard. 219 | - `emoji`: Emoji for visual flair (e.g., "🎥" for movies). 220 | - `show_episodes`: If `true`, episode counts are shown (useful for series). 221 | 222 | - **`presence`**: 223 | - `sections`: Libraries shown in the bot’s Discord status when idle. 224 | - `section_title`: Matches `plex_sections` keys. 225 | - `display_name`: Name in the status. 226 | - `emoji`: Emoji in the status. 227 | - `offline_text`: Bot status when Plex is offline. 228 | - `stream_text`: Bot status with active streams (e.g., "3 active Streams 🟢"). 229 | 230 | - **`cache`**: 231 | - `library_update_interval`: Time (in seconds) between Plex library cache updates (default: 900 = 15 minutes). 232 | 233 | - **`sabnzbd`**: 234 | - `keywords`: List of keywords used to trim download names. The bot cuts off the name at the first occurrence of any keyword (e.g., "Movie.Name.German.1080p" becomes "Movie Name"), then limits it to 40 characters (truncating with "..." if longer). This ensures clean, readable names in the dashboard. 235 | 236 | ## User Mapping 237 | 238 | The `/data/user_mapping.json` file allows you to personalize Plex usernames by mapping them to custom display names shown in the dashboard. This keeps the interface clean and user-friendly. 239 | 240 | **Example `user_mapping.json`**: 241 | ```json 242 | { 243 | "nichtlegacy": "LEGACY", 244 | "plexfan99": "Fan", 245 | "moviebuff": "Buff" 246 | } 247 | ``` 248 | 249 | - **Key**: The exact Plex username (case-sensitive). 250 | - **Value**: The custom name displayed in the dashboard. 251 | 252 | If a username is listed, its mapped name is used (e.g., "Alex" instead of "user123"); otherwise, the original Plex username is shown. 253 | 254 | ## Logging 255 | Logs are stored in `/logs/plexwatch_debug.log`: 256 | - **Format**: `timestamp - logger_name - level - message` (e.g., `2025-03-12 20:37:34,092 - plexwatch_bot - INFO - Bot is online`). 257 | - **Rotation**: Daily, with a 7-day backup (older logs are overwritten). 258 | - **Levels**: DEBUG, INFO, WARNING, ERROR – useful for troubleshooting. 259 | 260 | Example log entries: 261 | ``` 262 | 2025-03-12 20:37:34,509 - plexwatch_bot - INFO - Loaded cog: plex_core 263 | 2025-03-12 20:37:35,050 - plexwatch_bot - ERROR - Failed to connect to Plex server: Timeout 264 | ``` 265 | 266 | ## Commands 267 | PlexWatch uses Discord slash commands (synced on startup): 268 | - `/load `: Load a cog (e.g., `plex_core`). 269 | - `/unload `: Unload a cog. 270 | - `/reload `: Reload a cog. 271 | - `/cogs`: List all available cogs with their status. 272 | 273 | *Note*: Only users listed in `DISCORD_AUTHORIZED_USERS` can use these commands. 274 | 275 | ## Acknowledgements 276 | - [Plex](https://www.plex.tv) - For providing an excellent media server platform that powers the core monitoring capabilities of PlexWatch. 277 | - [PlexAPI](https://github.com/pkkid/python-plexapi) - A Python library for interacting with Plex servers, essential for stream and library tracking. 278 | - [discord.py](https://github.com/Rapptz/discord.py) - The backbone of the Discord bot functionality, making embeds and real-time updates possible. 279 | - [SABnzbd](https://sabnzbd.org) - A powerful download manager integrated to monitor ongoing downloads within the dashboard. 280 | - [Uptime Kuma](https://uptime.kuma.pet) - A lightweight tool for monitoring server uptime, integrated for availability tracking over 24h, 7d, and 30d. 281 | 282 | ## Contributing 283 | 284 | Contributions are welcome! Please feel free to submit a Pull Request. 285 | 286 | ## License 287 | 288 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 289 | 290 | ## Star History 291 | 292 | 293 | 294 | 295 | 296 | Star History Chart 297 | 298 | 299 | -------------------------------------------------------------------------------- /cogs/plex_core.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands, tasks 3 | from plexapi.server import PlexServer 4 | import time 5 | import json 6 | import os 7 | import logging 8 | from datetime import datetime, timedelta 9 | from typing import Optional, Dict, Any, List 10 | 11 | from dotenv import load_dotenv 12 | 13 | RUNNING_IN_DOCKER = os.getenv("RUNNING_IN_DOCKER", "false").lower() == "true" 14 | 15 | if not RUNNING_IN_DOCKER: 16 | load_dotenv() 17 | 18 | class PlexCore(commands.Cog): 19 | def __init__(self, bot: commands.Bot) -> None: 20 | self.bot = bot 21 | self.logger = logging.getLogger("plexwatch_bot.plex") 22 | 23 | # Load environment variables 24 | self.PLEX_URL = os.getenv("PLEX_URL") 25 | self.PLEX_TOKEN = os.getenv("PLEX_TOKEN") 26 | channel_id = os.getenv("CHANNEL_ID") 27 | if channel_id is None: 28 | self.logger.error("CHANNEL_ID not set in .env file") 29 | raise ValueError("CHANNEL_ID must be set in .env") 30 | self.CHANNEL_ID = int(channel_id) 31 | 32 | # File paths 33 | self.current_dir = os.path.dirname(os.path.abspath(__file__)) 34 | self.MESSAGE_ID_FILE = os.path.join(self.current_dir, "..", "data", "dashboard_message_id.json") 35 | self.USER_MAPPING_FILE = os.path.join(self.current_dir, "..", "data", "user_mapping.json") 36 | self.CONFIG_FILE = os.path.join(self.current_dir, "..", "data", "config.json") 37 | 38 | # Initialize state 39 | self.config = self._load_config() 40 | self.plex: Optional[PlexServer] = None 41 | self.plex_start_time: Optional[float] = None 42 | self.dashboard_message_id = self._load_message_id() 43 | self.last_scan = datetime.now() 44 | self.offline_since: Optional[datetime] = None 45 | self.stream_debug = False 46 | 47 | # Cache settings 48 | self.library_cache: Dict[str, Dict[str, Any]] = {} 49 | self.last_library_update: Optional[datetime] = None 50 | self.library_update_interval = self.config.get("cache", {}).get("library_update_interval", 900) 51 | 52 | self.user_mapping = self._load_user_mapping() 53 | self.update_status.start() 54 | self.update_dashboard.start() 55 | 56 | def _load_config(self) -> Dict[str, Any]: 57 | """Load configuration from config.json with defaults if unavailable.""" 58 | default_config = { 59 | "dashboard": {"name": "Plex Dashboard", "icon_url": "", "footer_icon_url": ""}, 60 | "plex_sections": {"show_all": True, "sections": {}}, 61 | "presence": { 62 | "sections": [], 63 | "offline_text": "🔴 Server Offline!", 64 | "stream_text": "{count} active Stream{s} 🟢", 65 | }, 66 | "cache": {"library_update_interval": 900}, 67 | } 68 | try: 69 | with open(self.CONFIG_FILE, "r", encoding="utf-8") as f: 70 | config = json.load(f) 71 | return {**default_config, **config} # Merge with defaults 72 | except (FileNotFoundError, json.JSONDecodeError) as e: 73 | self.logger.error(f"Failed to load config: {e}. Using defaults.") 74 | return default_config 75 | 76 | def _load_message_id(self) -> Optional[int]: 77 | """Load the dashboard message ID from file.""" 78 | if not os.path.exists(self.MESSAGE_ID_FILE): 79 | return None 80 | try: 81 | with open(self.MESSAGE_ID_FILE, "r", encoding="utf-8") as f: 82 | data = json.load(f) 83 | return int(data.get("message_id")) 84 | except (json.JSONDecodeError, ValueError) as e: 85 | self.logger.error(f"Failed to load message ID: {e}") 86 | return None 87 | 88 | def _save_message_id(self, message_id: int) -> None: 89 | """Save the dashboard message ID to file.""" 90 | try: 91 | with open(self.MESSAGE_ID_FILE, "w", encoding="utf-8") as f: 92 | json.dump({"message_id": message_id}, f) 93 | except OSError as e: 94 | self.logger.error(f"Failed to save message ID: {e}") 95 | 96 | def _load_user_mapping(self) -> Dict[str, str]: 97 | """Load user mapping from JSON file.""" 98 | try: 99 | with open(self.USER_MAPPING_FILE, "r", encoding="utf-8") as f: 100 | return json.load(f) 101 | except (FileNotFoundError, json.JSONDecodeError) as e: 102 | self.logger.error(f"Failed to load user mapping: {e}") 103 | return {} 104 | 105 | def connect_to_plex(self) -> Optional[PlexServer]: 106 | """Attempt to establish a connection to the Plex server.""" 107 | try: 108 | plex = PlexServer(self.PLEX_URL, self.PLEX_TOKEN) 109 | if self.plex_start_time is None: 110 | self.plex_start_time = time.time() 111 | return plex 112 | except Exception as e: # Using generic Exception as plexapi doesn't expose a single base exception 113 | self.logger.error(f"Failed to connect to Plex server: {e}") 114 | self.plex_start_time = None 115 | return None 116 | 117 | def get_server_info(self) -> Dict[str, Any]: 118 | """Retrieve current Plex server status and statistics.""" 119 | self.plex = self.connect_to_plex() 120 | if not self.plex: 121 | return self.get_offline_info() 122 | try: 123 | self.offline_since = None 124 | return { 125 | "status": "🟢 Online", 126 | "uptime": self.calculate_uptime(), 127 | "library_stats": self.get_library_stats(), 128 | "active_users": self.get_active_streams(), 129 | "current_streams": self.plex.sessions(), 130 | } 131 | except Exception as e: 132 | self.logger.error(f"Error retrieving server info: {e}") 133 | return self.get_offline_info() 134 | 135 | def calculate_uptime(self) -> str: 136 | """Calculate Plex server uptime as a formatted string.""" 137 | if not self.plex_start_time: 138 | return "Offline" 139 | total_minutes = int((time.time() - self.plex_start_time) / 60) 140 | hours = total_minutes // 60 141 | minutes = total_minutes % 60 142 | return "99+ Hours" if hours > 99 else f"{hours:02d}:{minutes:02d}" 143 | 144 | def get_library_stats(self) -> Dict[str, Dict[str, Any]]: 145 | """Fetch and cache Plex library statistics, preserving config order.""" 146 | current_time = datetime.now() 147 | if ( 148 | self.last_library_update 149 | and (current_time - self.last_library_update).total_seconds() <= self.library_update_interval 150 | ): 151 | return self.library_cache 152 | 153 | # Ensure Plex connection is established 154 | if not self.plex: 155 | self.plex = self.connect_to_plex() 156 | if not self.plex: 157 | return self.library_cache 158 | 159 | try: 160 | sections = {section.title: section for section in self.plex.library.sections()} 161 | stats: Dict[str, Dict[str, Any]] = {} 162 | plex_config = self.config["plex_sections"] 163 | configured_sections = plex_config["sections"] 164 | 165 | if not plex_config["show_all"]: 166 | for title in configured_sections: 167 | if title in sections: 168 | config = configured_sections[title] 169 | section = sections[title] 170 | stats[title] = self._build_section_stats(section, config) 171 | else: 172 | for title in configured_sections: 173 | if title in sections: 174 | config = configured_sections[title] 175 | section = sections[title] 176 | stats[title] = self._build_section_stats(section, config) 177 | for title, section in sections.items(): 178 | if title not in configured_sections: 179 | stats[title] = { 180 | "count": len(section.all()), 181 | "episodes": 0, 182 | "display_name": title, 183 | "emoji": "🎬", 184 | "show_episodes": False, 185 | } 186 | 187 | self.library_cache = stats 188 | self.last_library_update = current_time 189 | self.logger.info(f"Library stats updated and cached (interval: {self.library_update_interval}s)") 190 | return stats 191 | except Exception as e: 192 | self.logger.error(f"Error updating library stats: {e}") 193 | return self.library_cache 194 | 195 | def _build_section_stats(self, section, config: Dict[str, Any]) -> Dict[str, Any]: 196 | """Build statistics dictionary for a Plex section.""" 197 | return { 198 | "count": len(section.all()), 199 | "episodes": sum(show.leafCount for show in section.all()) if config["show_episodes"] and hasattr(section, "all") else 0, 200 | "display_name": config["display_name"], 201 | "emoji": config["emoji"], 202 | "show_episodes": config["show_episodes"], 203 | } 204 | 205 | def get_active_streams(self) -> List[str]: 206 | """Retrieve formatted information about active Plex streams.""" 207 | if not self.plex: 208 | return [] 209 | sessions = self.plex.sessions() 210 | if self.stream_debug: 211 | self.logger.debug(f"Found {len(sessions)} active sessions") 212 | return [ 213 | stream_info 214 | for idx, session in enumerate(sessions, start=1) 215 | if (stream_info := self.format_stream_info(session, idx)) 216 | and (self.stream_debug and self.logger.debug(f"Formatted Stream Info:\n{stream_info}\n{'='*50}") or True) 217 | ] 218 | 219 | def _debug_session_details(self, session, idx: int) -> None: 220 | """Log detailed Plex session information for debugging.""" 221 | self.logger.debug(f"\n{'='*50}\nSession {idx} Raw Data:") 222 | self.logger.debug(f"Type: {session.type}") 223 | self.logger.debug(f"Title: {session.title}") 224 | self.logger.debug(f"User: {session.usernames[0] if session.usernames else 'Unknown'}") 225 | self.logger.debug(f"Player: {session.players[0].product if session.players else 'Unknown'}") 226 | 227 | if hasattr(session, "media") and session.media: 228 | media = session.media[0] 229 | self.logger.debug("\nMedia Info:") 230 | self.logger.debug(f"Resolution: {getattr(media, 'videoResolution', 'Unknown')}") 231 | self.logger.debug(f"Bitrate: {getattr(media, 'bitrate', 'Unknown')}") 232 | self.logger.debug(f"Duration: {session.duration if hasattr(session, 'duration') else 'Unknown'}") 233 | 234 | if hasattr(media, "parts") and media.parts: 235 | self.logger.debug("\nAudio Streams:") 236 | for part in media.parts: 237 | if hasattr(part, "streams"): 238 | for stream in part.streams: 239 | if getattr(stream, "streamType", None) == 2: # Audio stream 240 | self.logger.debug(f"Language: {getattr(stream, 'language', 'Unknown')}") 241 | self.logger.debug(f"Language Code: {getattr(stream, 'languageCode', 'Unknown')}") 242 | self.logger.debug(f"Selected: {getattr(stream, 'selected', False)}") 243 | 244 | if hasattr(session, "viewOffset"): 245 | progress = (session.viewOffset / session.duration * 100) if session.duration else 0 246 | self.logger.debug("\nProgress Info:") 247 | self.logger.debug(f"View Offset: {session.viewOffset}") 248 | self.logger.debug(f"Progress: {progress:.2f}%") 249 | 250 | self.logger.debug("\nTranscode Info:") 251 | self.logger.debug(f"Transcoding: {session.transcodeSession is not None}") 252 | if session.transcodeSession: 253 | self.logger.debug(f"Transcode Data: {vars(session.transcodeSession)}") 254 | 255 | def format_stream_info(self, session, idx: int) -> str: 256 | """Format Plex stream details into a displayable string.""" 257 | try: 258 | user = session.usernames[0] if session.usernames else "Unbekannt" 259 | displayed_user = self.user_mapping.get(user, user) 260 | section_title = getattr(session, "librarySectionTitle", "Unknown") 261 | stats = self.get_library_stats() 262 | content_emoji = stats.get(section_title, {}).get("emoji") or ( 263 | "🎵" if getattr(session, "type", "") == "track" else 264 | "🎥" if getattr(session, "type", "") in ["movie", None] else "📺" 265 | ) 266 | 267 | title = self._get_formatted_title(session) 268 | progress_percent = ( 269 | (session.viewOffset / session.duration * 100) 270 | if hasattr(session, "viewOffset") and hasattr(session, "duration") and session.duration 271 | else 0 272 | ) 273 | is_paused = getattr(session.players[0], "state", "") == "paused" if hasattr(session, "players") and session.players else False 274 | progress_display = "⏸️" if is_paused else f"[{'▓' * int(progress_percent / 10)}{'░' * (10 - int(progress_percent / 10))}] {progress_percent:.1f}%" 275 | 276 | current_time = self._format_time(str(timedelta(milliseconds=session.viewOffset or 0)).split(".")[0], session.duration or 0) 277 | total_time = self._format_time(str(timedelta(milliseconds=session.duration or 0)).split(".")[0], session.duration or 0) 278 | 279 | media = session.media[0] if hasattr(session, "media") and session.media else None 280 | 281 | # Handle quality display based on content type 282 | if getattr(session, "type", "") == "track": 283 | # For music, show audio quality 284 | audio_stream = next((stream for part in media.parts for stream in part.streams if stream.streamType == 2), None) if media else None 285 | quality = f"{getattr(audio_stream, 'bitDepth', '')}bit" if audio_stream and getattr(audio_stream, 'bitDepth', None) else "" 286 | if audio_stream and getattr(audio_stream, 'samplingRate', None): 287 | quality += f" {int(audio_stream.samplingRate/1000)}kHz" if quality else f"{int(audio_stream.samplingRate/1000)}kHz" 288 | quality = quality if quality else "Audio" 289 | else: 290 | # For video content, show video quality 291 | quality = f"{getattr(media, 'videoResolution', '1080')}p" if media else "1080p" 292 | quality = quality[:-1] if quality.endswith("pp") else "4K" if quality in ["4kp", "4Kp"] else quality 293 | 294 | transcode_session = getattr(session, "transcodeSession", None) 295 | transcode_emoji = "🔄" if transcode_session else "⏯️" 296 | bitrate = ( 297 | f"{transcode_session.bitrate / 1000:.1f} Mbps" if transcode_session and getattr(transcode_session, "bitrate", None) 298 | else f"{media.bitrate / 1000:.1f} Mbps" if media and getattr(media, "bitrate", None) 299 | else "" 300 | ) 301 | 302 | product_name = ( 303 | session.players[0].product.replace("Plex for ", "").replace("Infuse-Library", "Infuse") 304 | if hasattr(session, "players") and session.players 305 | else "Unknown" 306 | ) 307 | 308 | return ( 309 | f"**```{content_emoji} {title} | {displayed_user}\n" 310 | f"└─ {progress_display} | {current_time}/{total_time}\n" 311 | f" └─ {transcode_emoji} {quality} {bitrate} | {product_name}```**" 312 | ) 313 | except Exception as e: 314 | self.logger.error(f"Error formatting stream info: {e}") 315 | return f"```❓ Stream could not be loaded (# {idx})```" 316 | 317 | def _format_time(self, time_str: str, duration: int) -> str: 318 | """Format time string based on content duration.""" 319 | parts = time_str.split(":") 320 | less_than_hour = (duration // 1000) < 3600 321 | return f"{int(parts[-2]):02d}:{int(parts[-1]):02d}" if less_than_hour else f"{int(parts[0]):01d}:{int(parts[1]):02d}:{int(parts[2]):02d}" 322 | 323 | def _get_formatted_title(self, session) -> str: 324 | """Format content title based on its type.""" 325 | if hasattr(session, "type") and session.type == "track": 326 | # Handle music tracks 327 | artist = getattr(session, "grandparentTitle", "Unknown Artist") 328 | track = getattr(session, "title", "Unknown Track") 329 | return f"{artist} - {track}" 330 | elif hasattr(session, "grandparentTitle"): 331 | # Handle TV shows 332 | series_title = session.grandparentTitle.split(":")[0].split("-")[0].strip() 333 | episode_info = ( 334 | f"S{session.parentIndex:02d}E{session.index:02d}" 335 | if hasattr(session, "parentIndex") and hasattr(session, "index") 336 | else "" 337 | ) 338 | return f"{series_title} - {episode_info}" 339 | # Handle movies 340 | year = f" ({session.year})" if hasattr(session, "year") and session.year else "" 341 | return f"{session.title}{year}" 342 | 343 | def get_offline_info(self) -> Dict[str, Any]: 344 | """Generate server info when Plex is offline, respecting config order.""" 345 | current_time = discord.utils.utcnow() 346 | if not self.offline_since: 347 | self.offline_since = current_time 348 | stats: Dict[str, Dict[str, Any]] = {} 349 | plex_config = self.config["plex_sections"] 350 | configured_sections = plex_config["sections"] 351 | 352 | for title in configured_sections: 353 | config = configured_sections[title] 354 | stats[title] = { 355 | "count": 0, 356 | "episodes": 0 if config["show_episodes"] else 0, 357 | "display_name": config["display_name"], 358 | "emoji": config["emoji"], 359 | "show_episodes": config["show_episodes"], 360 | } 361 | return { 362 | "status": "🔴 Offline", 363 | "offline_since": self.offline_since, 364 | "library_stats": stats, 365 | "active_users": [], 366 | "current_streams": [], 367 | } 368 | 369 | @tasks.loop(minutes=5) 370 | async def update_status(self) -> None: 371 | """Update bot presence with Plex status and stream count.""" 372 | try: 373 | info = self.get_server_info() 374 | active_streams = len(info["active_users"]) 375 | presence_config = self.config["presence"] 376 | stats = info["library_stats"] 377 | 378 | if info["status"] != "🟢 Online": 379 | activity_text = presence_config["offline_text"] 380 | status = discord.Status.dnd 381 | elif active_streams > 0: 382 | activity_text = presence_config["stream_text"].format( 383 | count=active_streams, s="s" if active_streams != 1 else "" 384 | ) 385 | status = discord.Status.online 386 | else: 387 | presence_parts = [ 388 | f"{'{:,.0f}'.format(stats[section['section_title']]['count']).replace(',', '.')} {section['display_name']} {section['emoji']}" 389 | for section in presence_config["sections"] 390 | if section["section_title"] in stats 391 | ] 392 | activity_text = " | ".join(presence_parts) if presence_parts else "No streams or sections configured" 393 | status = discord.Status.online 394 | 395 | await self.bot.change_presence(activity=discord.CustomActivity(name=activity_text), status=status) 396 | self.logger.info(f"Status updated: {activity_text} ({status})") 397 | except Exception as e: 398 | self.logger.error(f"Error updating status: {e}") 399 | 400 | @tasks.loop(minutes=1) 401 | async def update_dashboard(self) -> None: 402 | """Update Discord dashboard with Plex, SABnzbd, and Uptime data.""" 403 | channel = self.bot.get_channel(self.CHANNEL_ID) 404 | if not channel: 405 | return 406 | 407 | try: 408 | info = self.get_server_info() 409 | sabnzbd_cog = self.bot.get_cog("SABnzbd") 410 | if sabnzbd_cog: 411 | info["downloads"] = await sabnzbd_cog.get_sabnzbd_info() 412 | 413 | uptime_cog = self.bot.get_cog("Uptime") 414 | if uptime_cog: 415 | uptime_data = uptime_cog.get_uptime_data() 416 | info["uptime_24h"] = ( 417 | f"{uptime_data[0]:.1f}% ({uptime_cog.format_online_time(uptime_data[1])})" 418 | if uptime_data[0] is not None else "No data" 419 | ) 420 | info["uptime_7d"] = ( 421 | f"{uptime_data[2]:.1f}% ({uptime_cog.format_online_time(uptime_data[3])})" 422 | if uptime_data[2] is not None else "No data" 423 | ) 424 | info["uptime_30d"] = ( 425 | f"{uptime_data[4]:.1f}% ({uptime_cog.format_online_time(uptime_data[5])})" 426 | if uptime_data[4] is not None else "No data" 427 | ) 428 | info["last_offline"] = uptime_data[6] if uptime_data[6] else "Not available" 429 | 430 | embed = await self.create_dashboard_embed(info) 431 | await self._update_dashboard_message(channel, embed) 432 | except Exception as e: 433 | self.logger.error(f"Error updating dashboard: {e}") 434 | 435 | async def create_dashboard_embed(self, info: Dict[str, Any]) -> discord.Embed: 436 | """Create a dashboard embed reflecting server status.""" 437 | dashboard_config = self.config["dashboard"] 438 | embed = discord.Embed( 439 | title="Server is currently Offline! :warning:" if info["status"] != "🟢 Online" else "Server is currently Online! :white_check_mark:", 440 | color=discord.Color.red() if info["status"] != "🟢 Online" else discord.Color.green(), 441 | timestamp=discord.utils.utcnow(), 442 | ) 443 | 444 | if info["status"] != "🟢 Online": 445 | offline_since_str, time_diff_str = "Unknown", "Unknown duration" 446 | if info["offline_since"] and isinstance(info["offline_since"], datetime): 447 | offline_since_dt = info["offline_since"] 448 | offline_since_str = (offline_since_dt + timedelta(hours=1)).strftime("%d.%m.%Y %H:%M") 449 | time_diff = discord.utils.utcnow() - offline_since_dt 450 | days, hours, minutes = time_diff.days, time_diff.seconds // 3600, (time_diff.seconds % 3600) // 60 451 | time_diff_str = ( 452 | f"{days} day{'s' if days != 1 else ''}, {hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''} ago" 453 | if days > 0 else 454 | f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''} ago" 455 | if hours > 0 else 456 | f"{minutes} minute{'s' if minutes != 1 else ''} ago" 457 | if minutes > 0 else "Just now" 458 | ) 459 | embed.add_field(name="Offline since:", value=f"```{offline_since_str}\n{time_diff_str}```", inline=False) 460 | 461 | uptime_cog = self.bot.get_cog("Uptime") 462 | if uptime_cog and "uptime_24h" in info and info["uptime_24h"] != "No data": 463 | embed.add_field(name="Uptime (24h)", value=f"```{info['uptime_24h']}```", inline=True) 464 | embed.add_field(name="Uptime (7 days)", value=f"```{info['uptime_7d']}```", inline=True) 465 | embed.add_field(name="Uptime (30 days)", value=f"```{info['uptime_30d']}```", inline=True) 466 | else: 467 | await self._add_embed_fields(embed, info) 468 | 469 | embed.set_author(name=dashboard_config["name"], icon_url=dashboard_config["icon_url"]) 470 | embed.set_thumbnail(url=dashboard_config["icon_url"]) 471 | embed.set_footer(text="Last updated", icon_url=dashboard_config["footer_icon_url"]) 472 | return embed 473 | 474 | async def _add_embed_fields(self, embed: discord.Embed, info: Dict[str, Any]) -> None: 475 | """Add fields to the dashboard embed when server is online.""" 476 | embed.add_field(name="Server Uptime 🖥️", value=f"```{info['uptime']}```", inline=True) 477 | embed.add_field(name="", value="", inline=True) # Spacer 478 | embed.add_field(name="", value="", inline=True) # Spacer 479 | 480 | stats = info["library_stats"] 481 | plex_config = self.config["plex_sections"] 482 | configured_sections = plex_config["sections"] 483 | 484 | sections_to_display = configured_sections if not plex_config["show_all"] else {**configured_sections, **{k: None for k in stats if k not in configured_sections}} 485 | for title in sections_to_display: 486 | if title in stats: 487 | section_data = stats[title] 488 | display_name = f"{section_data['display_name']} {section_data['emoji']}" 489 | value = f"```{'{:,.0f}'.format(section_data['count']).replace(',', '.')}```" 490 | embed.add_field(name=display_name, value=value, inline=True) 491 | if section_data["show_episodes"]: 492 | embed.add_field( 493 | name=f"{section_data['display_name']} Episodes 📺", 494 | value=f"```{'{:,.0f}'.format(section_data['episodes']).replace(',', '.')}```", 495 | inline=True, 496 | ) 497 | 498 | if info["active_users"]: 499 | stream_count = len(info["active_users"]) 500 | 501 | streams_limited = info["active_users"][:8] 502 | streams_text = " ".join(streams_limited) 503 | embed.add_field( 504 | name=f"{stream_count} current Stream{'s' if stream_count != 1 else ''}:" + (f" (showing 8 of {stream_count})" if stream_count > 8 else ""), 505 | value=streams_text, 506 | inline=False, 507 | ) 508 | else: 509 | embed.add_field(name="Current Streams:", value="💤 *No active streams currently*", inline=False) 510 | 511 | sabnzbd_cog = self.bot.get_cog("SABnzbd") 512 | if info.get("downloads", {}).get("downloads"): 513 | downloads = info["downloads"]["downloads"][:4] 514 | download_count = len(info["downloads"]["downloads"]) 515 | downloads_text = "\n".join( 516 | sabnzbd_cog.format_download_info(download, i) 517 | for i, download in enumerate(downloads) 518 | ) 519 | embed.add_field( 520 | name=f"{download_count} current Download{'s' if download_count != 1 else ''}:", 521 | value=downloads_text, 522 | inline=False, 523 | ) 524 | embed.add_field(name="Downloads 📥", value=f"```{self._calculate_total_size(downloads)}```", inline=True) 525 | embed.add_field(name="Free Space 💾", value=f"```{info['downloads']['diskspace1']}```", inline=True) 526 | embed.add_field(name="Total Space 🗄️", value=f"```{info['downloads']['diskspacetotal1']}```", inline=True) 527 | else: 528 | embed.add_field(name="Current Downloads:", value="💤 *No active downloads currently*", inline=False) 529 | 530 | def _calculate_total_size(self, downloads: List[Dict[str, Any]]) -> str: 531 | """Calculate total download size in human-readable format.""" 532 | total_size_mb = 0 533 | for download in downloads: 534 | if download["size"] == "Unknown": 535 | continue 536 | value, unit = download["size"].split() 537 | value = float(value) 538 | total_size_mb += value / 1024 if unit == "KB" else value if unit == "MB" else value * 1024 if unit == "GB" else 0 539 | return f"{total_size_mb / 1024:.2f} GB" if total_size_mb >= 1024 else f"{total_size_mb:.2f} MB" 540 | 541 | async def _update_dashboard_message(self, channel: discord.TextChannel, embed: discord.Embed) -> None: 542 | """Update or create the dashboard message in the specified channel.""" 543 | if self.dashboard_message_id: 544 | try: 545 | message = await channel.fetch_message(self.dashboard_message_id) 546 | await message.edit(embed=embed) 547 | self.logger.debug("Dashboard message updated successfully") 548 | except discord.NotFound: 549 | self.logger.warning("Dashboard message not found, creating new one") 550 | self.dashboard_message_id = None 551 | 552 | if not self.dashboard_message_id: 553 | message = await channel.send(embed=embed) 554 | self.dashboard_message_id = message.id 555 | self._save_message_id(self.dashboard_message_id) 556 | self.logger.info(f"New dashboard message created with ID: {self.dashboard_message_id}") 557 | 558 | async def setup(bot: commands.Bot) -> None: 559 | """Set up the PlexCore cog for the bot.""" 560 | await bot.add_cog(PlexCore(bot)) -------------------------------------------------------------------------------- /cogs/sabnzbd.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import aiohttp 3 | import logging 4 | import os 5 | import json 6 | from typing import Dict, Any, List 7 | from dotenv import load_dotenv 8 | from urllib.parse import urljoin 9 | 10 | RUNNING_IN_DOCKER = os.getenv("RUNNING_IN_DOCKER", "false").lower() == "true" 11 | 12 | if not RUNNING_IN_DOCKER: 13 | load_dotenv() 14 | 15 | class SABnzbd(commands.Cog): 16 | def __init__(self, bot: commands.Bot) -> None: 17 | self.bot = bot 18 | self.logger = logging.getLogger("plexwatch_bot.sabnzbd") 19 | self.SABNZBD_URL = os.getenv("SABNZBD_URL") 20 | self.SABNZBD_API_KEY = os.getenv("SABNZBD_API_KEY") 21 | 22 | # Path to config.json 23 | self.current_dir = os.path.dirname(os.path.abspath(__file__)) 24 | self.CONFIG_FILE = os.path.join(self.current_dir, "..", "data", "config.json") 25 | self.keywords = self._load_keywords() 26 | 27 | def _load_keywords(self) -> List[str]: 28 | """Load SABnzbd keywords from config.json with defaults if unavailable.""" 29 | default_keywords = ["AC3", "DL", "German", "1080p", "2160p", "4K", "GERMAN"] 30 | try: 31 | with open(self.CONFIG_FILE, "r", encoding="utf-8") as f: 32 | config = json.load(f) 33 | return config.get("sabnzbd", {}).get("keywords", default_keywords) 34 | except (FileNotFoundError, json.JSONDecodeError) as e: 35 | self.logger.error(f"Failed to load SABnzbd keywords: {e}. Using defaults.") 36 | return default_keywords 37 | 38 | async def get_sabnzbd_info(self) -> Dict[str, Any]: 39 | """Fetch download queue and disk space information from SABnzbd API.""" 40 | url = urljoin(self.SABNZBD_URL, "api") 41 | params = {"apikey": self.SABNZBD_API_KEY, "output": "json", "mode": "queue"} 42 | try: 43 | async with aiohttp.ClientSession() as session: 44 | async with session.get(url, params=params) as response: 45 | if not response.ok: 46 | error_text = await response.text() 47 | self.logger.error(f"SABnzbd API error - Status {response.status}: {error_text}") 48 | return {"downloads": [], "diskspace1": "Unknown", "diskspacetotal1": "Unknown"} 49 | data = await response.json() 50 | 51 | queue = data.get("queue", {}) 52 | slots = queue.get("slots", []) 53 | disk_space = queue.get("diskspace1", "Unknown") 54 | total_disk_space = queue.get("diskspacetotal1", "Unknown") 55 | 56 | if not slots: 57 | return { 58 | "downloads": [], 59 | "diskspace1": self._format_size_diskspace(disk_space), 60 | "diskspacetotal1": self._format_size_diskspace(total_disk_space, "TB"), 61 | } 62 | 63 | downloads = [ 64 | { 65 | "name": item.get("filename", "Unknown"), 66 | "progress": float(item.get("percentage", "0")), 67 | "timeleft": item.get("timeleft", "Unknown"), 68 | "speed": self._format_speed_from_kbps(queue.get("kbpersec", "0")), 69 | "size": self._format_size(item.get("size", "Unknown")), 70 | } 71 | for item in slots 72 | ] 73 | return { 74 | "downloads": downloads, 75 | "diskspace1": self._format_size_diskspace(disk_space), 76 | "diskspacetotal1": self._format_size_diskspace(total_disk_space, "TB"), 77 | } 78 | except aiohttp.ClientError as e: 79 | self.logger.error(f"SABnzbd API request failed: {e}") 80 | return {"downloads": [], "diskspace1": "Unknown", "diskspacetotal1": "Unknown"} 81 | 82 | def _format_size(self, size: str) -> str: 83 | """Convert size to human-readable format with appropriate units.""" 84 | try: 85 | size_float = float(size) 86 | for unit in ["B", "KB", "MB", "GB", "TB"]: 87 | if size_float < 1024: 88 | return f"{size_float:.2f} {unit}" 89 | size_float /= 1024 90 | return f"{size_float:.2f} TB" # Fallback for very large sizes 91 | except ValueError: 92 | return size 93 | 94 | def _format_speed_from_kbps(self, kbpersec: str) -> str: 95 | """Convert speed from KB/s to human-readable format.""" 96 | try: 97 | speed_float = float(kbpersec) 98 | for unit in ["KB", "MB", "GB", "TB"]: 99 | if speed_float < 1024: 100 | return f"{speed_float:.2f} {unit}/s" 101 | speed_float /= 1024 102 | return f"{speed_float:.2f} TB/s" 103 | except ValueError: 104 | return f"{kbpersec} KB/s" 105 | 106 | def _format_size_diskspace(self, size: str, unit: str = "GB") -> str: 107 | """Format disk space size into specified unit (GB or TB).""" 108 | try: 109 | size_float = float(size) 110 | if unit == "TB": 111 | size_float /= 1024 112 | return f"{size_float:.2f}{unit}" 113 | except ValueError: 114 | return size 115 | 116 | def format_download_info(self, download: Dict[str, Any], index: int) -> str: 117 | """Format download details into a Discord-friendly string with numbered emoji.""" 118 | try: 119 | number_emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"] 120 | emoji = number_emojis[index] if index < len(number_emojis) else "➡️" 121 | progress_percent = float(download["progress"]) 122 | progress_bar = f"[{'▓' * int(progress_percent / 10)}{'░' * (10 - int(progress_percent / 10))}]" 123 | name = download["name"] 124 | min_pos = min( 125 | [name.find(kw) for kw in self.keywords if kw in name], default=len(name) 126 | ) 127 | name = name[:min_pos].strip() 128 | if len(name) > 40: 129 | name = name[:37] + "..." 130 | 131 | return ( 132 | f"**```{emoji} {name}\n" 133 | f"└─ {progress_bar} {progress_percent:.1f}% | {download['timeleft']} remaining\n" 134 | f" └─ 📊 {download['speed']} | Size: {download['size']}```**" 135 | ) 136 | except (ValueError, KeyError) as e: 137 | self.logger.error(f"Error formatting download info: {e}") 138 | return "```❓ Download could not be loaded```" 139 | 140 | async def setup(bot: commands.Bot) -> None: 141 | """Set up the SABnzbd cog for the bot.""" 142 | await bot.add_cog(SABnzbd(bot)) -------------------------------------------------------------------------------- /cogs/uptime.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | import logging 3 | import os 4 | from uptime_kuma_api import UptimeKumaApi, UptimeKumaException 5 | from typing import Tuple, Optional 6 | from dotenv import load_dotenv 7 | 8 | RUNNING_IN_DOCKER = os.getenv("RUNNING_IN_DOCKER", "false").lower() == "true" 9 | 10 | if not RUNNING_IN_DOCKER: 11 | load_dotenv() 12 | 13 | class Uptime(commands.Cog): 14 | def __init__(self, bot: commands.Bot) -> None: 15 | self.bot = bot 16 | self.logger = logging.getLogger("plexwatch_bot.uptime") 17 | self.api_url = os.getenv("UPTIME_URL") 18 | self.username = os.getenv("UPTIME_USERNAME") 19 | self.password = os.getenv("UPTIME_PASSWORD") 20 | self.monitor_id = int(os.getenv("UPTIME_MONITOR_ID")) 21 | 22 | def get_uptime_data(self) -> Tuple[ 23 | Optional[float], Optional[float], Optional[float], 24 | Optional[float], Optional[float], Optional[float], Optional[str] 25 | ]: 26 | """Fetch uptime statistics from Uptime Kuma for specified monitor.""" 27 | try: 28 | with UptimeKumaApi(self.api_url) as api: 29 | api.login(self.username, self.password) 30 | beats_24h = api.get_monitor_beats(self.monitor_id, 24) 31 | beats_7d = api.get_monitor_beats(self.monitor_id, 7 * 24) 32 | beats_30d = api.get_monitor_beats(self.monitor_id, 30 * 24) 33 | 34 | def calculate_uptime_and_online_time(beats: list, period_hours: int) -> Tuple[float, float]: 35 | up_count = sum(1 for beat in beats if beat["status"].name == "UP") 36 | uptime_percent = (up_count / len(beats)) * 100 if beats else 0.0 37 | online_minutes = up_count * (period_hours * 60 / len(beats)) if beats else 0 38 | return uptime_percent, online_minutes 39 | 40 | uptime_24h, online_24h = calculate_uptime_and_online_time(beats_24h, 24) 41 | uptime_7d, online_7d = calculate_uptime_and_online_time(beats_7d, 7 * 24) 42 | uptime_30d, online_30d = calculate_uptime_and_online_time(beats_30d, 30 * 24) 43 | 44 | last_offline = next( 45 | (beat["time"] for beat in reversed(beats_30d) if beat["status"].name == "DOWN"), 46 | None, 47 | ) 48 | return uptime_24h, online_24h, uptime_7d, online_7d, uptime_30d, online_30d, last_offline 49 | except UptimeKumaException as e: 50 | self.logger.error(f"Uptime Kuma API error: {e}") 51 | return None, None, None, None, None, None, None 52 | 53 | def format_online_time(self, minutes: float) -> str: 54 | """Convert online time in minutes to a human-readable hours and minutes string.""" 55 | hours = int(minutes // 60) 56 | remaining_minutes = int(minutes % 60) 57 | return f"{hours}h {remaining_minutes}m" if hours > 0 else f"{remaining_minutes}m" 58 | 59 | async def setup(bot: commands.Bot) -> None: 60 | """Set up the Uptime cog for the bot.""" 61 | await bot.add_cog(Uptime(bot)) -------------------------------------------------------------------------------- /data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "dashboard": { 3 | "name": "Your Plex Dashboard", 4 | "icon_url": "https://example.com/icon.png", 5 | "footer_icon_url": "https://example.com/icon.png" 6 | }, 7 | "plex_sections": { 8 | "show_all": false, 9 | "sections": { 10 | "Movies": { 11 | "display_name": "Movies", 12 | "emoji": "🎥", 13 | "show_episodes": false 14 | }, 15 | "Shows": { 16 | "display_name": "Shows", 17 | "emoji": "📺", 18 | "show_episodes": true 19 | }, 20 | "Documentaries": { 21 | "display_name": "Documentaries", 22 | "emoji": "📚", 23 | "show_episodes": false 24 | } 25 | } 26 | }, 27 | "presence": { 28 | "sections": [ 29 | { 30 | "section_title": "Movies", 31 | "display_name": "Movies", 32 | "emoji": "🎥" 33 | }, 34 | { 35 | "section_title": "Shows", 36 | "display_name": "Shows", 37 | "emoji": "📺" 38 | } 39 | ], 40 | "offline_text": "🔴 Server Offline!", 41 | "stream_text": "{count} active Stream{s} 🟢" 42 | }, 43 | "cache": { 44 | "library_update_interval": 900 45 | }, 46 | "sabnzbd": { 47 | "keywords": ["AC3", "DL", "German", "1080p", "2160p", "4K", "GERMAN", "English"] 48 | } 49 | } -------------------------------------------------------------------------------- /data/user_mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "nichtlegacy": "LEGACY", 3 | "plexfan99": "Fan", 4 | "moviebuff": "Buff" 5 | } -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | CONFIG_DIR="/app/data" 5 | 6 | # Ensure config directory exists 7 | mkdir -p "$CONFIG_DIR" 8 | 9 | # Copy default config files only if the directory is empty 10 | if [ -z "$(ls -A $CONFIG_DIR)" ]; then 11 | echo "No config files found in $CONFIG_DIR, copying defaults..." 12 | cp -r /app/defaults/* "$CONFIG_DIR/" 13 | else 14 | echo "Config files already exist in $CONFIG_DIR, skipping copy." 15 | fi 16 | 17 | # Start the main process 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | import os 4 | import logging 5 | from logging.handlers import TimedRotatingFileHandler 6 | from dotenv import load_dotenv 7 | import asyncio 8 | import platform 9 | from typing import List 10 | 11 | # Configure event loop policy for Windows compatibility 12 | if platform.system() == "Windows": 13 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 14 | 15 | RUNNING_IN_DOCKER = os.getenv("RUNNING_IN_DOCKER", "false").lower() == "true" 16 | 17 | if not RUNNING_IN_DOCKER: 18 | load_dotenv() # Load environment variables from .env file 19 | 20 | TOKEN = os.getenv("DISCORD_TOKEN") 21 | if not TOKEN: 22 | raise ValueError("DISCORD_TOKEN must be set in .env file") 23 | AUTHORIZED_USERS: List[int] = [ 24 | int(user_id) for user_id in os.getenv("DISCORD_AUTHORIZED_USERS", "").split(",") if user_id 25 | ] 26 | 27 | # Setup logging with rotation 28 | LOG_DIR = "logs" 29 | os.makedirs(LOG_DIR, exist_ok=True) 30 | bot_logger = logging.getLogger("plexwatch_bot") 31 | bot_logger.setLevel(logging.DEBUG) 32 | 33 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 34 | 35 | if RUNNING_IN_DOCKER: 36 | console_handler = logging.StreamHandler() 37 | console_handler.setFormatter(formatter) 38 | bot_logger.addHandler(console_handler) 39 | else: 40 | file_handler = TimedRotatingFileHandler( 41 | filename=os.path.join(LOG_DIR, "plexwatch_debug.log"), 42 | when="midnight", 43 | interval=1, 44 | backupCount=7, 45 | encoding="utf-8", 46 | ) 47 | file_handler.setFormatter(formatter) 48 | bot_logger.addHandler(file_handler) 49 | 50 | # Initialize bot with intents and command prefix 51 | intents = discord.Intents.all() 52 | bot = commands.Bot(command_prefix="!", intents=intents) 53 | tree = bot.tree 54 | 55 | def is_authorized(interaction: discord.Interaction) -> bool: 56 | """Check if the user is authorized to execute privileged commands.""" 57 | return interaction.user.id in AUTHORIZED_USERS 58 | 59 | async def load_cogs() -> None: 60 | """Load all Python files in the 'cogs' directory as bot extensions.""" 61 | for filename in os.listdir("./cogs"): 62 | if filename.endswith(".py") and not filename.startswith("__"): 63 | try: 64 | await bot.load_extension(f"cogs.{filename[:-3]}") 65 | bot_logger.info(f"Loaded cog: {filename[:-3]}") 66 | except commands.ExtensionError as e: 67 | bot_logger.error(f"Failed to load cog {filename[:-3]}: {e}") 68 | 69 | @bot.event 70 | async def on_ready() -> None: 71 | """Handle bot startup: log readiness, load cogs, and sync command tree.""" 72 | bot_logger.info(f"Bot is online as {bot.user.name}") 73 | await load_cogs() 74 | try: 75 | await tree.sync() 76 | bot_logger.info("Command tree synced") 77 | except discord.HTTPException as e: 78 | bot_logger.error(f"Failed to sync command tree: {e}") 79 | 80 | @tree.command(name="load", description="Load a specific cog") 81 | async def load(interaction: discord.Interaction, cog: str) -> None: 82 | """Load a specified cog if the user is authorized.""" 83 | await interaction.response.defer(ephemeral=True) 84 | if not is_authorized(interaction): 85 | await interaction.followup.send("❌ You are not authorized to execute this command.") 86 | return 87 | try: 88 | await bot.load_extension(f"cogs.{cog}") 89 | await interaction.followup.send(f"✅ Cog `{cog}` loaded successfully!") 90 | bot_logger.info(f"Cog {cog} loaded by {interaction.user}") 91 | except commands.ExtensionError as e: 92 | await interaction.followup.send(f"❌ Error loading cog `{cog}`: `{e}`") 93 | bot_logger.error(f"Error loading cog {cog}: {e}") 94 | 95 | @tree.command(name="unload", description="Unload a specific cog") 96 | async def unload(interaction: discord.Interaction, cog: str) -> None: 97 | """Unload a specified cog if the user is authorized.""" 98 | await interaction.response.defer(ephemeral=True) 99 | if not is_authorized(interaction): 100 | await interaction.followup.send("❌ You are not authorized to execute this command.") 101 | return 102 | try: 103 | await bot.unload_extension(f"cogs.{cog}") 104 | await interaction.followup.send(f"✅ Cog `{cog}` unloaded successfully!") 105 | bot_logger.info(f"Cog {cog} unloaded by {interaction.user}") 106 | except commands.ExtensionError as e: 107 | await interaction.followup.send(f"❌ Error unloading cog `{cog}`: `{e}`") 108 | bot_logger.error(f"Error unloading cog {cog}: {e}") 109 | 110 | @tree.command(name="reload", description="Reload a specific cog") 111 | async def reload(interaction: discord.Interaction, cog: str) -> None: 112 | """Reload a specified cog if the user is authorized.""" 113 | await interaction.response.defer(ephemeral=True) 114 | if not is_authorized(interaction): 115 | await interaction.followup.send("❌ You are not authorized to execute this command.") 116 | return 117 | try: 118 | await bot.reload_extension(f"cogs.{cog}") 119 | await interaction.followup.send(f"✅ Cog `{cog}` reloaded successfully!") 120 | bot_logger.info(f"Cog {cog} reloaded by {interaction.user}") 121 | except commands.ExtensionError as e: 122 | await interaction.followup.send(f"❌ Error reloading cog `{cog}`: `{e}`") 123 | bot_logger.error(f"Error reloading cog {cog}: {e}") 124 | 125 | @tree.command(name="cogs", description="List all available cogs") 126 | async def list_cogs(interaction: discord.Interaction) -> None: 127 | """Display a list of available and loaded cogs in an embed.""" 128 | cogs_list = [f[:-3] for f in os.listdir("./cogs") if f.endswith(".py") and not f.startswith("__")] 129 | loaded_cogs = [ext.split(".")[-1] for ext in bot.extensions.keys()] 130 | 131 | embed = discord.Embed(title="Cog Manager - Overview", color=discord.Color.blue()) 132 | for cog in cogs_list: 133 | status = "🟢 Loaded" if cog in loaded_cogs else "🔴 Not Loaded" 134 | embed.add_field(name=cog, value=status, inline=False) 135 | 136 | await interaction.response.send_message(embed=embed, ephemeral=True) 137 | 138 | if __name__ == "__main__": 139 | try: 140 | bot.run(TOKEN) 141 | except discord.LoginFailure as e: 142 | bot_logger.error(f"Failed to start bot: Invalid token - {e}") 143 | except Exception as e: 144 | bot_logger.error(f"Unexpected error starting bot: {e}") -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py 2 | plexapi 3 | requests 4 | aiohttp 5 | python-dateutil 6 | uptime_kuma_api 7 | python-dotenv --------------------------------------------------------------------------------