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