Files
kino/backend/server.py
T
2026-04-29 14:49:07 +00:00

486 lines
17 KiB
Python

import os
import uuid
import logging
import mimetypes
from pathlib import Path
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request
from fastapi.responses import StreamingResponse, FileResponse, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv
from starlette.middleware.cors import CORSMiddleware
from motor.motor_asyncio import AsyncIOMotorClient
from models import (
UserCreate, UserLogin, UserPublic, TokenResponse,
Movie, MovieCreate, MovieUpdate,
WatchlistItem, ProgressUpsert, Progress,
RequestCreate, RequestUpdate, MovieRequest,
)
from auth import (
hash_password, verify_password, create_token, decode_token,
)
from seed import SAMPLE_MOVIES
# ---------- Setup ----------
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / ".env")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("kino")
mongo_url = os.environ["MONGO_URL"]
client = AsyncIOMotorClient(mongo_url)
db = client[os.environ["DB_NAME"]]
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", str(ROOT_DIR / "media")))
VIDEOS_DIR = MEDIA_ROOT / "videos"
POSTERS_DIR = MEDIA_ROOT / "posters"
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
POSTERS_DIR.mkdir(parents=True, exist_ok=True)
CHUNK_SIZE = 1024 * 1024 # 1 MiB
app = FastAPI(title="Kino — Personal Media Server")
api = APIRouter(prefix="/api")
bearer = HTTPBearer(auto_error=False)
# ---------- Auth deps ----------
async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> dict:
if not creds:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = decode_token(creds.credentials)
user = await db.users.find_one({"id": payload["sub"]}, {"_id": 0, "password_hash": 0})
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
async def get_current_user_optional(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> Optional[dict]:
if not creds:
return None
try:
payload = decode_token(creds.credentials)
except HTTPException:
return None
return await db.users.find_one({"id": payload["sub"]}, {"_id": 0, "password_hash": 0})
async def require_admin(user: dict = Depends(get_current_user)) -> dict:
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin only")
return user
# ---------- Startup: seed admin + sample movies ----------
@app.on_event("startup")
async def on_startup():
# Index for unique email
await db.users.create_index("email", unique=True)
await db.movies.create_index("title")
await db.watchlist.create_index([("user_id", 1), ("movie_id", 1)], unique=True)
await db.progress.create_index([("user_id", 1), ("movie_id", 1)], unique=True)
admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local")
if not await db.users.find_one({"email": admin_email}):
admin_user = {
"id": str(uuid.uuid4()),
"email": admin_email,
"name": os.environ.get("ADMIN_NAME", "Admin"),
"password_hash": hash_password(os.environ.get("ADMIN_PASSWORD", "kino-admin-2026")),
"is_admin": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await db.users.insert_one(admin_user)
logger.info(f"Seeded admin user: {admin_email}")
# Seed movies if empty
count = await db.movies.count_documents({})
if count == 0:
for m in SAMPLE_MOVIES:
doc = Movie(**m).model_dump()
await db.movies.insert_one(doc)
logger.info(f"Seeded {len(SAMPLE_MOVIES)} sample movies")
@app.on_event("shutdown")
async def on_shutdown():
client.close()
# ---------- Helpers ----------
def _user_public(u: dict) -> UserPublic:
return UserPublic(
id=u["id"], email=u["email"], name=u["name"],
is_admin=u.get("is_admin", False), created_at=u["created_at"],
)
def _strip(doc: dict) -> dict:
doc.pop("_id", None)
return doc
# ---------- Auth ----------
@api.post("/auth/register", response_model=TokenResponse)
async def register(payload: UserCreate):
if await db.users.find_one({"email": payload.email.lower()}):
raise HTTPException(status_code=400, detail="Email already registered")
user = {
"id": str(uuid.uuid4()),
"email": payload.email.lower(),
"name": payload.name,
"password_hash": hash_password(payload.password),
"is_admin": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await db.users.insert_one(user)
token = create_token(user["id"], False)
return TokenResponse(access_token=token, user=_user_public(user))
@api.post("/auth/login", response_model=TokenResponse)
async def login(payload: UserLogin):
user = await db.users.find_one({"email": payload.email.lower()})
if not user or not verify_password(payload.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid email or password")
token = create_token(user["id"], user.get("is_admin", False))
return TokenResponse(access_token=token, user=_user_public(user))
@api.get("/auth/me", response_model=UserPublic)
async def me(user: dict = Depends(get_current_user)):
return _user_public(user)
# ---------- Movies ----------
@api.get("/movies", response_model=List[Movie])
async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
query: dict = {}
if genre:
query["genres"] = {"$regex": f"^{genre}$", "$options": "i"}
if q:
query["$or"] = [
{"title": {"$regex": q, "$options": "i"}},
{"description": {"$regex": q, "$options": "i"}},
{"director": {"$regex": q, "$options": "i"}},
{"cast": {"$regex": q, "$options": "i"}},
]
docs = await db.movies.find(query, {"_id": 0}).sort("created_at", -1).to_list(limit)
return docs
@api.get("/movies/featured", response_model=Movie)
async def get_featured():
doc = await db.movies.find_one({"featured": True}, {"_id": 0})
if not doc:
doc = await db.movies.find_one({}, {"_id": 0})
if not doc:
raise HTTPException(status_code=404, detail="No movies")
return doc
@api.get("/movies/genres", response_model=List[str])
async def list_genres():
genres = await db.movies.distinct("genres")
return sorted([g for g in genres if g])
@api.get("/movies/{movie_id}", response_model=Movie)
async def get_movie(movie_id: str):
doc = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not doc:
raise HTTPException(status_code=404, detail="Movie not found")
return doc
@api.post("/movies", response_model=Movie)
async def create_movie(payload: MovieCreate, user: dict = Depends(require_admin)):
movie = Movie(**payload.model_dump())
doc = movie.model_dump()
await db.movies.insert_one(doc)
return _strip(doc)
@api.patch("/movies/{movie_id}", response_model=Movie)
async def update_movie(movie_id: str, payload: MovieUpdate, user: dict = Depends(require_admin)):
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
res = await db.movies.find_one_and_update(
{"id": movie_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
)
if not res:
raise HTTPException(status_code=404, detail="Movie not found")
return res
@api.delete("/movies/{movie_id}")
async def delete_movie(movie_id: str, user: dict = Depends(require_admin)):
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie:
raise HTTPException(status_code=404, detail="Movie not found")
# delete local files if any
if movie.get("storage_type") == "local" and movie.get("storage_path"):
try:
(VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True)
except Exception:
pass
await db.movies.delete_one({"id": movie_id})
await db.watchlist.delete_many({"movie_id": movie_id})
await db.progress.delete_many({"movie_id": movie_id})
return {"ok": True}
# ---------- Upload (admin) ----------
@api.post("/upload/video", response_model=Movie)
async def upload_video(
title: str = Form(...),
description: str = Form(""),
year: int = Form(2024),
duration_minutes: int = Form(0),
rating: str = Form("NR"),
genres: str = Form(""), # comma-separated
cast: str = Form(""),
director: str = Form(""),
poster_url: str = Form(""),
backdrop_url: str = Form(""),
featured: bool = Form(False),
file: UploadFile = File(...),
user: dict = Depends(require_admin),
):
ext = (file.filename.rsplit(".", 1)[-1] if "." in (file.filename or "") else "mp4").lower()
safe_id = str(uuid.uuid4())
fname = f"{safe_id}.{ext}"
target = VIDEOS_DIR / fname
# stream save
with target.open("wb") as f:
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
f.write(chunk)
movie = Movie(
title=title,
description=description,
year=year,
duration_minutes=duration_minutes,
rating=rating,
genres=[g.strip() for g in genres.split(",") if g.strip()],
cast=[c.strip() for c in cast.split(",") if c.strip()],
director=director,
poster_url=poster_url,
backdrop_url=backdrop_url,
video_url="", # filled with stream endpoint client-side
storage_type="local",
storage_path=fname,
featured=featured,
)
doc = movie.model_dump()
await db.movies.insert_one(doc)
return _strip(doc)
# ---------- Streaming with Range support ----------
@api.get("/stream/{movie_id}")
async def stream_movie(
movie_id: str,
request: Request,
auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
# token check (header OR ?auth= for <video> tag)
token = None
if authorization and authorization.lower().startswith("bearer "):
token = authorization.split(" ", 1)[1].strip()
elif auth:
token = auth
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
decode_token(token) # raises 401 if invalid
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie:
raise HTTPException(status_code=404, detail="Movie not found")
if movie.get("storage_type") != "local" or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Movie has no local file; use video_url directly")
file_path = VIDEOS_DIR / movie["storage_path"]
if not file_path.is_file():
raise HTTPException(status_code=404, detail="File missing on disk")
file_size = file_path.stat().st_size
content_type, _ = mimetypes.guess_type(str(file_path))
content_type = content_type or "video/mp4"
range_header = request.headers.get("range") or request.headers.get("Range")
if range_header and range_header.startswith("bytes="):
try:
start_str, end_str = range_header.replace("bytes=", "").split("-")
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
except ValueError:
raise HTTPException(status_code=416, detail="Invalid range")
if start >= file_size:
raise HTTPException(status_code=416, detail="Range out of bounds")
end = min(end, file_size - 1)
length = end - start + 1
def iter_file():
with file_path.open("rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(CHUNK_SIZE, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
"Content-Type": content_type,
}
return StreamingResponse(iter_file(), status_code=206, headers=headers, media_type=content_type)
# full file
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
# ---------- Watchlist ----------
@api.get("/watchlist", response_model=List[Movie])
async def get_watchlist(user: dict = Depends(get_current_user)):
items = await db.watchlist.find({"user_id": user["id"]}, {"_id": 0}).sort("added_at", -1).to_list(500)
movie_ids = [i["movie_id"] for i in items]
if not movie_ids:
return []
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(500)
# preserve order
by_id = {m["id"]: m for m in movies}
return [by_id[mid] for mid in movie_ids if mid in by_id]
@api.post("/watchlist/{movie_id}")
async def add_watchlist(movie_id: str, user: dict = Depends(get_current_user)):
if not await db.movies.find_one({"id": movie_id}, {"_id": 1}):
raise HTTPException(status_code=404, detail="Movie not found")
item = WatchlistItem(user_id=user["id"], movie_id=movie_id).model_dump()
try:
await db.watchlist.insert_one(item)
except Exception:
pass # duplicate ok
return {"ok": True}
@api.delete("/watchlist/{movie_id}")
async def remove_watchlist(movie_id: str, user: dict = Depends(get_current_user)):
await db.watchlist.delete_one({"user_id": user["id"], "movie_id": movie_id})
return {"ok": True}
# ---------- Progress (continue watching) ----------
@api.post("/progress")
async def upsert_progress(payload: ProgressUpsert, user: dict = Depends(get_current_user)):
now = datetime.now(timezone.utc).isoformat()
await db.progress.update_one(
{"user_id": user["id"], "movie_id": payload.movie_id},
{"$set": {
"user_id": user["id"],
"movie_id": payload.movie_id,
"position_seconds": payload.position_seconds,
"duration_seconds": payload.duration_seconds,
"updated_at": now,
}},
upsert=True,
)
return {"ok": True}
@api.get("/progress/continue", response_model=List[dict])
async def continue_watching(user: dict = Depends(get_current_user)):
rows = await db.progress.find(
{"user_id": user["id"]}, {"_id": 0}
).sort("updated_at", -1).to_list(50)
# filter out completed (>95%)
rows = [r for r in rows if r.get("duration_seconds", 0) == 0 or
r["position_seconds"] / max(r["duration_seconds"], 1) < 0.95]
if not rows:
return []
movie_ids = [r["movie_id"] for r in rows]
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(50)
by_id = {m["id"]: m for m in movies}
out = []
for r in rows:
m = by_id.get(r["movie_id"])
if m:
out.append({**m, "progress": r})
return out
@api.get("/progress/{movie_id}")
async def get_progress(movie_id: str, user: dict = Depends(get_current_user)):
p = await db.progress.find_one({"user_id": user["id"], "movie_id": movie_id}, {"_id": 0})
return p or {"position_seconds": 0, "duration_seconds": 0}
# ---------- Movie Requests ----------
@api.post("/requests", response_model=MovieRequest)
async def submit_request(payload: RequestCreate, user: dict = Depends(get_current_user)):
req = MovieRequest(
user_id=user["id"], user_name=user.get("name", ""),
title=payload.title, year=payload.year, notes=payload.notes,
)
doc = req.model_dump()
await db.requests.insert_one(doc)
return _strip(doc)
@api.get("/requests/mine", response_model=List[MovieRequest])
async def my_requests(user: dict = Depends(get_current_user)):
docs = await db.requests.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", -1).to_list(200)
return docs
@api.get("/requests", response_model=List[MovieRequest])
async def all_requests(user: dict = Depends(require_admin)):
docs = await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500)
return docs
@api.patch("/requests/{request_id}", response_model=MovieRequest)
async def update_request(request_id: str, payload: RequestUpdate, user: dict = Depends(require_admin)):
res = await db.requests.find_one_and_update(
{"id": request_id}, {"$set": {"status": payload.status}},
projection={"_id": 0}, return_document=True,
)
if not res:
raise HTTPException(status_code=404, detail="Request not found")
return res
# ---------- Health ----------
@api.get("/")
async def root():
return {"app": "Kino", "status": "ok"}
# Mount router and CORS
app.include_router(api)
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
)