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.get("/history/shared/{entry_id}") async def get_shared_history(entry_id: int): """Public endpoint — returns a history entry by ID (no auth required).""" db = await get_db() try: cursor = await db.execute( "SELECT id, mood, results, meta, created_at FROM search_history WHERE id = ?", (entry_id,), ) row = await cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Search not found") 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"], } 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()