auto-commit for df4b0748-985b-4592-8c48-1e56102f3613

This commit is contained in:
emergent-agent-e1
2026-04-29 14:49:07 +00:00
parent 7673090279
commit 356aa13063
30 changed files with 2809 additions and 244 deletions
+462 -66
View File
@@ -1,89 +1,485 @@
from fastapi import FastAPI, APIRouter
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
import os
import logging
from pathlib import Path
from pydantic import BaseModel, Field, ConfigDict
from typing import List
import uuid
from datetime import datetime, timezone
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')
load_dotenv(ROOT_DIR / ".env")
# MongoDB connection
mongo_url = os.environ['MONGO_URL']
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']]
db = client[os.environ["DB_NAME"]]
# Create the main app without a prefix
app = FastAPI()
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)
# Create a router with the /api prefix
api_router = APIRouter(prefix="/api")
CHUNK_SIZE = 1024 * 1024 # 1 MiB
app = FastAPI(title="Kino — Personal Media Server")
api = APIRouter(prefix="/api")
bearer = HTTPBearer(auto_error=False)
# Define Models
class StatusCheck(BaseModel):
model_config = ConfigDict(extra="ignore") # Ignore MongoDB's _id field
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
client_name: str
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# ---------- 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
class StatusCheckCreate(BaseModel):
client_name: str
# Add your routes to the router instead of directly to app
@api_router.get("/")
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 {"message": "Hello World"}
return {"app": "Kino", "status": "ok"}
@api_router.post("/status", response_model=StatusCheck)
async def create_status_check(input: StatusCheckCreate):
status_dict = input.model_dump()
status_obj = StatusCheck(**status_dict)
# Convert to dict and serialize datetime to ISO string for MongoDB
doc = status_obj.model_dump()
doc['timestamp'] = doc['timestamp'].isoformat()
_ = await db.status_checks.insert_one(doc)
return status_obj
@api_router.get("/status", response_model=List[StatusCheck])
async def get_status_checks():
# Exclude MongoDB's _id field from the query results
status_checks = await db.status_checks.find({}, {"_id": 0}).to_list(1000)
# Convert ISO string timestamps back to datetime objects
for check in status_checks:
if isinstance(check['timestamp'], str):
check['timestamp'] = datetime.fromisoformat(check['timestamp'])
return status_checks
# Include the router in the main app
app.include_router(api_router)
# Mount router and CORS
app.include_router(api)
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@app.on_event("shutdown")
async def shutdown_db_client():
client.close()