2026-03-14 19:20:56 -07:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import time
|
2026-03-14 19:57:50 -07:00
|
|
|
from datetime import datetime, timezone
|
2026-03-14 19:20:56 -07:00
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
2026-03-14 19:57:50 -07:00
|
|
|
from app.database import get_db, get_unwatched_movies
|
2026-03-14 19:20:56 -07:00
|
|
|
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)
|
2026-03-14 20:07:05 -07:00
|
|
|
|
|
|
|
|
# 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]
|
|
|
|
|
|
2026-03-14 19:20:56 -07:00
|
|
|
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(
|
2026-03-14 20:07:05 -07:00
|
|
|
unwatched_raw, body.mood, max_candidates=settings.max_candidates,
|
|
|
|
|
kid_friendly=body.kid_friendly,
|
2026-03-14 19:20:56 -07:00
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-14 19:57:50 -07:00
|
|
|
meta = {
|
|
|
|
|
"total_unwatched": total_unwatched,
|
|
|
|
|
"candidates_evaluated": len(candidates),
|
|
|
|
|
"processing_time_ms": elapsed_ms,
|
|
|
|
|
"mood": body.mood,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Save to search history
|
2026-03-14 20:07:05 -07:00
|
|
|
history_id = None
|
2026-03-14 19:57:50 -07:00
|
|
|
try:
|
|
|
|
|
db = await get_db()
|
|
|
|
|
try:
|
2026-03-14 20:07:05 -07:00
|
|
|
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
|
2026-03-14 19:57:50 -07:00
|
|
|
await db.commit()
|
|
|
|
|
finally:
|
|
|
|
|
await db.close()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to save search history: {e}")
|
|
|
|
|
|
2026-03-14 20:07:05 -07:00
|
|
|
meta["history_id"] = history_id
|
2026-03-14 19:57:50 -07:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 20:22:55 -07:00
|
|
|
@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()
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 19:57:50 -07:00
|
|
|
@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()
|