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,100 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_unwatched_movies
|
||||
from app.models import MoodRequest, MoodResponse, Recommendation
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.jellyfin import get_deep_link, get_poster_url
|
||||
from app.services.llm import get_llm_provider
|
||||
from app.services.prefilter import prefilter_candidates
|
||||
|
||||
logger = logging.getLogger("movie-night.mood")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/mood", response_model=MoodResponse)
|
||||
async def get_mood_recommendations(request: Request, body: MoodRequest):
|
||||
user = await get_current_user(request)
|
||||
|
||||
if not body.mood.strip():
|
||||
raise HTTPException(status_code=400, detail="Please describe what you're in the mood for")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Build list of user IDs to check watch state against
|
||||
user_ids = [user["id"]] + body.additional_user_ids
|
||||
|
||||
# Get unwatched movies from cache
|
||||
unwatched_raw = await get_unwatched_movies(user_ids)
|
||||
total_unwatched = len(unwatched_raw)
|
||||
|
||||
if total_unwatched == 0:
|
||||
return MoodResponse(
|
||||
recommendations=[],
|
||||
meta={
|
||||
"total_unwatched": 0,
|
||||
"candidates_evaluated": 0,
|
||||
"processing_time_ms": 0,
|
||||
"mood": body.mood,
|
||||
},
|
||||
)
|
||||
|
||||
# Pre-filter to narrow candidates
|
||||
candidates = prefilter_candidates(
|
||||
unwatched_raw, body.mood, max_candidates=settings.max_candidates
|
||||
)
|
||||
logger.info(f"Pre-filtered {total_unwatched} movies to {len(candidates)} candidates for mood: '{body.mood}'")
|
||||
|
||||
# Get recommendations from LLM
|
||||
try:
|
||||
llm = get_llm_provider()
|
||||
raw_recs = await llm.get_recommendations(
|
||||
body.mood, candidates, max_results=settings.max_recommendations
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM error: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to get recommendations from AI. Please try again.")
|
||||
|
||||
# Validate and enrich recommendations
|
||||
candidate_map = {m.jellyfin_id: m for m in candidates}
|
||||
recommendations = []
|
||||
|
||||
for rec in raw_recs:
|
||||
jf_id = rec.get("jellyfin_id")
|
||||
if not jf_id or jf_id not in candidate_map:
|
||||
logger.warning(f"LLM returned invalid jellyfin_id: {jf_id}")
|
||||
continue
|
||||
|
||||
movie = candidate_map[jf_id]
|
||||
recommendations.append(
|
||||
Recommendation(
|
||||
jellyfin_id=movie.jellyfin_id,
|
||||
title=movie.title,
|
||||
year=movie.year,
|
||||
genres=movie.genres,
|
||||
community_rating=movie.community_rating,
|
||||
runtime_minutes=movie.runtime_minutes,
|
||||
content_rating=movie.content_rating,
|
||||
poster_url=get_poster_url(movie.jellyfin_id),
|
||||
deep_link=get_deep_link(movie.jellyfin_id),
|
||||
reasoning=rec.get("reasoning", "A great match for your mood!"),
|
||||
match_score=rec.get("match_score", 0.5),
|
||||
)
|
||||
)
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
return MoodResponse(
|
||||
recommendations=recommendations,
|
||||
meta={
|
||||
"total_unwatched": total_unwatched,
|
||||
"candidates_evaluated": len(candidates),
|
||||
"processing_time_ms": elapsed_ms,
|
||||
"mood": body.mood,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user