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
+27
View File
@@ -0,0 +1,27 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Jellyfin
jellyfin_url: str = "http://192.168.5.254:8096"
jellyfin_api_key: str = ""
jellyfin_external_url: str = "https://jellyfin.internal.bondelie.net"
# LLM provider
llm_provider: str = "anthropic" # anthropic, openai, or ollama
llm_api_key: str = ""
llm_model: str = "" # defaults vary by provider
llm_base_url: str = "" # override for ollama or custom endpoints
# App settings
db_path: str = "/data/library.db"
sync_interval_hours: int = 24
watch_state_sync_hours: int = 4
max_candidates: int = 200
max_recommendations: int = 6
session_secret: str = "change-me-in-production"
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
+90
View File
@@ -0,0 +1,90 @@
import aiosqlite
import os
from app.config import settings
_db_path = settings.db_path
def _ensure_db_dir():
os.makedirs(os.path.dirname(_db_path), exist_ok=True)
async def get_db() -> aiosqlite.Connection:
_ensure_db_dir()
db = await aiosqlite.connect(_db_path)
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA journal_mode=WAL")
return db
async def init_database():
db = await get_db()
try:
await db.executescript("""
CREATE TABLE IF NOT EXISTS movies (
jellyfin_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
sort_title TEXT,
year INTEGER,
genres TEXT DEFAULT '[]',
overview TEXT,
community_rating REAL,
critic_rating REAL,
runtime_minutes INTEGER,
content_rating TEXT,
studios TEXT DEFAULT '[]',
people TEXT DEFAULT '[]',
tags TEXT DEFAULT '[]',
synced_at TEXT
);
CREATE TABLE IF NOT EXISTS watch_state (
jellyfin_id TEXT NOT NULL,
user_id TEXT NOT NULL,
is_played INTEGER DEFAULT 0,
synced_at TEXT,
PRIMARY KEY (jellyfin_id, user_id)
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
jellyfin_token TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_status (
key TEXT PRIMARY KEY,
value TEXT
);
""")
await db.commit()
finally:
await db.close()
async def get_unwatched_movies(user_ids: list[str]) -> list[dict]:
db = await get_db()
try:
if not user_ids:
return []
# Get movies that are unwatched by ALL specified users
placeholders = ",".join("?" for _ in user_ids)
query = f"""
SELECT m.* FROM movies m
WHERE NOT EXISTS (
SELECT 1 FROM watch_state ws
WHERE ws.jellyfin_id = m.jellyfin_id
AND ws.user_id IN ({placeholders})
AND ws.is_played = 1
)
ORDER BY m.community_rating DESC NULLS LAST
"""
cursor = await db.execute(query, user_ids)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
finally:
await db.close()
+59
View File
@@ -0,0 +1,59 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles
from app.database import init_database
from app.routers import auth, library, users
from app.services.jellyfin import get_poster
from app.services.library_sync import run_periodic_sync
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_database()
sync_task = asyncio.create_task(run_periodic_sync())
yield
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
app = FastAPI(title="Movie Night", lifespan=lifespan)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(library.router, prefix="/api/library", tags=["library"])
app.include_router(users.router, prefix="/api", tags=["users"])
# Mood router imported lazily to avoid circular imports during early phases
try:
from app.routers import mood
app.include_router(mood.router, prefix="/api", tags=["mood"])
except ImportError:
pass
@app.get("/api/poster/{item_id}")
async def poster_proxy(item_id: str, request: Request):
from app.routers.auth import get_current_user
await get_current_user(request)
image_data = await get_poster(item_id)
if image_data is None:
return FastAPIResponse(status_code=404)
return FastAPIResponse(
content=image_data,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)
# Static files must be mounted last (catches all non-API routes)
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
+51
View File
@@ -0,0 +1,51 @@
from pydantic import BaseModel
class Movie(BaseModel):
jellyfin_id: str
title: str
sort_title: str | None = None
year: int | None = None
genres: list[str] = []
overview: str | None = None
community_rating: float | None = None
critic_rating: float | None = None
runtime_minutes: int | None = None
content_rating: str | None = None
studios: list[str] = []
people: list[dict] = []
tags: list[str] = []
class MoodRequest(BaseModel):
mood: str
additional_user_ids: list[str] = []
class Recommendation(BaseModel):
jellyfin_id: str
title: str
year: int | None = None
genres: list[str] = []
community_rating: float | None = None
runtime_minutes: int | None = None
content_rating: str | None = None
poster_url: str
deep_link: str
reasoning: str
match_score: float
class MoodResponse(BaseModel):
recommendations: list[Recommendation]
meta: dict
class LoginRequest(BaseModel):
username: str
password: str
class UserInfo(BaseModel):
id: str
name: str
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
View File
+189
View File
@@ -0,0 +1,189 @@
import httpx
from app.config import settings
_headers = {
"X-MediaBrowser-Token": settings.jellyfin_api_key,
}
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
base_url=settings.jellyfin_url,
headers=_headers,
timeout=30.0,
)
return _client
async def authenticate_user(username: str, password: str) -> dict | None:
"""Authenticate a user via Jellyfin. Returns user info + token or None."""
client = _get_client()
auth_header = (
'MediaBrowser Client="Movie Night", Device="Web", DeviceId="movie-night-app", Version="1.0.0"'
)
try:
res = await client.post(
"/Users/AuthenticateByName",
json={"Username": username, "Pw": password},
headers={"X-Emby-Authorization": auth_header},
)
if res.status_code == 200:
data = res.json()
return {
"user_id": data["User"]["Id"],
"username": data["User"]["Name"],
"token": data["AccessToken"],
}
except httpx.HTTPError:
pass
return None
async def get_users() -> list[dict]:
"""Get all Jellyfin users."""
client = _get_client()
try:
res = await client.get("/Users")
if res.status_code == 200:
return [{"id": u["Id"], "name": u["Name"]} for u in res.json()]
except httpx.HTTPError:
pass
return []
async def get_all_movies(user_id: str) -> list[dict]:
"""Fetch all movies from Jellyfin with full metadata, paginated."""
client = _get_client()
movies = []
start_index = 0
page_size = 200
fields = "Genres,Overview,CommunityRating,CriticRating,OfficialRating,Studios,People,Tags,RunTimeTicks"
while True:
try:
res = await client.get(
f"/Users/{user_id}/Items",
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": fields,
"Limit": page_size,
"StartIndex": start_index,
"SortBy": "SortName",
"SortOrder": "Ascending",
},
)
if res.status_code != 200:
break
data = res.json()
items = data.get("Items", [])
if not items:
break
for item in items:
runtime_ticks = item.get("RunTimeTicks")
runtime_minutes = int(runtime_ticks / 600_000_000) if runtime_ticks else None
people = []
for person in (item.get("People") or [])[:5]:
people.append({
"name": person.get("Name", ""),
"role": person.get("Role", person.get("Type", "")),
})
movies.append({
"jellyfin_id": item["Id"],
"title": item.get("Name", "Unknown"),
"sort_title": item.get("SortName"),
"year": item.get("ProductionYear"),
"genres": item.get("Genres", []),
"overview": item.get("Overview"),
"community_rating": item.get("CommunityRating"),
"critic_rating": item.get("CriticRating"),
"runtime_minutes": runtime_minutes,
"content_rating": item.get("OfficialRating"),
"studios": [s.get("Name", "") for s in (item.get("Studios") or [])],
"people": people,
"tags": item.get("Tags", []),
"is_played": item.get("UserData", {}).get("Played", False),
})
total = data.get("TotalRecordCount", 0)
start_index += page_size
if start_index >= total:
break
except httpx.HTTPError:
break
return movies
async def get_played_movie_ids(user_id: str) -> set[str]:
"""Get set of movie IDs that have been played by a user."""
client = _get_client()
played_ids = set()
start_index = 0
page_size = 200
while True:
try:
res = await client.get(
f"/Users/{user_id}/Items",
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"IsPlayed": "true",
"Limit": page_size,
"StartIndex": start_index,
"Fields": "",
},
)
if res.status_code != 200:
break
data = res.json()
items = data.get("Items", [])
if not items:
break
for item in items:
played_ids.add(item["Id"])
total = data.get("TotalRecordCount", 0)
start_index += page_size
if start_index >= total:
break
except httpx.HTTPError:
break
return played_ids
async def get_poster(item_id: str) -> bytes | None:
"""Fetch poster image bytes for a movie."""
client = _get_client()
try:
res = await client.get(
f"/Items/{item_id}/Images/Primary",
params={"maxWidth": 400, "quality": 80},
)
if res.status_code == 200:
return res.content
except httpx.HTTPError:
pass
return None
def get_poster_url(item_id: str) -> str:
"""Get the proxied poster URL."""
return f"/api/poster/{item_id}"
def get_deep_link(item_id: str) -> str:
"""Get a direct link to the movie in Jellyfin."""
return f"{settings.jellyfin_external_url}/web/index.html#!/details?id={item_id}"
+157
View File
@@ -0,0 +1,157 @@
import asyncio
import json
import logging
from datetime import datetime, timezone
from app.config import settings
from app.database import get_db
from app.services.jellyfin import get_all_movies, get_played_movie_ids, get_users
logger = logging.getLogger("movie-night.sync")
async def sync_movie_metadata():
"""Full sync of movie metadata from Jellyfin."""
logger.info("Starting movie metadata sync...")
users = await get_users()
if not users:
logger.warning("No Jellyfin users found, skipping sync")
return
# Use first user to fetch library (all users see the same movies)
user_id = users[0]["id"]
movies = await get_all_movies(user_id)
logger.info(f"Fetched {len(movies)} movies from Jellyfin")
db = await get_db()
try:
now = datetime.now(timezone.utc).isoformat()
for movie in movies:
await db.execute(
"""INSERT OR REPLACE INTO movies
(jellyfin_id, title, sort_title, year, genres, overview,
community_rating, critic_rating, runtime_minutes, content_rating,
studios, people, tags, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
movie["jellyfin_id"],
movie["title"],
movie["sort_title"],
movie["year"],
json.dumps(movie["genres"]),
movie["overview"],
movie["community_rating"],
movie["critic_rating"],
movie["runtime_minutes"],
movie["content_rating"],
json.dumps(movie["studios"]),
json.dumps(movie["people"]),
json.dumps(movie["tags"]),
now,
),
)
# Update sync timestamp
await db.execute(
"INSERT OR REPLACE INTO sync_status (key, value) VALUES ('last_metadata_sync', ?)",
(now,),
)
await db.commit()
logger.info(f"Movie metadata sync complete: {len(movies)} movies")
finally:
await db.close()
async def sync_watch_state():
"""Sync watch state for all users."""
logger.info("Starting watch state sync...")
users = await get_users()
db = await get_db()
try:
now = datetime.now(timezone.utc).isoformat()
for user in users:
played_ids = await get_played_movie_ids(user["id"])
logger.info(f"User {user['name']}: {len(played_ids)} played movies")
# Get all movie IDs
cursor = await db.execute("SELECT jellyfin_id FROM movies")
all_movie_ids = {row["jellyfin_id"] for row in await cursor.fetchall()}
for movie_id in all_movie_ids:
is_played = 1 if movie_id in played_ids else 0
await db.execute(
"""INSERT OR REPLACE INTO watch_state
(jellyfin_id, user_id, is_played, synced_at)
VALUES (?, ?, ?, ?)""",
(movie_id, user["id"], is_played, now),
)
await db.execute(
"INSERT OR REPLACE INTO sync_status (key, value) VALUES ('last_watch_sync', ?)",
(now,),
)
await db.commit()
logger.info("Watch state sync complete")
finally:
await db.close()
async def needs_metadata_sync() -> bool:
db = await get_db()
try:
cursor = await db.execute(
"SELECT value FROM sync_status WHERE key = 'last_metadata_sync'"
)
row = await cursor.fetchone()
if not row:
return True
last_sync = datetime.fromisoformat(row["value"])
age_hours = (datetime.now(timezone.utc) - last_sync).total_seconds() / 3600
return age_hours > settings.sync_interval_hours
finally:
await db.close()
async def needs_watch_sync() -> bool:
db = await get_db()
try:
cursor = await db.execute(
"SELECT value FROM sync_status WHERE key = 'last_watch_sync'"
)
row = await cursor.fetchone()
if not row:
return True
last_sync = datetime.fromisoformat(row["value"])
age_hours = (datetime.now(timezone.utc) - last_sync).total_seconds() / 3600
return age_hours > settings.watch_state_sync_hours
finally:
await db.close()
async def run_periodic_sync():
"""Background task that periodically syncs library data."""
# Initial sync on startup
try:
if await needs_metadata_sync():
await sync_movie_metadata()
if await needs_watch_sync():
await sync_watch_state()
except Exception as e:
logger.error(f"Initial sync failed: {e}")
# Periodic sync loop
while True:
try:
await asyncio.sleep(3600) # Check every hour
if await needs_metadata_sync():
await sync_movie_metadata()
if await needs_watch_sync():
await sync_watch_state()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Periodic sync failed: {e}")
+17
View File
@@ -0,0 +1,17 @@
from app.config import settings
from app.services.llm.base import LLMProvider
def get_llm_provider() -> LLMProvider:
provider = settings.llm_provider.lower()
if provider == "anthropic":
from app.services.llm.anthropic import AnthropicProvider
return AnthropicProvider()
elif provider == "openai":
from app.services.llm.openai_provider import OpenAIProvider
return OpenAIProvider()
elif provider == "ollama":
from app.services.llm.ollama import OllamaProvider
return OllamaProvider()
else:
raise ValueError(f"Unknown LLM provider: {provider}. Must be 'anthropic', 'openai', or 'ollama'.")
+48
View File
@@ -0,0 +1,48 @@
import json
import logging
import anthropic
from app.config import settings
from app.models import Movie
from app.services.llm.base import SYSTEM_PROMPT, LLMProvider, build_user_message
logger = logging.getLogger("movie-night.llm.anthropic")
class AnthropicProvider(LLMProvider):
def __init__(self):
self.client = anthropic.AsyncAnthropic(api_key=settings.llm_api_key)
self.model = settings.llm_model or "claude-sonnet-4-6"
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
system = SYSTEM_PROMPT.format(max_results=max_results)
user_msg = build_user_message(mood, candidates)
logger.info(f"Calling Anthropic ({self.model}) with {len(candidates)} candidates")
response = await self.client.messages.create(
model=self.model,
max_tokens=2048,
system=system,
messages=[{"role": "user", "content": user_msg}],
)
text = response.content[0].text.strip()
# Parse JSON response
try:
data = json.loads(text)
return data.get("recommendations", [])
except json.JSONDecodeError:
# Try to extract JSON from the response
start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
try:
data = json.loads(text[start:end])
return data.get("recommendations", [])
except json.JSONDecodeError:
pass
logger.error(f"Failed to parse LLM response: {text[:200]}")
return []
+54
View File
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from app.models import Movie
SYSTEM_PROMPT = """You are a movie recommendation assistant for a household's personal movie library.
The user will describe their mood or what kind of movie night they want. You will receive a list of
unwatched movies from their library and recommend the best matches.
Rules:
- ONLY recommend movies from the provided list — these are movies they already own but haven't watched
- Consider genre, themes, tone, cast, era, and the movie's overview when matching to the mood
- Provide a brief, enthusiastic 1-2 sentence explanation for each pick that connects it to the mood
- Rank by how well they match the described mood, not by rating alone
- If the mood mentions kids, children, or family, only recommend age-appropriate content (G, PG, or PG-13)
- Return exactly {max_results} recommendations, or fewer only if the library has very few matches
Respond with ONLY valid JSON in this exact format, no other text:
{{
"recommendations": [
{{
"jellyfin_id": "the-exact-id-from-the-list",
"title": "Movie Title",
"reasoning": "Why this fits the mood",
"match_score": 0.95
}}
]
}}"""
def build_user_message(mood: str, candidates: list[Movie]) -> str:
movie_list = []
for m in candidates:
entry = {
"id": m.jellyfin_id,
"title": m.title,
"year": m.year,
"genres": m.genres,
"rating": m.community_rating,
"runtime_min": m.runtime_minutes,
"content_rating": m.content_rating,
"overview": (m.overview or "")[:200],
}
movie_list.append(entry)
import json
movies_json = json.dumps(movie_list, indent=None)
return f'Mood: "{mood}"\n\nAvailable unwatched movies ({len(candidates)} total):\n{movies_json}'
class LLMProvider(ABC):
@abstractmethod
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
"""Send mood + candidates to the LLM and return parsed recommendations."""
...
+50
View File
@@ -0,0 +1,50 @@
import json
import logging
from openai import AsyncOpenAI
from app.config import settings
from app.models import Movie
from app.services.llm.base import SYSTEM_PROMPT, LLMProvider, build_user_message
logger = logging.getLogger("movie-night.llm.ollama")
class OllamaProvider(LLMProvider):
def __init__(self):
base_url = settings.llm_base_url or "http://localhost:11434/v1"
self.client = AsyncOpenAI(api_key="ollama", base_url=base_url)
self.model = settings.llm_model or "llama3"
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
system = SYSTEM_PROMPT.format(max_results=max_results)
user_msg = build_user_message(mood, candidates)
logger.info(f"Calling Ollama ({self.model}) with {len(candidates)} candidates")
response = await self.client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_msg},
],
)
text = response.choices[0].message.content.strip()
try:
data = json.loads(text)
return data.get("recommendations", [])
except json.JSONDecodeError:
# Try to extract JSON from response
start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
try:
data = json.loads(text[start:end])
return data.get("recommendations", [])
except json.JSONDecodeError:
pass
logger.error(f"Failed to parse Ollama response: {text[:200]}")
return []
+44
View File
@@ -0,0 +1,44 @@
import json
import logging
from openai import AsyncOpenAI
from app.config import settings
from app.models import Movie
from app.services.llm.base import SYSTEM_PROMPT, LLMProvider, build_user_message
logger = logging.getLogger("movie-night.llm.openai")
class OpenAIProvider(LLMProvider):
def __init__(self):
kwargs = {"api_key": settings.llm_api_key}
if settings.llm_base_url:
kwargs["base_url"] = settings.llm_base_url
self.client = AsyncOpenAI(**kwargs)
self.model = settings.llm_model or "gpt-4o"
async def get_recommendations(self, mood: str, candidates: list[Movie], max_results: int = 6) -> list[dict]:
system = SYSTEM_PROMPT.format(max_results=max_results)
user_msg = build_user_message(mood, candidates)
logger.info(f"Calling OpenAI ({self.model}) with {len(candidates)} candidates")
response = await self.client.chat.completions.create(
model=self.model,
max_tokens=2048,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_msg},
],
response_format={"type": "json_object"},
)
text = response.choices[0].message.content.strip()
try:
data = json.loads(text)
return data.get("recommendations", [])
except json.JSONDecodeError:
logger.error(f"Failed to parse LLM response: {text[:200]}")
return []
+168
View File
@@ -0,0 +1,168 @@
import json
import re
from app.models import Movie
# Mood signal → genre boosts and filters
MOOD_SIGNALS = {
"kids": {"boost": ["Family", "Animation", "Comedy", "Adventure"], "penalize": ["Horror", "Thriller"], "max_rating": "PG-13"},
"children": {"boost": ["Family", "Animation", "Comedy", "Adventure"], "penalize": ["Horror", "Thriller"], "max_rating": "PG"},
"family": {"boost": ["Family", "Animation", "Comedy", "Adventure"], "penalize": ["Horror", "Thriller"], "max_rating": "PG-13"},
"scary": {"boost": ["Horror", "Thriller", "Mystery"], "penalize": [], "max_rating": None},
"horror": {"boost": ["Horror", "Thriller"], "penalize": [], "max_rating": None},
"spooky": {"boost": ["Horror", "Thriller", "Mystery", "Fantasy"], "penalize": [], "max_rating": None},
"creepy": {"boost": ["Horror", "Thriller", "Mystery"], "penalize": [], "max_rating": None},
"funny": {"boost": ["Comedy"], "penalize": ["Horror", "War"], "max_rating": None},
"comedy": {"boost": ["Comedy"], "penalize": [], "max_rating": None},
"laugh": {"boost": ["Comedy"], "penalize": [], "max_rating": None},
"light": {"boost": ["Comedy", "Romance", "Animation", "Family"], "penalize": ["Horror", "Thriller", "War"], "max_rating": None},
"fun": {"boost": ["Comedy", "Adventure", "Animation", "Action"], "penalize": ["Horror", "War"], "max_rating": None},
"feel-good": {"boost": ["Comedy", "Romance", "Family", "Animation"], "penalize": ["Horror", "Thriller", "War"], "max_rating": None},
"relax": {"boost": ["Comedy", "Romance", "Drama"], "penalize": ["Horror", "Thriller", "Action"], "max_rating": None},
"action": {"boost": ["Action", "Adventure", "Sci-Fi", "Thriller"], "penalize": [], "max_rating": None},
"exciting": {"boost": ["Action", "Adventure", "Thriller"], "penalize": [], "max_rating": None},
"adventure": {"boost": ["Adventure", "Action", "Fantasy", "Sci-Fi"], "penalize": [], "max_rating": None},
"intense": {"boost": ["Action", "Thriller", "Drama", "War"], "penalize": [], "max_rating": None},
"romantic": {"boost": ["Romance", "Comedy", "Drama"], "penalize": ["Horror", "War"], "max_rating": None},
"romance": {"boost": ["Romance", "Comedy", "Drama"], "penalize": [], "max_rating": None},
"date night": {"boost": ["Romance", "Comedy", "Drama", "Thriller"], "penalize": [], "max_rating": None},
"date": {"boost": ["Romance", "Comedy", "Drama"], "penalize": [], "max_rating": None},
"sad": {"boost": ["Drama", "Romance"], "penalize": ["Comedy", "Animation"], "max_rating": None},
"cry": {"boost": ["Drama", "Romance", "War"], "penalize": [], "max_rating": None},
"drama": {"boost": ["Drama"], "penalize": [], "max_rating": None},
"sci-fi": {"boost": ["Science Fiction", "Sci-Fi", "Fantasy"], "penalize": [], "max_rating": None},
"space": {"boost": ["Science Fiction", "Sci-Fi"], "penalize": [], "max_rating": None},
"fantasy": {"boost": ["Fantasy", "Adventure"], "penalize": [], "max_rating": None},
"mystery": {"boost": ["Mystery", "Thriller", "Crime"], "penalize": [], "max_rating": None},
"crime": {"boost": ["Crime", "Thriller", "Mystery"], "penalize": [], "max_rating": None},
"documentary": {"boost": ["Documentary"], "penalize": [], "max_rating": None},
"war": {"boost": ["War", "History", "Drama"], "penalize": [], "max_rating": None},
"classic": {"boost": [], "penalize": [], "max_rating": None},
"animated": {"boost": ["Animation"], "penalize": [], "max_rating": None},
"anime": {"boost": ["Animation"], "penalize": [], "max_rating": None},
"music": {"boost": ["Music", "Musical"], "penalize": [], "max_rating": None},
"musical": {"boost": ["Music", "Musical"], "penalize": [], "max_rating": None},
"western": {"boost": ["Western"], "penalize": [], "max_rating": None},
"superhero": {"boost": ["Action", "Adventure", "Science Fiction"], "penalize": [], "max_rating": None},
}
# Content rating hierarchy for family filtering
RATING_ORDER = ["G", "PG", "PG-13", "R", "NC-17", "NR", "Not Rated", None]
def _parse_decade(mood: str) -> tuple[int, int] | None:
"""Extract decade filter from mood text."""
match = re.search(r"\b(19|20)(\d)0s\b", mood.lower())
if match:
decade_start = int(match.group(1) + match.group(2) + "0")
return (decade_start, decade_start + 9)
match = re.search(r"\b(old|classic|vintage|retro)\b", mood.lower())
if match:
return (1920, 1989)
return None
def _is_rating_appropriate(content_rating: str | None, max_rating: str | None) -> bool:
"""Check if a movie's content rating is at or below the max allowed."""
if max_rating is None:
return True
if content_rating is None:
return True # Unknown rating, let it through
try:
movie_idx = RATING_ORDER.index(content_rating)
max_idx = RATING_ORDER.index(max_rating)
return movie_idx <= max_idx
except ValueError:
return True # Unknown rating format, let it through
def _parse_movie(raw: dict) -> Movie:
"""Convert a raw DB row dict into a Movie model."""
return Movie(
jellyfin_id=raw["jellyfin_id"],
title=raw["title"],
sort_title=raw.get("sort_title"),
year=raw.get("year"),
genres=json.loads(raw.get("genres") or "[]"),
overview=raw.get("overview"),
community_rating=raw.get("community_rating"),
critic_rating=raw.get("critic_rating"),
runtime_minutes=raw.get("runtime_minutes"),
content_rating=raw.get("content_rating"),
studios=json.loads(raw.get("studios") or "[]"),
people=json.loads(raw.get("people") or "[]"),
tags=json.loads(raw.get("tags") or "[]"),
)
def prefilter_candidates(movies_raw: list[dict], mood: str, max_candidates: int = 200) -> list[Movie]:
"""Score and filter movies based on mood signals. Returns top candidates as Movie models."""
mood_lower = mood.lower()
# Collect all active signals
boost_genres: set[str] = set()
penalize_genres: set[str] = set()
max_rating: str | None = None
decade = _parse_decade(mood)
for keyword, signals in MOOD_SIGNALS.items():
if keyword in mood_lower:
boost_genres.update(signals["boost"])
penalize_genres.update(signals["penalize"])
if signals["max_rating"] and (max_rating is None or RATING_ORDER.index(signals["max_rating"]) < RATING_ORDER.index(max_rating)):
max_rating = signals["max_rating"]
# Remove any genres that appear in both boost and penalize
penalize_genres -= boost_genres
scored: list[tuple[float, dict]] = []
for raw in movies_raw:
movie_genres = set(json.loads(raw.get("genres") or "[]"))
content_rating = raw.get("content_rating")
# Filter by content rating
if not _is_rating_appropriate(content_rating, max_rating):
continue
# Filter by decade
year = raw.get("year")
if decade and year:
if year < decade[0] or year > decade[1]:
continue
# Score the movie
score = 0.0
# Genre match bonus
if boost_genres:
genre_overlap = len(movie_genres & boost_genres)
score += genre_overlap * 3.0
# Genre penalty
if penalize_genres:
penalty_overlap = len(movie_genres & penalize_genres)
score -= penalty_overlap * 2.0
# Rating bonus (higher rated movies get a small boost)
rating = raw.get("community_rating")
if rating:
score += rating * 0.3
# Keyword match in overview
overview = (raw.get("overview") or "").lower()
mood_words = [w for w in mood_lower.split() if len(w) > 3]
for word in mood_words:
if word in overview:
score += 1.0
scored.append((score, raw))
# Sort by score descending
scored.sort(key=lambda x: x[0], reverse=True)
# Return top candidates as Movie models
return [_parse_movie(raw) for _, raw in scored[:max_candidates]]
+222
View File
@@ -0,0 +1,222 @@
// Movie Night — Frontend Logic
const API = '';
let currentUser = null;
let selectedUserIds = [];
// --- Auth ---
async function checkAuth() {
try {
const res = await fetch(`${API}/api/auth/me`);
if (res.ok) {
currentUser = await res.json();
showMainScreen();
} else {
showLoginScreen();
}
} catch {
showLoginScreen();
}
}
async function login(username, password) {
const res = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
throw new Error(err.detail || 'Login failed');
}
currentUser = await res.json();
showMainScreen();
}
async function logout() {
await fetch(`${API}/api/auth/logout`, { method: 'POST' });
currentUser = null;
showLoginScreen();
}
// --- Screens ---
function showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
function showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
document.getElementById('user-name').textContent = currentUser.name;
selectedUserIds = [currentUser.id];
loadUsers();
loadStats();
}
// --- Users ---
async function loadUsers() {
try {
const res = await fetch(`${API}/api/users`);
if (!res.ok) return;
const users = await res.json();
const container = document.getElementById('user-pills');
container.innerHTML = '';
users.forEach(user => {
const pill = document.createElement('button');
pill.className = `user-pill border rounded-full px-4 py-1.5 text-sm ${selectedUserIds.includes(user.id) ? 'active' : ''}`;
pill.textContent = user.name;
pill.onclick = () => toggleUser(user.id, pill);
container.appendChild(pill);
});
} catch { /* ignore */ }
}
function toggleUser(userId, pill) {
if (selectedUserIds.includes(userId)) {
if (selectedUserIds.length > 1) {
selectedUserIds = selectedUserIds.filter(id => id !== userId);
pill.classList.remove('active');
}
} else {
selectedUserIds.push(userId);
pill.classList.add('active');
}
}
// --- Mood / Recommendations ---
async function findMovies() {
const mood = document.getElementById('mood-input').value.trim();
if (!mood) return;
document.getElementById('empty-state').classList.add('hidden');
document.getElementById('results').classList.add('hidden');
document.getElementById('error-state').classList.add('hidden');
document.getElementById('loading').classList.remove('hidden');
try {
const additionalIds = selectedUserIds.filter(id => id !== currentUser.id);
const res = await fetch(`${API}/api/mood`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mood, additional_user_ids: additionalIds })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Something went wrong' }));
throw new Error(err.detail || 'Failed to get recommendations');
}
const data = await res.json();
renderResults(data);
} catch (err) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('error-message').textContent = err.message;
document.getElementById('error-state').classList.remove('hidden');
}
}
function renderResults(data) {
document.getElementById('loading').classList.add('hidden');
const grid = document.getElementById('results-grid');
grid.innerHTML = '';
if (!data.recommendations || data.recommendations.length === 0) {
document.getElementById('empty-state').classList.remove('hidden');
return;
}
data.recommendations.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card bg-dark-200 rounded-xl overflow-hidden border border-gray-800';
card.innerHTML = `
<div class="aspect-[2/3] bg-dark-300 relative">
<img src="${movie.poster_url}" alt="${movie.title}"
class="w-full h-full object-cover"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="absolute inset-0 flex items-center justify-center text-gray-600 text-lg font-semibold" style="display:none;">
${movie.title}
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg leading-tight">${movie.title}</h3>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-400">
${movie.year ? `<span>${movie.year}</span>` : ''}
${movie.runtime_minutes ? `<span>&middot; ${movie.runtime_minutes}m</span>` : ''}
${movie.community_rating ? `<span>&middot; ★ ${movie.community_rating.toFixed(1)}</span>` : ''}
${movie.content_rating ? `<span>&middot; ${movie.content_rating}</span>` : ''}
</div>
<div class="flex flex-wrap gap-1 mt-2">
${movie.genres.slice(0, 3).map(g => `<span class="genre-pill">${g}</span>`).join('')}
</div>
<p class="text-sm text-gray-400 mt-3 italic leading-relaxed">${movie.reasoning}</p>
<a href="${movie.deep_link}" target="_blank"
class="block mt-4 text-center py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg font-semibold transition-colors text-sm">
Watch
</a>
</div>
`;
grid.appendChild(card);
});
const meta = data.meta;
document.getElementById('results-meta').textContent =
`Evaluated ${meta.candidates_evaluated} of ${meta.total_unwatched} unwatched movies in ${(meta.processing_time_ms / 1000).toFixed(1)}s`;
document.getElementById('results').classList.remove('hidden');
}
// --- Library Stats ---
async function loadStats() {
try {
const res = await fetch(`${API}/api/library/stats`);
if (!res.ok) return;
const stats = await res.json();
document.getElementById('library-stats').textContent =
`${stats.total_movies} movies in library${stats.last_sync ? ` · Last synced ${new Date(stats.last_sync).toLocaleString()}` : ''}`;
} catch { /* ignore */ }
}
async function refreshLibrary() {
document.getElementById('refresh-btn').textContent = 'Syncing...';
try {
await fetch(`${API}/api/library/sync`, { method: 'POST' });
setTimeout(loadStats, 5000);
} catch { /* ignore */ }
setTimeout(() => {
document.getElementById('refresh-btn').textContent = 'Refresh Library';
}, 3000);
}
// --- Event Listeners ---
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
errorEl.classList.add('hidden');
try {
await login(
document.getElementById('login-username').value,
document.getElementById('login-password').value
);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
}
});
document.getElementById('find-btn').addEventListener('click', findMovies);
document.getElementById('mood-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') findMovies();
});
document.getElementById('logout-btn').addEventListener('click', logout);
document.getElementById('refresh-btn').addEventListener('click', refreshLibrary);
// --- Init ---
checkAuth();
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie Night</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/styles.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
dark: { 50: '#f8fafc', 100: '#1e293b', 200: '#1a2332', 300: '#151d2b', 400: '#111827', 500: '#0d1320' }
}
}
}
}
</script>
</head>
<body class="bg-dark-400 text-gray-100 min-h-screen">
<div id="app" class="max-w-4xl mx-auto px-4 py-8">
<!-- Login Screen -->
<div id="login-screen" class="hidden flex flex-col items-center justify-center min-h-[80vh]">
<h1 class="text-4xl font-bold mb-2">Movie Night</h1>
<p class="text-gray-400 mb-8">Sign in with your Jellyfin account</p>
<form id="login-form" class="w-full max-w-sm space-y-4">
<input type="text" id="login-username" placeholder="Username"
class="w-full px-4 py-3 bg-dark-200 border border-gray-700 rounded-lg focus:outline-none focus:border-indigo-500 text-gray-100">
<input type="password" id="login-password" placeholder="Password"
class="w-full px-4 py-3 bg-dark-200 border border-gray-700 rounded-lg focus:outline-none focus:border-indigo-500 text-gray-100">
<button type="submit"
class="w-full py-3 bg-indigo-600 hover:bg-indigo-700 rounded-lg font-semibold transition-colors">
Sign In
</button>
<p id="login-error" class="text-red-400 text-sm text-center hidden"></p>
</form>
</div>
<!-- Main App Screen -->
<div id="main-screen" class="hidden">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold">Movie Night</h1>
<div class="flex items-center gap-3">
<span id="user-name" class="text-gray-400 text-sm"></span>
<button id="logout-btn"
class="text-sm text-gray-500 hover:text-gray-300 transition-colors">Logout</button>
</div>
</div>
<!-- User Picker -->
<div id="user-picker" class="mb-6">
<p class="text-sm text-gray-400 mb-2">Show unwatched by:</p>
<div id="user-pills" class="flex flex-wrap gap-2"></div>
</div>
<!-- Mood Input -->
<div class="mb-8">
<div class="relative">
<input type="text" id="mood-input" placeholder="What are you in the mood for?"
class="w-full px-5 py-4 bg-dark-200 border border-gray-700 rounded-xl text-lg focus:outline-none focus:border-indigo-500 text-gray-100 pr-24">
<button id="find-btn"
class="absolute right-2 top-1/2 -translate-y-1/2 px-5 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg font-semibold transition-colors text-sm">
Find Movies
</button>
</div>
<p class="text-gray-600 text-sm mt-2">
Try: "pizza night with the kids" &middot; "something scary" &middot; "light fun movie after a hard week" &middot; "80s action"
</p>
</div>
<!-- Loading State -->
<div id="loading" class="hidden">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="skeleton-card bg-dark-200 rounded-xl h-96 animate-pulse"></div>
<div class="skeleton-card bg-dark-200 rounded-xl h-96 animate-pulse delay-100"></div>
<div class="skeleton-card bg-dark-200 rounded-xl h-96 animate-pulse delay-200"></div>
</div>
</div>
<!-- Results -->
<div id="results" class="hidden">
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"></div>
<div id="results-meta" class="mt-6 text-center text-gray-500 text-sm"></div>
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-16">
<p class="text-gray-500 text-lg">Describe what you're in the mood for and we'll find the perfect movie from your library.</p>
</div>
<!-- Error State -->
<div id="error-state" class="hidden text-center py-16">
<p class="text-red-400 text-lg" id="error-message"></p>
<button onclick="document.getElementById('error-state').classList.add('hidden'); document.getElementById('empty-state').classList.remove('hidden');"
class="mt-4 text-sm text-gray-400 hover:text-gray-200">Try again</button>
</div>
<!-- Footer -->
<div class="mt-12 pt-6 border-t border-gray-800 flex items-center justify-between text-sm text-gray-600">
<span id="library-stats"></span>
<button id="refresh-btn" class="hover:text-gray-400 transition-colors">Refresh Library</button>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.movie-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.movie-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.genre-pill {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 9999px;
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
.user-pill {
cursor: pointer;
transition: all 0.15s ease;
}
.user-pill.active {
background: rgba(99, 102, 241, 0.3);
border-color: #6366f1;
color: #c7d2fe;
}
.user-pill:not(.active) {
background: transparent;
border-color: #374151;
color: #6b7280;
}