Initial working version
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -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"]
|
||||||
|
|
21
bot/config.py
Normal file
21
bot/config.py
Normal file
@ -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")
|
61
bot/library_cache.py
Normal file
61
bot/library_cache.py
Normal file
@ -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
|
61
bot/main.py
Normal file
61
bot/main.py
Normal file
@ -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)
|
66
bot/plex_client.py
Normal file
66
bot/plex_client.py
Normal file
@ -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
|
89
bot/recommender.py
Normal file
89
bot/recommender.py
Normal file
@ -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
|
73
bot/tautulli_client.py
Normal file
73
bot/tautulli_client.py
Normal file
@ -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
|
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
plex-discord-bot:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./bot:/app/bot
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
discord.py
|
||||||
|
openai==0.28
|
||||||
|
plexapi
|
||||||
|
python-dotenv
|
||||||
|
requests
|
Reference in New Issue
Block a user