From 3d5de06b4465ddad712fa781dca382c5e90545f5 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sat, 14 Mar 2026 19:20:56 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Movie=20Night=20?= =?UTF-8?q?media=20discovery=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 8 + .env.example | 13 ++ .gitignore | 5 + CLAUDE.md | 95 ++++++++++++ Dockerfile | 12 ++ app/__init__.py | 0 app/config.py | 27 ++++ app/database.py | 90 +++++++++++ app/main.py | 59 ++++++++ app/models.py | 51 +++++++ app/routers/__init__.py | 0 app/routers/auth.py | 92 ++++++++++++ app/routers/library.py | 45 ++++++ app/routers/mood.py | 100 +++++++++++++ app/routers/users.py | 13 ++ app/services/__init__.py | 0 app/services/jellyfin.py | 189 +++++++++++++++++++++++ app/services/library_sync.py | 157 ++++++++++++++++++++ app/services/llm/__init__.py | 17 +++ app/services/llm/anthropic.py | 48 ++++++ app/services/llm/base.py | 54 +++++++ app/services/llm/ollama.py | 50 +++++++ app/services/llm/openai_provider.py | 44 ++++++ app/services/prefilter.py | 168 +++++++++++++++++++++ app/static/app.js | 222 ++++++++++++++++++++++++++++ app/static/index.html | 109 ++++++++++++++ app/static/styles.css | 33 +++++ docker-compose.yml | 13 ++ requirements.txt | 7 + research.md | 160 ++++++++++++++++++++ 30 files changed, 1881 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/library.py create mode 100644 app/routers/mood.py create mode 100644 app/routers/users.py create mode 100644 app/services/__init__.py create mode 100644 app/services/jellyfin.py create mode 100644 app/services/library_sync.py create mode 100644 app/services/llm/__init__.py create mode 100644 app/services/llm/anthropic.py create mode 100644 app/services/llm/base.py create mode 100644 app/services/llm/ollama.py create mode 100644 app/services/llm/openai_provider.py create mode 100644 app/services/prefilter.py create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/static/styles.css create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 research.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bafb51e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +*.md +.git +.gitignore +__pycache__ +*.pyc +.pytest_cache +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a6bdb6c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e3a5d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.env +*.pyc +__pycache__/ +*.db +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e90c618 --- /dev/null +++ b/CLAUDE.md @@ -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 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9563127 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..833efff --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..3c0e625 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..90c305a --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..23be37e --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..5a1471f --- /dev/null +++ b/app/routers/auth.py @@ -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"]} diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..99630a6 --- /dev/null +++ b/app/routers/library.py @@ -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() diff --git a/app/routers/mood.py b/app/routers/mood.py new file mode 100644 index 0000000..2882edd --- /dev/null +++ b/app/routers/mood.py @@ -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, + }, + ) diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..3a9dbec --- /dev/null +++ b/app/routers/users.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/jellyfin.py b/app/services/jellyfin.py new file mode 100644 index 0000000..c693bf4 --- /dev/null +++ b/app/services/jellyfin.py @@ -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}" diff --git a/app/services/library_sync.py b/app/services/library_sync.py new file mode 100644 index 0000000..d984698 --- /dev/null +++ b/app/services/library_sync.py @@ -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}") diff --git a/app/services/llm/__init__.py b/app/services/llm/__init__.py new file mode 100644 index 0000000..e3413d9 --- /dev/null +++ b/app/services/llm/__init__.py @@ -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'.") diff --git a/app/services/llm/anthropic.py b/app/services/llm/anthropic.py new file mode 100644 index 0000000..121fc3d --- /dev/null +++ b/app/services/llm/anthropic.py @@ -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 [] diff --git a/app/services/llm/base.py b/app/services/llm/base.py new file mode 100644 index 0000000..7678402 --- /dev/null +++ b/app/services/llm/base.py @@ -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.""" + ... diff --git a/app/services/llm/ollama.py b/app/services/llm/ollama.py new file mode 100644 index 0000000..1de8e4a --- /dev/null +++ b/app/services/llm/ollama.py @@ -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 [] diff --git a/app/services/llm/openai_provider.py b/app/services/llm/openai_provider.py new file mode 100644 index 0000000..bcddb6f --- /dev/null +++ b/app/services/llm/openai_provider.py @@ -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 [] diff --git a/app/services/prefilter.py b/app/services/prefilter.py new file mode 100644 index 0000000..a683c40 --- /dev/null +++ b/app/services/prefilter.py @@ -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]] diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..3d23338 --- /dev/null +++ b/app/static/app.js @@ -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 = ` +
+ ${movie.title} + +
+
+

${movie.title}

+
+ ${movie.year ? `${movie.year}` : ''} + ${movie.runtime_minutes ? `· ${movie.runtime_minutes}m` : ''} + ${movie.community_rating ? `· ★ ${movie.community_rating.toFixed(1)}` : ''} + ${movie.content_rating ? `· ${movie.content_rating}` : ''} +
+
+ ${movie.genres.slice(0, 3).map(g => `${g}`).join('')} +
+

${movie.reasoning}

+ + Watch + +
+ `; + 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(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e71b575 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,109 @@ + + + + + + Movie Night + + + + + +
+ + + + + +
+ + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..d83d9d7 --- /dev/null +++ b/app/static/styles.css @@ -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; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b118545 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ea3f446 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/research.md b/research.md new file mode 100644 index 0000000..2346979 --- /dev/null +++ b/research.md @@ -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