Working version
This commit is contained in:
@ -5,6 +5,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from typing import List
|
from typing import List
|
||||||
from plex_client import PlexClient
|
from plex_client import PlexClient
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
CACHE_FILE = 'bot/library_cache.json'
|
CACHE_FILE = 'bot/library_cache.json'
|
||||||
MAX_CACHE_AGE_HOURS = 6
|
MAX_CACHE_AGE_HOURS = 6
|
||||||
@ -30,11 +31,16 @@ class LibraryCache:
|
|||||||
|
|
||||||
new_data = []
|
new_data = []
|
||||||
for section in media:
|
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():
|
for item in section.all():
|
||||||
new_data.append({
|
new_data.append({
|
||||||
'title': item.title.strip(),
|
'title': item.title.strip(),
|
||||||
'type': section.type,
|
'type': section_type,
|
||||||
'genres': [g.tag for g in getattr(item, 'genres', [])],
|
'genres': [g.tag for g in getattr(item, 'genres', [])],
|
||||||
'year': getattr(item, 'year', None)
|
'year': getattr(item, 'year', None)
|
||||||
})
|
})
|
||||||
@ -42,6 +48,7 @@ class LibraryCache:
|
|||||||
self.data = new_data
|
self.data = new_data
|
||||||
self.save_cache()
|
self.save_cache()
|
||||||
|
|
||||||
|
|
||||||
def save_cache(self):
|
def save_cache(self):
|
||||||
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
|
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||||
json.dump(self.data, f, ensure_ascii=False, indent=2)
|
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]:
|
def get_titles_by_type(self, media_type: str) -> List[str]:
|
||||||
return [item['title'] for item in self.data if item['type'] == media_type]
|
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:
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
38
bot/main.py
38
bot/main.py
@ -29,33 +29,39 @@ async def recommend(interaction: discord.Interaction, username: str, media_type:
|
|||||||
await interaction.response.defer() # Acknowledge the request
|
await interaction.response.defer() # Acknowledge the request
|
||||||
|
|
||||||
try:
|
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())
|
history = tautulli.get_user_watch_history(username=username, media_type=media_type.lower())
|
||||||
if not history:
|
if not history:
|
||||||
await interaction.followup.send(f"⚠️ No recent {media_type} history found for `{username}`.")
|
await interaction.followup.send(f"⚠️ No recent {media_type} history found for `{username}`.")
|
||||||
return
|
return
|
||||||
|
|
||||||
watched_titles = [entry["title"] for entry in history]
|
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"
|
results = recommender.recommend(watched_titles, media_type=media_type.lower())
|
||||||
if result["available"]:
|
available = results["available"]
|
||||||
response += "✅ **Available on Plex:**\n" + "\n".join(f"- {title}" for title in result["available"]) + "\n\n"
|
requestable = results["requestable"]
|
||||||
if result["requestable"]:
|
|
||||||
response += "🛒 **Requestable:**\n" + "\n".join(f"- {title}" for title in result["requestable"])
|
def format_recs(title_list):
|
||||||
if not result["available"] and not result["requestable"]:
|
return "\n".join(f"• {title}" for title in title_list)
|
||||||
response += "❌ No recommendations found."
|
|
||||||
|
# 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)
|
await interaction.followup.send(response)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(f"❌ Error: {str(e)}")
|
await interaction.followup.send(f"❌ An error occurred while generating recommendations: {e}")
|
||||||
print("Error in /recommend:", e)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
client.run(DISCORD_BOT_TOKEN)
|
client.run(DISCORD_BOT_TOKEN)
|
||||||
|
@ -11,7 +11,7 @@ load_dotenv()
|
|||||||
|
|
||||||
openai.api_key = OPENAI_API_KEY
|
openai.api_key = OPENAI_API_KEY
|
||||||
|
|
||||||
MediaType = Literal["movie", "show"]
|
MediaType = Literal["movie", "tv"]
|
||||||
|
|
||||||
class Recommender:
|
class Recommender:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -21,34 +21,39 @@ class Recommender:
|
|||||||
if not watched_titles:
|
if not watched_titles:
|
||||||
raise ValueError("No watched titles provided.")
|
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)
|
response = self.query_openai(prompt)
|
||||||
|
|
||||||
print("🧠 Prompt:", prompt)
|
print("🧠 Prompt:", prompt)
|
||||||
print("📥 Raw response:", response)
|
print("📥 Raw response:", response)
|
||||||
|
|
||||||
all_titles = self.parse_titles(response)
|
all_titles = self.parse_titles(response) # ✅ Bring this back
|
||||||
print("📦 Parsed titles:", all_titles)
|
|
||||||
|
|
||||||
|
# Re-verify locally
|
||||||
available = [title for title in all_titles if self.cache.search(title, media_type)]
|
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]
|
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 {
|
return {
|
||||||
"available": available,
|
"available": available[:max_recs],
|
||||||
"requestable": requestable
|
"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"
|
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 (
|
return (
|
||||||
f"A user has watched the following {type_text}: {', '.join(watched[:20])}. "
|
f"""
|
||||||
f"These shows are mostly {genre_summary}. "
|
The user has recently watched the following {type_text}: {', '.join(watched[:10])}.
|
||||||
f"Recommend {max_recs} similar {type_text} based on theme and tone. "
|
Here is a list of {type_text} available on the Plex server: {', '.join(available_titles[:200])}.
|
||||||
f"Return only a plain comma-separated list of titles — no numbers, no explanations."
|
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:
|
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]]
|
top_genres = [g for g, _ in sorted_genres[:3]]
|
||||||
return ", ".join(top_genres) if top_genres else "varied genres"
|
return ", ".join(top_genres) if top_genres else "varied genres"
|
||||||
|
|
||||||
|
|
||||||
def query_openai(self, prompt: str) -> str:
|
def query_openai(self, prompt: str) -> str:
|
||||||
try:
|
try:
|
||||||
response = openai.ChatCompletion.create(
|
response = openai.ChatCompletion.create(
|
||||||
@ -71,13 +75,12 @@ class Recommender:
|
|||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
],
|
],
|
||||||
temperature=0.4,
|
temperature=0.4,
|
||||||
max_tokens=150
|
max_tokens=300
|
||||||
)
|
)
|
||||||
return response.choices[0].message.content
|
return response.choices[0].message.content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("⚠️ OpenAI API error:", e)
|
print("⚠️ OpenAI API error:", e)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def parse_titles(self, response: str) -> List[str]:
|
def parse_titles(self, response: str) -> List[str]:
|
||||||
lines = response.replace("\n", ",").split(",")
|
lines = response.replace("\n", ",").split(",")
|
||||||
cleaned = []
|
cleaned = []
|
||||||
@ -87,3 +90,13 @@ class Recommender:
|
|||||||
if item:
|
if item:
|
||||||
cleaned.append(item)
|
cleaned.append(item)
|
||||||
return cleaned
|
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
|
||||||
|
Reference in New Issue
Block a user