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:
2026-03-14 19:20:56 -07:00
commit 3d5de06b44
30 changed files with 1881 additions and 0 deletions
+100
View File
@@ -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,
},
)