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:
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
tests/
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
*.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.db
|
||||
.pytest_cache/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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}")
|
||||
@@ -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'.")
|
||||
@@ -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 []
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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]]
|
||||
@@ -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>· ${movie.runtime_minutes}m</span>` : ''}
|
||||
${movie.community_rating ? `<span>· ★ ${movie.community_rating.toFixed(1)}</span>` : ''}
|
||||
${movie.content_rating ? `<span>· ${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();
|
||||
@@ -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" · "something scary" · "light fun movie after a hard week" · "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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user