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
View File
+92
View File
@@ -0,0 +1,92 @@
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request, Response
from app.database import get_db
from app.models import LoginRequest
from app.services.jellyfin import authenticate_user
router = APIRouter()
SESSION_DURATION_DAYS = 30
async def get_current_user(request: Request) -> dict:
"""Extract and validate session from cookie. Returns user info or raises 401."""
session_id = request.cookies.get("session_id")
if not session_id:
raise HTTPException(status_code=401, detail="Not authenticated")
db = await get_db()
try:
cursor = await db.execute(
"SELECT user_id, username, jellyfin_token, expires_at FROM sessions WHERE session_id = ?",
(session_id,),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=401, detail="Invalid session")
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
await db.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
await db.commit()
raise HTTPException(status_code=401, detail="Session expired")
return {
"id": row["user_id"],
"name": row["username"],
"token": row["jellyfin_token"],
}
finally:
await db.close()
@router.post("/login")
async def login(request: LoginRequest, response: Response):
result = await authenticate_user(request.username, request.password)
if not result:
raise HTTPException(status_code=401, detail="Invalid username or password")
session_id = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
expires = now + timedelta(days=SESSION_DURATION_DAYS)
db = await get_db()
try:
await db.execute(
"INSERT INTO sessions (session_id, user_id, username, jellyfin_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
(session_id, result["user_id"], result["username"], result["token"], now.isoformat(), expires.isoformat()),
)
await db.commit()
finally:
await db.close()
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
samesite="lax",
max_age=SESSION_DURATION_DAYS * 86400,
)
return {"id": result["user_id"], "name": result["username"]}
@router.post("/logout")
async def logout(request: Request, response: Response):
session_id = request.cookies.get("session_id")
if session_id:
db = await get_db()
try:
await db.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
await db.commit()
finally:
await db.close()
response.delete_cookie("session_id")
return {"ok": True}
@router.get("/me")
async def me(request: Request):
user = await get_current_user(request)
return {"id": user["id"], "name": user["name"]}
+45
View File
@@ -0,0 +1,45 @@
import asyncio
from fastapi import APIRouter, Request
from app.database import get_db
from app.routers.auth import get_current_user
from app.services.library_sync import sync_movie_metadata, sync_watch_state
router = APIRouter()
@router.get("/stats")
async def library_stats(request: Request):
await get_current_user(request)
db = await get_db()
try:
cursor = await db.execute("SELECT COUNT(*) as count FROM movies")
row = await cursor.fetchone()
total_movies = row["count"]
cursor = await db.execute(
"SELECT value FROM sync_status WHERE key = 'last_metadata_sync'"
)
row = await cursor.fetchone()
last_sync = row["value"] if row else None
return {
"total_movies": total_movies,
"last_sync": last_sync,
}
finally:
await db.close()
@router.post("/sync")
async def trigger_sync(request: Request):
await get_current_user(request)
asyncio.create_task(_do_sync())
return {"status": "sync started"}
async def _do_sync():
await sync_movie_metadata()
await sync_watch_state()
+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,
},
)
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter, Request
from app.routers.auth import get_current_user
from app.services.jellyfin import get_users as jf_get_users
router = APIRouter()
@router.get("/users")
async def list_users(request: Request):
await get_current_user(request)
users = await jf_get_users()
return users