From e991862a01407f1e9c09efa9a5a9c4117873f894 Mon Sep 17 00:00:00 2001 From: ccahill27 Date: Thu, 29 May 2025 15:04:36 -0400 Subject: [PATCH 1/2] Initial working version --- .dockerignore | 3 ++ Dockerfile | 18 +++++++++ bot/config.py | 21 ++++++++++ bot/library_cache.py | 61 +++++++++++++++++++++++++++++ bot/main.py | 61 +++++++++++++++++++++++++++++ bot/plex_client.py | 66 +++++++++++++++++++++++++++++++ bot/recommender.py | 89 ++++++++++++++++++++++++++++++++++++++++++ bot/tautulli_client.py | 73 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 9 +++++ requirements.txt | 5 +++ 10 files changed, 406 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 bot/config.py create mode 100644 bot/library_cache.py create mode 100644 bot/main.py create mode 100644 bot/plex_client.py create mode 100644 bot/recommender.py create mode 100644 bot/tautulli_client.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dec9d62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Dockerfile + +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 +# Set working directory +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy bot source code +COPY bot/ ./bot + +# Set default command +CMD ["python", "bot/main.py"] + diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..ca1efc4 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,21 @@ +# bot/config.py + +import os +from dotenv import load_dotenv + +load_dotenv() + +# --- Plex --- +PLEX_BASE_URL = os.getenv("PLEX_BASE_URL") +PLEX_TOKEN = os.getenv("PLEX_TOKEN") + +# --- OpenAI --- +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4") + +# --- Caching --- +CACHE_FILE = os.getenv("CACHE_FILE", "bot/library_cache.json") +MAX_CACHE_AGE_HOURS = int(os.getenv("MAX_CACHE_AGE_HOURS", 6)) + +# --- Discord Bot --- +DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") diff --git a/bot/library_cache.py b/bot/library_cache.py new file mode 100644 index 0000000..f84e5c6 --- /dev/null +++ b/bot/library_cache.py @@ -0,0 +1,61 @@ +# bot/library_cache.py + +import json +import os +import time +from typing import List +from plex_client import PlexClient + +CACHE_FILE = 'bot/library_cache.json' +MAX_CACHE_AGE_HOURS = 6 + +class LibraryCache: + def __init__(self): + if self.is_cache_stale(): + print("📦 Plex cache is stale or missing — refreshing...") + self.refresh_cache() + else: + print("✅ Using cached Plex library data.") + self.load_cache() + + def is_cache_stale(self) -> bool: + if not os.path.exists(CACHE_FILE): + return True + age_hours = (time.time() - os.path.getmtime(CACHE_FILE)) / 3600 + return age_hours > MAX_CACHE_AGE_HOURS + + def refresh_cache(self): + plex = PlexClient() + media = plex.server.library.sections() + + new_data = [] + for section in media: + if section.type in ['movie', 'show']: + for item in section.all(): + new_data.append({ + 'title': item.title.strip(), + 'type': section.type, + 'genres': [g.tag for g in getattr(item, 'genres', [])], + 'year': getattr(item, 'year', None) + }) + + self.data = new_data + self.save_cache() + + def save_cache(self): + with open(CACHE_FILE, 'w', encoding='utf-8') as f: + json.dump(self.data, f, ensure_ascii=False, indent=2) + + def load_cache(self): + with open(CACHE_FILE, 'r', encoding='utf-8') as f: + self.data = json.load(f) + + def get_titles_by_type(self, media_type: str) -> List[str]: + return [item['title'] for item in self.data if item['type'] == media_type] + + def search(self, title: str, media_type: str = None) -> bool: + """Check if title exists in library (case-insensitive, optional type filter).""" + for item in self.data: + if item['title'].lower() == title.lower() and (media_type is None or item['type'] == media_type): + return True + return False diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..0c7e820 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,61 @@ +# bot/main.py + +import discord +from discord.ext import commands +from discord import app_commands +from tautulli_client import TautulliClient +from recommender import Recommender +from config import DISCORD_BOT_TOKEN + +intents = discord.Intents.default() +client = commands.Bot(command_prefix="!", intents=intents) +tree = client.tree + +tautulli = TautulliClient() +recommender = Recommender() + +@client.event +async def on_ready(): + print(f"🤖 Logged in as {client.user} (ID: {client.user.id})") + await tree.sync() + print("✅ Slash commands synced.") + +@tree.command(name="recommend", description="Recommend movies or TV shows for a Plex user") +@app_commands.describe( + username="Plex username (case-sensitive)", + media_type="Choose 'movie' or 'tv'" +) +async def recommend(interaction: discord.Interaction, username: str, media_type: str): + await interaction.response.defer() # Acknowledge the request + + try: + #users = tautulli._request("get_users") + #print("👤 Tautulli Users:") + #for u in users: + # print(f"{u.get('username')}") + # Convert Discord input to Plex-friendly type + + history = tautulli.get_user_watch_history(username=username, media_type=media_type.lower()) + if not history: + await interaction.followup.send(f"⚠️ No recent {media_type} history found for `{username}`.") + return + + watched_titles = [entry["title"] for entry in history] + result = recommender.recommend(watched_titles, media_type=media_type.lower()) + + response = f"🎬 **{media_type.title()} Recommendations for `{username}`:**\n\n" + if result["available"]: + response += "✅ **Available on Plex:**\n" + "\n".join(f"- {title}" for title in result["available"]) + "\n\n" + if result["requestable"]: + response += "🛒 **Requestable:**\n" + "\n".join(f"- {title}" for title in result["requestable"]) + if not result["available"] and not result["requestable"]: + response += "❌ No recommendations found." + + await interaction.followup.send(response) + + except Exception as e: + await interaction.followup.send(f"❌ Error: {str(e)}") + print("Error in /recommend:", e) + +if __name__ == "__main__": + client.run(DISCORD_BOT_TOKEN) diff --git a/bot/plex_client.py b/bot/plex_client.py new file mode 100644 index 0000000..9e920ce --- /dev/null +++ b/bot/plex_client.py @@ -0,0 +1,66 @@ +# bot/plex_client.py + +from plexapi.server import PlexServer +from dotenv import load_dotenv +import os +from datetime import datetime, timedelta + +from config import PLEX_BASE_URL, PLEX_TOKEN + + +class PlexClient: + def __init__(self): + self.server = PlexServer(PLEX_BASE_URL, PLEX_TOKEN) + self.users = {u.title.lower(): u for u in self.server.myPlexAccount().users()} + self.admin_username = self.server.myPlexAccount().username.lower() + + + + + def get_user_watch_history(self, username: str, media_type: str = "movie", max_items: int = 20): + """Returns recent watch history within the last 14 days for a user.""" + recent_cutoff = datetime.now() - timedelta(days=14) + + username = username.lower() + + if username == self.admin_username: + user_server = self.server + else: + user = self.users.get(username) + if not user: + raise ValueError(f"No Plex user found with username '{username}'") + token = user.get_token(self.server.friendlyName) + user_server = PlexServer(PLEX_BASE_URL, token) + + + history = [] + sections = user_server.library.sections() + + for section in sections: + if section.type == media_type: + items = section.search(sort='lastViewedAt:desc', limit=max_items * 2) # buffer for filtering + for item in items: + if item.lastViewedAt and item.lastViewedAt >= recent_cutoff: + history.append({ + 'title': item.title, + 'type': media_type, + 'genres': [g.tag for g in getattr(item, 'genres', [])], + 'summary': getattr(item, 'summary', ''), + }) + if len(history) >= max_items: + break + print(f"📺 Watch history for {username}:") + for item in history: + print(f"- {item['title']} (last viewed: {item.get('lastViewedAt', 'n/a')})") + + return history + + + def get_all_library_titles(self): + """Returns all movies and shows in the Plex library.""" + media = {'movie': [], 'show': []} + for section in self.server.library.sections(): + if section.type in media: + for item in section.all(): + media[section.type].append(item.title) + return media diff --git a/bot/recommender.py b/bot/recommender.py new file mode 100644 index 0000000..32ef065 --- /dev/null +++ b/bot/recommender.py @@ -0,0 +1,89 @@ +# bot/recommender.py + +import openai +import os +from dotenv import load_dotenv +from typing import List, Dict, Literal +from library_cache import LibraryCache +from config import OPENAI_API_KEY, OPENAI_MODEL + +load_dotenv() + +openai.api_key = OPENAI_API_KEY + +MediaType = Literal["movie", "show"] + +class Recommender: + def __init__(self): + self.cache = LibraryCache() + + def recommend(self, watched_titles: List[str], media_type: MediaType = "movie", max_recs: int = 5) -> Dict[str, List[str]]: + if not watched_titles: + raise ValueError("No watched titles provided.") + + prompt = self.build_prompt(watched_titles, media_type, max_recs) + response = self.query_openai(prompt) + + print("🧠 Prompt:", prompt) + print("📥 Raw response:", response) + + all_titles = self.parse_titles(response) + print("📦 Parsed titles:", all_titles) + + available = [title for title in all_titles if self.cache.search(title, media_type)] + requestable = [title for title in all_titles if title not in available] + + return { + "available": available, + "requestable": requestable + } + + def build_prompt(self, watched: List[str], media_type: str, max_recs: int) -> str: + type_text = "movies" if media_type == "movie" else "TV shows" + + # You could optionally summarize genres here + genre_summary = self.extract_common_genres(watched, media_type) + + return ( + f"A user has watched the following {type_text}: {', '.join(watched[:20])}. " + f"These shows are mostly {genre_summary}. " + f"Recommend {max_recs} similar {type_text} based on theme and tone. " + f"Return only a plain comma-separated list of titles — no numbers, no explanations." + ) + + def extract_common_genres(self, watched: List[str], media_type: str) -> str: + genre_counts = {} + for item in self.cache.data: + if item["title"] in watched and item["type"] == media_type: + for genre in item.get("genres", []): + genre_counts[genre] = genre_counts.get(genre, 0) + 1 + sorted_genres = sorted(genre_counts.items(), key=lambda x: x[1], reverse=True) + top_genres = [g for g, _ in sorted_genres[:3]] + return ", ".join(top_genres) if top_genres else "varied genres" + + + def query_openai(self, prompt: str) -> str: + try: + response = openai.ChatCompletion.create( + model=OPENAI_MODEL, + messages=[ + {"role": "system", "content": "You're a helpful and precise media recommender."}, + {"role": "user", "content": prompt} + ], + temperature=0.4, + max_tokens=150 + ) + return response.choices[0].message.content + except Exception as e: + print("⚠️ OpenAI API error:", e) + return "" + + def parse_titles(self, response: str) -> List[str]: + lines = response.replace("\n", ",").split(",") + cleaned = [] + for item in lines: + item = item.strip() + item = item.lstrip("-•*0123456789. ").strip() + if item: + cleaned.append(item) + return cleaned diff --git a/bot/tautulli_client.py b/bot/tautulli_client.py new file mode 100644 index 0000000..fa4c9a9 --- /dev/null +++ b/bot/tautulli_client.py @@ -0,0 +1,73 @@ +# bot/tautulli_client.py + +import os +import requests +from datetime import datetime, timedelta +from dotenv import load_dotenv + +load_dotenv() + +TAUTULLI_URL = os.getenv("TAUTULLI_URL") +TAUTULLI_API_KEY = os.getenv("TAUTULLI_API_KEY") + +class TautulliClient: + def __init__(self): + self.base_url = f"{TAUTULLI_URL}/api/v2" + self.api_key = TAUTULLI_API_KEY + + def _request(self, cmd: str, params: dict = {}): + payload = { + "apikey": self.api_key, + "cmd": cmd, + **params + } + try: + res = requests.get(self.base_url, params=payload) + res.raise_for_status() + return res.json().get("response", {}).get("data", []) + except Exception as e: + print(f"❌ Tautulli API error for {cmd}: {e}") + return [] + + def get_user_watch_history(self, username: str, media_type: str = "movie", days: int = 14, max_items: int = 20): + """Get a list of recently watched movies or shows by username.""" + cutoff = datetime.now() - timedelta(days=days) + print(media_type) + if media_type=='movie': + tautulli_media_type='movie' + elif media_type=='tv': + tautulli_media_type='episode' + + history = self._request("get_history", { + "user": username, + "media_type":tautulli_media_type, + "order_column": "date", + "order_dir": "desc", + "length": 100 # buffer to allow filtering + }) + history = history['data'] + #print(history) + filtered = [] + print('FULL HISTORY') + for item in history: + print(item['user'],item['media_type'],item['title'],item['grandparent_title'],username,cutoff,datetime.fromtimestamp(item["date"])) + try: + + watched_date = datetime.fromtimestamp(item["date"]) + + if watched_date < cutoff: + continue + + filtered.append({ + "title": item["title"] if media_type == "movie" else item["grandparent_title"], + "type": media_type, + "genres": item.get("genre", "").split(", "), + "summary": item.get("summary", "") + }) + + if len(filtered) >= max_items: + break + except Exception: + continue + print(filtered) + return filtered diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a131e7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' + +services: + plex-discord-bot: + build: . + env_file: .env + restart: unless-stopped + volumes: + - ./bot:/app/bot diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..caa9508 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +discord.py +openai==0.28 +plexapi +python-dotenv +requests From 24dbe252dedbd67f40603c81ae5838cb251f3ea5 Mon Sep 17 00:00:00 2001 From: ccahill27 Date: Tue, 3 Jun 2025 13:12:58 -0400 Subject: [PATCH 2/2] Working version --- bot/library_cache.py | 24 +++++++++++++++++----- bot/main.py | 38 ++++++++++++++++++++--------------- bot/recommender.py | 47 ++++++++++++++++++++++++++++---------------- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/bot/library_cache.py b/bot/library_cache.py index f84e5c6..824d9cb 100644 --- a/bot/library_cache.py +++ b/bot/library_cache.py @@ -5,6 +5,7 @@ import os import time from typing import List from plex_client import PlexClient +from difflib import SequenceMatcher CACHE_FILE = 'bot/library_cache.json' MAX_CACHE_AGE_HOURS = 6 @@ -30,11 +31,16 @@ class LibraryCache: new_data = [] for section in media: - if section.type in ['movie', 'show']: + section_type = section.type.lower() + # Normalize to 'show' + if section_type == 'show': + section_type = 'tv' + + if section_type in ['movie', 'tv']: for item in section.all(): new_data.append({ 'title': item.title.strip(), - 'type': section.type, + 'type': section_type, 'genres': [g.tag for g in getattr(item, 'genres', [])], 'year': getattr(item, 'year', None) }) @@ -42,6 +48,7 @@ class LibraryCache: self.data = new_data self.save_cache() + def save_cache(self): with open(CACHE_FILE, 'w', encoding='utf-8') as f: json.dump(self.data, f, ensure_ascii=False, indent=2) @@ -53,9 +60,16 @@ class LibraryCache: def get_titles_by_type(self, media_type: str) -> List[str]: return [item['title'] for item in self.data if item['type'] == media_type] - def search(self, title: str, media_type: str = None) -> bool: - """Check if title exists in library (case-insensitive, optional type filter).""" + + def search(self, title: str, media_type: str) -> bool: + def is_match(a: str, b: str) -> bool: + ratio = SequenceMatcher(None, a.lower(), b.lower()).ratio() + return ratio > 0.8 # tweak as needed + for item in self.data: - if item['title'].lower() == title.lower() and (media_type is None or item['type'] == media_type): + if item["type"] != media_type: + continue + if is_match(title, item["title"]): return True return False + diff --git a/bot/main.py b/bot/main.py index 0c7e820..d93cf33 100644 --- a/bot/main.py +++ b/bot/main.py @@ -29,33 +29,39 @@ async def recommend(interaction: discord.Interaction, username: str, media_type: await interaction.response.defer() # Acknowledge the request try: - #users = tautulli._request("get_users") - #print("👤 Tautulli Users:") - #for u in users: - # print(f"{u.get('username')}") - # Convert Discord input to Plex-friendly type - history = tautulli.get_user_watch_history(username=username, media_type=media_type.lower()) if not history: await interaction.followup.send(f"⚠️ No recent {media_type} history found for `{username}`.") return watched_titles = [entry["title"] for entry in history] - result = recommender.recommend(watched_titles, media_type=media_type.lower()) + # Print cached Plex titles for this media_type + print(f"🗂️ Cached Plex {media_type}s:") + for item in recommender.cache.data: + if item["type"] == media_type.lower(): + print(f"- {item['title']}") - response = f"🎬 **{media_type.title()} Recommendations for `{username}`:**\n\n" - if result["available"]: - response += "✅ **Available on Plex:**\n" + "\n".join(f"- {title}" for title in result["available"]) + "\n\n" - if result["requestable"]: - response += "🛒 **Requestable:**\n" + "\n".join(f"- {title}" for title in result["requestable"]) - if not result["available"] and not result["requestable"]: - response += "❌ No recommendations found." + results = recommender.recommend(watched_titles, media_type=media_type.lower()) + available = results["available"] + requestable = results["requestable"] + + def format_recs(title_list): + return "\n".join(f"• {title}" for title in title_list) + + # Final Discord message + response = ( + f"🎬 **Recommendations for `{username}` ({media_type}s)**\n\n" + f"**✅ Available on Plex:**\n" + f"{format_recs(available) or 'None found.'}\n\n" + f"**🛒 Requestable:**\n" + f"{format_recs(requestable) or 'None found.'}" + ) await interaction.followup.send(response) except Exception as e: - await interaction.followup.send(f"❌ Error: {str(e)}") - print("Error in /recommend:", e) + await interaction.followup.send(f"❌ An error occurred while generating recommendations: {e}") + if __name__ == "__main__": client.run(DISCORD_BOT_TOKEN) diff --git a/bot/recommender.py b/bot/recommender.py index 32ef065..51a335b 100644 --- a/bot/recommender.py +++ b/bot/recommender.py @@ -11,7 +11,7 @@ load_dotenv() openai.api_key = OPENAI_API_KEY -MediaType = Literal["movie", "show"] +MediaType = Literal["movie", "tv"] class Recommender: def __init__(self): @@ -21,34 +21,39 @@ class Recommender: if not watched_titles: raise ValueError("No watched titles provided.") - prompt = self.build_prompt(watched_titles, media_type, max_recs) + available_titles = [item["title"] for item in self.cache.data if item["type"] == media_type] + prompt = self.build_prompt(watched_titles, available_titles, media_type, max_recs) response = self.query_openai(prompt) print("🧠 Prompt:", prompt) print("📥 Raw response:", response) - all_titles = self.parse_titles(response) - print("📦 Parsed titles:", all_titles) + all_titles = self.parse_titles(response) # ✅ Bring this back + # Re-verify locally available = [title for title in all_titles if self.cache.search(title, media_type)] requestable = [title for title in all_titles if title not in available] + for title in all_titles: + match = self.cache.search(title, media_type) + print(f"{'✅' if match else '❌'} {title}") return { - "available": available, - "requestable": requestable + "available": available[:max_recs], + "requestable": requestable[:max_recs] } - def build_prompt(self, watched: List[str], media_type: str, max_recs: int) -> str: + + def build_prompt(self, watched: List[str], available_titles: List[str], media_type: str, max_recs: int) -> str: type_text = "movies" if media_type == "movie" else "TV shows" - - # You could optionally summarize genres here - genre_summary = self.extract_common_genres(watched, media_type) return ( - f"A user has watched the following {type_text}: {', '.join(watched[:20])}. " - f"These shows are mostly {genre_summary}. " - f"Recommend {max_recs} similar {type_text} based on theme and tone. " - f"Return only a plain comma-separated list of titles — no numbers, no explanations." + f""" + The user has recently watched the following {type_text}: {', '.join(watched[:10])}. + Here is a list of {type_text} available on the Plex server: {', '.join(available_titles[:200])}. + Recommend 10 similar {type_text}. Select 5 that the user would like that are available on the server and 5 that are not available. + Return only a plain comma-separated list of titles. + """ + ) def extract_common_genres(self, watched: List[str], media_type: str) -> str: @@ -61,7 +66,6 @@ class Recommender: top_genres = [g for g, _ in sorted_genres[:3]] return ", ".join(top_genres) if top_genres else "varied genres" - def query_openai(self, prompt: str) -> str: try: response = openai.ChatCompletion.create( @@ -71,13 +75,12 @@ class Recommender: {"role": "user", "content": prompt} ], temperature=0.4, - max_tokens=150 + max_tokens=300 ) return response.choices[0].message.content except Exception as e: print("⚠️ OpenAI API error:", e) return "" - def parse_titles(self, response: str) -> List[str]: lines = response.replace("\n", ",").split(",") cleaned = [] @@ -87,3 +90,13 @@ class Recommender: if item: cleaned.append(item) return cleaned + + def parse_split_titles(self, response: str) -> (List[str], List[str]): + available = [] + requestable = [] + for line in response.splitlines(): + if line.lower().startswith("on plex:"): + available = [t.strip() for t in line.split(":", 1)[1].split(",") if t.strip()] + elif line.lower().startswith("not on plex:"): + requestable = [t.strip() for t in line.split(":", 1)[1].split(",") if t.strip()] + return available, requestable