mirror of
https://github.com/myronblair/kino
synced 2026-06-30 17:50:29 -05:00
486 lines
17 KiB
Python
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"],
|
|
)
|