Files
movie-night/app/routers/mood.py
T
kbondelie 9f96a91986 Add runtime filter, kid-friendly toggle, surprise me, and re-roll
- Runtime quick-select buttons (Any/90m/2h/2.5h) filter movies by length
- Kid-friendly toggle forces PG-13 max and boosts Family/Animation genres
- Surprise Me picks a random mood prompt from 20 curated options
- Show Me More re-rolls same mood excluding already-shown movies
- Re-roll appends new results to the existing search history entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 20:07:05 -07:00

191 lines
6.9 KiB
Python

import json
import logging
import time
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.database import get_db, 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)
# Exclude previously shown movies (re-roll)
if body.exclude_ids:
exclude_set = set(body.exclude_ids)
unwatched_raw = [m for m in unwatched_raw if m["jellyfin_id"] not in exclude_set]
# Filter by max runtime
if body.max_runtime:
unwatched_raw = [m for m in unwatched_raw if not m.get("runtime_minutes") or m["runtime_minutes"] <= body.max_runtime]
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,
kid_friendly=body.kid_friendly,
)
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)
meta = {
"total_unwatched": total_unwatched,
"candidates_evaluated": len(candidates),
"processing_time_ms": elapsed_ms,
"mood": body.mood,
}
# Save to search history
history_id = None
try:
db = await get_db()
try:
if body.history_id:
# Re-roll: append new results to existing history entry
cursor = await db.execute(
"SELECT results FROM search_history WHERE id = ? AND user_id = ?",
(body.history_id, user["id"]),
)
row = await cursor.fetchone()
if row:
existing = json.loads(row["results"])
combined = existing + [r.model_dump() for r in recommendations]
await db.execute(
"UPDATE search_history SET results = ?, meta = ? WHERE id = ? AND user_id = ?",
(json.dumps(combined), json.dumps(meta), body.history_id, user["id"]),
)
history_id = body.history_id
else:
# Fallback: create new entry if original not found
cursor = await db.execute(
"INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)",
(user["id"], body.mood, json.dumps([r.model_dump() for r in recommendations]), json.dumps(meta), datetime.now(timezone.utc).isoformat()),
)
history_id = cursor.lastrowid
else:
cursor = await db.execute(
"INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)",
(user["id"], body.mood, json.dumps([r.model_dump() for r in recommendations]), json.dumps(meta), datetime.now(timezone.utc).isoformat()),
)
history_id = cursor.lastrowid
await db.commit()
finally:
await db.close()
except Exception as e:
logger.warning(f"Failed to save search history: {e}")
meta["history_id"] = history_id
return MoodResponse(recommendations=recommendations, meta=meta)
@router.get("/history")
async def get_search_history(request: Request):
user = await get_current_user(request)
db = await get_db()
try:
cursor = await db.execute(
"SELECT id, mood, results, meta, created_at FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 20",
(user["id"],),
)
rows = await cursor.fetchall()
return [
{
"id": row["id"],
"mood": row["mood"],
"results": json.loads(row["results"]),
"meta": json.loads(row["meta"]) if row["meta"] else {},
"created_at": row["created_at"],
}
for row in rows
]
finally:
await db.close()
@router.delete("/history/{entry_id}")
async def delete_history_entry(entry_id: int, request: Request):
user = await get_current_user(request)
db = await get_db()
try:
await db.execute(
"DELETE FROM search_history WHERE id = ? AND user_id = ?",
(entry_id, user["id"]),
)
await db.commit()
return {"ok": True}
finally:
await db.close()