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
+8
View File
@@ -0,0 +1,8 @@
.env
*.md
.git
.gitignore
__pycache__
*.pyc
.pytest_cache
tests/
+13
View File
@@ -0,0 +1,13 @@
# Jellyfin connection
JELLYFIN_URL=http://192.168.5.254:8096
JELLYFIN_API_KEY=your-jellyfin-api-key-here
JELLYFIN_EXTERNAL_URL=https://jellyfin.internal.bondelie.net
# LLM provider: anthropic, openai, or ollama
LLM_PROVIDER=anthropic
LLM_API_KEY=your-api-key-here
LLM_MODEL=claude-sonnet-4-6
# LLM_BASE_URL=http://localhost:11434/v1 # uncomment for ollama
# App settings
SESSION_SECRET=generate-a-random-string-here
+5
View File
@@ -0,0 +1,5 @@
*.env
*.pyc
__pycache__/
*.db
.pytest_cache/
+95
View File
@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
"Movie Night" — an AI-powered media discovery web app for a household with ~53TB of movies on a self-hosted Jellyfin media server. Users describe their mood ("pizza night with the kids", "light fun movie after a hard week") and get AI-ranked recommendations from their unwatched library, with posters and one-click deep links to Jellyfin.
**Users:** Kenny and his wife — the primary use case is "what should we watch tonight?"
## Architecture
```
Frontend (Tailwind + vanilla JS) → FastAPI backend → LLM (Claude/OpenAI/Ollama)
Jellyfin API → SQLite cache
```
- **Backend:** Python 3.12 + FastAPI (app/)
- **Frontend:** Single-page HTML/CSS/JS with Tailwind CDN (app/static/)
- **LLM:** Modular provider — Anthropic Claude, OpenAI, or Ollama (app/services/llm/)
- **Auth:** Jellyfin credentials (like Jellyseerr) — no separate user database
- **Data:** SQLite cache of movie metadata synced from Jellyfin, persisted at /data/library.db
- **Deployment:** Docker Compose on bondelie-media (192.168.5.254), port 5210
## Key Files
- `app/main.py` — FastAPI app entry, lifespan (DB init + background sync), poster proxy, static mount
- `app/config.py` — Settings via pydantic-settings (env vars)
- `app/database.py` — SQLite schema and query helpers
- `app/routers/auth.py` — Jellyfin-based login/logout/session management
- `app/routers/mood.py` — POST /api/mood — the core recommendation flow
- `app/services/jellyfin.py` — Jellyfin API client (auth, library fetch, watch state, posters)
- `app/services/prefilter.py` — Tier-1 keyword/genre scoring to narrow candidates before LLM
- `app/services/llm/` — Modular LLM providers (anthropic, openai, ollama)
- `app/services/library_sync.py` — Background sync of movie metadata + watch state
- `app/static/index.html` — Frontend: login, mood input, result cards
- `app/static/app.js` — Frontend logic
## Related Infrastructure
Integrates with the media server documented in `../media-server-migration/`:
| Service | Port | Purpose |
|-------------|-------|--------------------------------------------|
| Jellyfin | 8096 | Library metadata, watch state, posters |
| Movie Night | 5210 | This app |
**Server:** bondelie-media (192.168.5.254) — Ubuntu 24.04 LTS
**Proxy:** `movienight.internal.bondelie.net`
## Recommendation Flow
1. User types mood text
2. Backend fetches unwatched movies from SQLite cache (filtered by selected users' watch state)
3. `prefilter.py` scores movies by genre/keyword match, returns top 200 candidates
4. LLM provider ranks candidates and returns top 6 with reasoning (structured JSON)
5. Backend validates jellyfin_ids, enriches with poster URLs and deep links
6. Frontend renders movie cards with posters, metadata, reasoning, and "Watch" button
## Secrets
Store in `.env` (gitignored), never commit:
- `JELLYFIN_API_KEY` — Jellyfin API key (Settings → API Keys)
- `LLM_API_KEY` — Anthropic/OpenAI API key (not needed for Ollama)
- `SESSION_SECRET` — Random string for cookie signing
## Development Commands
```bash
# Local development (requires Python 3.12+)
pip install -r requirements.txt
cp .env.example .env # then fill in API keys
uvicorn app.main:app --reload --port 5210
# Docker build and run
docker compose up --build
# Deploy to bondelie-media
scp -r . kenny@192.168.5.254:/srv/stacks/movie-night/
ssh kenny@192.168.5.254 "cd /srv/stacks/movie-night && docker compose up --build -d"
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| JELLYFIN_URL | Yes | http://192.168.5.254:8096 | Jellyfin server URL |
| JELLYFIN_API_KEY | Yes | — | Jellyfin API key |
| JELLYFIN_EXTERNAL_URL | Yes | https://jellyfin.internal.bondelie.net | URL for deep links |
| LLM_PROVIDER | No | anthropic | anthropic, openai, or ollama |
| LLM_API_KEY | Yes* | — | API key (*not needed for ollama) |
| LLM_MODEL | No | varies by provider | Model name override |
| LLM_BASE_URL | No | — | Base URL override (for ollama) |
| SESSION_SECRET | Yes | — | Random string for sessions |
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
EXPOSE 5210
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5210"]
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;
}
+13
View File
@@ -0,0 +1,13 @@
services:
movie-night:
build: .
container_name: movie-night
environment:
- TZ=America/Los_Angeles
env_file:
- .env
volumes:
- /srv/configs/movie-night:/data
ports:
- "5210:5210"
restart: unless-stopped
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
anthropic==0.43.0
openai==1.60.0
aiosqlite==0.21.0
pydantic-settings==2.7.1
+160
View File
@@ -0,0 +1,160 @@
# Media Discovery Tool — Research & Planning
## Problem Statement
With ~32TB of media (mostly movies and TV shows), choosing what to watch has become its own challenge. The goal is a simple, AI-powered tool that:
- Knows what's in the library and what's been watched
- Accepts natural language mood/theme descriptions ("light fun movie after a hard week", "pizza and movie night with the kids")
- Recommends unwatched titles that match
- Optionally: swipe/card UI for browsing picks together
---
## Landscape Scan (March 2026)
### Tinder-Style Swipe Apps
| Project | Jellyfin? | AI? | Status | Notes |
|---------|-----------|-----|--------|-------|
| **[Swiparr](https://github.com/m3sserstudi0s/swiparr)** | Yes (+ Plex, Emby, TMDb) | No | Active (~299 stars), Docker support | Best swipe project for our stack. Group sessions where multiple users swipe and get matched on shared picks. Mobile-first PWA. **No mood/AI layer.** |
| **[MovieMatch](https://github.com/LukeChannings/moviematch)** | No (Plex only) | No | Mature, last update ~Aug 2025 | The original "Tinder for Plex." Multiple users swipe, matches appear on agreement. |
| **[Plexensus](https://github.com/OperationFman/Plexensus)** | No (Plex only) | No | Smaller, less maintained | Similar concept to MovieMatch. |
**Verdict:** Swiparr is the closest to what we'd want for a swipe UI, but none of these have any AI/mood-based filtering. They just shuffle the deck randomly.
### AI/LLM-Powered Recommendation Tools
| Project | Jellyfin? | Mood Input? | LLM Support | Status | Notes |
|---------|-----------|-------------|-------------|--------|-------|
| **[Recommendarr](https://github.com/fingerthief/recommendarr)** | Yes (+ Plex, Radarr, Sonarr) | Yes | OpenAI, Ollama, LM Studio, any OpenAI-compatible | **Repo deleted/private — avoid** | Was the closest to what we wanted. GitHub repo returns 404 as of March 2026. Possibly related to huntarr supply-chain incident. |
| **[SuggestArr](https://github.com/giuseppe99barchetta/SuggestArr)** | Yes (+ Plex, Emby) | Yes (natural language search) | OpenAI, Ollama, Gemini, LiteLLM | Active (v2.4.3, 1.1k stars, MIT) | Clean project, no security concerns. **Deployed and tested.** AI search works but the tool is focused on suggesting *new content to add* via Jellyseerr, not discovering what's already in the library. Keeping deployed for its content pipeline use case. |
| **[Discovarr](https://github.com/sqrlmstr5000/discovarr)** | Yes (+ Plex) | Partial | Gemini, OpenAI-compatible | Smaller, less mature | FastAPI + Vue.js. Tracks watch history. |
| **[PlexIs](https://github.com/JulesMellot/PlexIs)** | Yes (+ Plex) | Theme/keyword search | GROQ, Ollama, OpenAI | Active | AI-powered *collection* manager — creates themed collections, not a recommendation UI. |
| **[plex-recommendations-ai](https://github.com/rocstack/plex-recommendations-ai)** | No (Plex only) | No | OpenAI | Smaller | Auto-creates a Plex collection with AI picks. ~$0.01/day. |
### Jellyfin-Native Plugins
| Plugin | Approach | Notes |
|--------|----------|-------|
| **[LocalRecs](https://github.com/rdpharr/jellyfin-plugin-localrecs)** | TF-IDF + cosine similarity | Privacy-first, no cloud. Per-user virtual libraries. Handles 2,000+ items. No LLM, no mood input. |
| **[JellyNext](https://github.com/luall0/jellynext)** | Trakt-powered recommendations | Links Trakt account, creates per-user virtual libraries. One-click Jellyseerr downloads. Not AI-based. |
| **Jellyfin built-in** | Basic "because you watched X" | Limited. [Feature request](https://features.jellyfin.org/posts/2737) open for a local recommendation engine. |
### Other Interesting Patterns
- **[plex-recommendation](https://github.com/wgeorgecook/plex-recommendation)** — RAG-powered (LangChain in Go), feeds watch history into LLM chain with Postgres caching
- **[MovieGPT](https://github.com/rafaelpierre/moviegpt)** — RAG + vector DB for semantic movie search (not library-integrated)
- **[AudioMuse AI](https://brainsteam.co.uk/2025/8/16/smart-jellyfin-playlists-AudioMuseAI/)** — Music, not movies, but interesting: describes mood in English → converts to SQL queries against Jellyfin. Same pattern we'd want.
---
## Evaluation Results (March 14, 2026)
### Deployed & Tested
| Tool | Status | Verdict |
|------|--------|---------|
| **Swiparr** | Deployed on bondelie-media (port 4321) | Fun swipe UI, good for "both swipe and match" sessions. No AI. **Keeping.** |
| **SuggestArr** | Deployed on bondelie-media (port 5100) | AI search works but focused on adding new content via Jellyseerr, not browsing existing library. **Keeping for content pipeline.** |
| **Recommendarr** | Deployed, then removed | GitHub repo gone (404). Registration required undocumented curl workaround. Likely related to huntarr incident. **Removed.** |
### Conclusion
**No existing tool solves our core problem:** "Tell me what mood you're in and I'll recommend something unwatched from your existing library."
- Swiparr does swipe UI but no intelligence
- SuggestArr does AI but for adding new content, not browsing existing
- Recommendarr is dead
- Jellyfin plugins are too basic (no natural language, no mood)
**Decision: Build a custom tool.**
---
## Custom Build — Integration Points on bondelie-media
### Jellyfin API (port 8096)
- `GET /Users/{userId}/Items` — Full library listing with metadata
- `GET /Users/{userId}/Items?IsPlayed=false` — Unwatched items
- `GET /Users/{userId}/Items?IsPlayed=true` — Watch history
- `GET /Items/{itemId}` — Detailed metadata (genres, cast, overview, ratings, year)
- `GET /Items/{itemId}/Images` — Poster art for card UI
- Watch status per user (Kenny vs. wife may have different watch states)
### Radarr API (port 7878)
- `GET /api/v3/movie` — All movies with TMDb metadata, quality info, file paths
- Rich metadata: genres, ratings, year, overview, studio, runtime
### Sonarr API (port 8989)
- `GET /api/v3/series` — All TV series with TVDb metadata
- Episode-level tracking (partially watched shows)
### Jellystat API (port 3004)
- Viewing statistics — what's been watched most, recently, etc.
- Could inform "you haven't watched anything like X in a while" suggestions
## Custom Build — Proposed MVP Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Simple Web UI │────▶│ Python Backend │────▶│ Claude API │
│ (mood input) │◀────│ (FastAPI) │◀────│ (matching) │
└─────────────────┘ └──────────────────┘ └─────────────┘
│ ▲
▼ │
┌──────────────────┐
│ Jellyfin API │
│ Radarr API │
│ (library + art) │
└──────────────────┘
```
### Flow
1. User opens app, types mood: "pizza and movie night with the kids"
2. Backend fetches unwatched movies from Jellyfin (with metadata + posters)
3. Backend sends movie list + mood to Claude API
4. Claude returns ranked recommendations with brief explanations
5. UI shows top picks with posters, ratings, year, and "why this fits"
6. Pick one and deep link to Jellyfin to start watching
### Tech Stack (Proposed)
- **Backend:** Python 3.12 + FastAPI
- **Frontend:** Simple HTML/CSS/JS (or Svelte for reactivity) — not a heavy framework
- **AI:** Anthropic Claude API (claude-sonnet-4-6 for speed/cost)
- **Data:** On-demand Jellyfin API calls, cached locally (SQLite or just JSON)
- **Deployment:** Docker Compose on bondelie-media
## Key Design Questions for Custom Build
1. **Movies only for MVP, or include TV shows?**
- Movies are simpler (watched/not watched is binary)
- TV shows have partial progress complexity
2. **Per-user or household?**
- Jellyfin tracks watch state per user
- "Unwatched by both" is the sweet spot for movie night
3. **Claude API vs. local LLM (Ollama)?**
- Claude API: better quality, simple, costs pennies per query
- Ollama: free, private, but needs good hardware and more tuning
4. **How much metadata to send to the LLM?**
- Full library could be thousands of movies — pre-filter by genre/year first?
- Or cache embeddings and do semantic search before LLM ranking?
5. **Caching strategy?**
- Library metadata doesn't change often — cache and refresh periodically?
- Watch state changes more frequently
## Next Steps
- [x] Deploy Swiparr on bondelie-media — done, keeping
- [x] Deploy SuggestArr on bondelie-media — done, keeping for content pipeline
- [x] Deploy Recommendarr — done, removed (dead project)
- [x] Decide build vs. adopt — **building custom**
- [ ] Plan custom build architecture
- [ ] Set up project scaffolding
- [ ] Get API keys from Jellyfin
- [ ] Prototype Claude API with sample movie metadata + mood prompts
- [ ] Build UI
- [ ] Dockerize and deploy