Initial commit — Movie Night media discovery app
AI-powered web app that recommends unwatched movies from a Jellyfin library based on natural language mood input. Jellyfin auth, modular LLM backend (Claude/OpenAI/Ollama), two-tier pre-filter + AI ranking, mobile-responsive dark theme UI with poster cards and deep links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
from app.config import settings
|
||||
from app.services.llm.base import LLMProvider
|
||||
|
||||
|
||||
def get_llm_provider() -> LLMProvider:
|
||||
provider = settings.llm_provider.lower()
|
||||
if provider == "anthropic":
|
||||
from app.services.llm.anthropic import AnthropicProvider
|
||||
return AnthropicProvider()
|
||||
elif provider == "openai":
|
||||
from app.services.llm.openai_provider import OpenAIProvider
|
||||
return OpenAIProvider()
|
||||
elif provider == "ollama":
|
||||
from app.services.llm.ollama import OllamaProvider
|
||||
return OllamaProvider()
|
||||
else:
|
||||
raise ValueError(f"Unknown LLM provider: {provider}. Must be 'anthropic', 'openai', or 'ollama'.")
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import anthropic
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Movie
|
||||
from app.services.llm.base import SYSTEM_PROMPT, LLMProvider, build_user_message
|
||||
|
||||
logger = logging.getLogger("movie-night.llm.anthropic")
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
def __init__(self):
|
||||
self.client = anthropic.AsyncAnthropic(api_key=settings.llm_api_key)
|
||||
self.model = settings.llm_model or "claude-sonnet-4-6"
|
||||
|
||||
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
|
||||
system = SYSTEM_PROMPT.format(max_results=max_results)
|
||||
user_msg = build_user_message(mood, candidates)
|
||||
|
||||
logger.info(f"Calling Anthropic ({self.model}) with {len(candidates)} candidates")
|
||||
|
||||
response = await self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=2048,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
)
|
||||
|
||||
text = response.content[0].text.strip()
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data.get("recommendations", [])
|
||||
except json.JSONDecodeError:
|
||||
# Try to extract JSON from the response
|
||||
start = text.find("{")
|
||||
end = text.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
data = json.loads(text[start:end])
|
||||
return data.get("recommendations", [])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.error(f"Failed to parse LLM response: {text[:200]}")
|
||||
return []
|
||||
@@ -0,0 +1,54 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.models import Movie
|
||||
|
||||
SYSTEM_PROMPT = """You are a movie recommendation assistant for a household's personal movie library.
|
||||
The user will describe their mood or what kind of movie night they want. You will receive a list of
|
||||
unwatched movies from their library and recommend the best matches.
|
||||
|
||||
Rules:
|
||||
- ONLY recommend movies from the provided list — these are movies they already own but haven't watched
|
||||
- Consider genre, themes, tone, cast, era, and the movie's overview when matching to the mood
|
||||
- Provide a brief, enthusiastic 1-2 sentence explanation for each pick that connects it to the mood
|
||||
- Rank by how well they match the described mood, not by rating alone
|
||||
- If the mood mentions kids, children, or family, only recommend age-appropriate content (G, PG, or PG-13)
|
||||
- Return exactly {max_results} recommendations, or fewer only if the library has very few matches
|
||||
|
||||
Respond with ONLY valid JSON in this exact format, no other text:
|
||||
{{
|
||||
"recommendations": [
|
||||
{{
|
||||
"jellyfin_id": "the-exact-id-from-the-list",
|
||||
"title": "Movie Title",
|
||||
"reasoning": "Why this fits the mood",
|
||||
"match_score": 0.95
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
|
||||
def build_user_message(mood: str, candidates: list[Movie]) -> str:
|
||||
movie_list = []
|
||||
for m in candidates:
|
||||
entry = {
|
||||
"id": m.jellyfin_id,
|
||||
"title": m.title,
|
||||
"year": m.year,
|
||||
"genres": m.genres,
|
||||
"rating": m.community_rating,
|
||||
"runtime_min": m.runtime_minutes,
|
||||
"content_rating": m.content_rating,
|
||||
"overview": (m.overview or "")[:200],
|
||||
}
|
||||
movie_list.append(entry)
|
||||
|
||||
import json
|
||||
movies_json = json.dumps(movie_list, indent=None)
|
||||
return f'Mood: "{mood}"\n\nAvailable unwatched movies ({len(candidates)} total):\n{movies_json}'
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
|
||||
"""Send mood + candidates to the LLM and return parsed recommendations."""
|
||||
...
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Movie
|
||||
from app.services.llm.base import SYSTEM_PROMPT, LLMProvider, build_user_message
|
||||
|
||||
logger = logging.getLogger("movie-night.llm.ollama")
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
def __init__(self):
|
||||
base_url = settings.llm_base_url or "http://localhost:11434/v1"
|
||||
self.client = AsyncOpenAI(api_key="ollama", base_url=base_url)
|
||||
self.model = settings.llm_model or "llama3"
|
||||
|
||||
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
|
||||
system = SYSTEM_PROMPT.format(max_results=max_results)
|
||||
user_msg = build_user_message(mood, candidates)
|
||||
|
||||
logger.info(f"Calling Ollama ({self.model}) with {len(candidates)} candidates")
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
max_tokens=2048,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data.get("recommendations", [])
|
||||
except json.JSONDecodeError:
|
||||
# Try to extract JSON from response
|
||||
start = text.find("{")
|
||||
end = text.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
data = json.loads(text[start:end])
|
||||
return data.get("recommendations", [])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.error(f"Failed to parse Ollama response: {text[:200]}")
|
||||
return []
|
||||
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Movie
|
||||
from app.services.llm.base import SYSTEM_PROMPT, LLMProvider, build_user_message
|
||||
|
||||
logger = logging.getLogger("movie-night.llm.openai")
|
||||
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
def __init__(self):
|
||||
kwargs = {"api_key": settings.llm_api_key}
|
||||
if settings.llm_base_url:
|
||||
kwargs["base_url"] = settings.llm_base_url
|
||||
self.client = AsyncOpenAI(**kwargs)
|
||||
self.model = settings.llm_model or "gpt-4o"
|
||||
|
||||
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
|
||||
system = SYSTEM_PROMPT.format(max_results=max_results)
|
||||
user_msg = build_user_message(mood, candidates)
|
||||
|
||||
logger.info(f"Calling OpenAI ({self.model}) with {len(candidates)} candidates")
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
max_tokens=2048,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data.get("recommendations", [])
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse LLM response: {text[:200]}")
|
||||
return []
|
||||
Reference in New Issue
Block a user