d8c8b473ad
Saves each mood search and its recommendations to SQLite per user. Recent searches appear below the results area with mood text, top movie titles, and timestamp. Click any entry to reload those results. Entries can be deleted individually. History toggleable via show/hide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
160 lines
5.2 KiB
Python
160 lines
5.2 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)
|
|
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)
|
|
|
|
meta = {
|
|
"total_unwatched": total_unwatched,
|
|
"candidates_evaluated": len(candidates),
|
|
"processing_time_ms": elapsed_ms,
|
|
"mood": body.mood,
|
|
}
|
|
|
|
# Save to search history
|
|
try:
|
|
db = await get_db()
|
|
try:
|
|
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(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
finally:
|
|
await db.close()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to save search history: {e}")
|
|
|
|
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()
|