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
+72
View File
@@ -0,0 +1,72 @@
"""JWT auth helpers using bcrypt + python-jose."""
import os
from datetime import datetime, timezone, timedelta
from typing import Optional
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-secret")
JWT_ALG = os.environ.get("JWT_ALG", "HS256")
JWT_EXPIRE_HOURS = int(os.environ.get("JWT_EXPIRE_HOURS", "720"))
bearer = HTTPBearer(auto_error=False)
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
return False
def create_token(user_id: str, is_admin: bool = False) -> str:
payload = {
"sub": user_id,
"is_admin": is_admin,
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
except JWTError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e}")
async def get_current_user_id(
creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer),
auth_query: Optional[str] = None,
) -> str:
"""Reads bearer token; supports `?auth=` query for video tags."""
token = None
if creds and creds.credentials:
token = creds.credentials
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = decode_token(token)
return payload["sub"]
def token_from_query_or_header(authorization: Optional[str], auth: Optional[str]) -> str:
"""Helper for endpoints that accept token via Authorization header OR ?auth= query."""
if authorization and authorization.lower().startswith("bearer "):
return authorization.split(" ", 1)[1].strip()
if auth:
return auth
raise HTTPException(status_code=401, detail="Not authenticated")
def require_user_from_any(authorization: Optional[str], auth: Optional[str]) -> dict:
token = token_from_query_or_header(authorization, auth)
return decode_token(token)
+129
View File
@@ -0,0 +1,129 @@
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional
from datetime import datetime, timezone
import uuid
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------- Users ----------
class UserCreate(BaseModel):
email: str
password: str
name: str
class UserLogin(BaseModel):
email: str
password: str
class UserPublic(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
email: str
name: str
is_admin: bool = False
created_at: str
# ---------- Movies ----------
class MovieBase(BaseModel):
title: str
description: str = ""
year: int = 2024
duration_minutes: int = 0
rating: str = "NR" # G / PG / PG-13 / R / NR
genres: List[str] = []
cast: List[str] = []
director: str = ""
poster_url: str = ""
backdrop_url: str = ""
video_url: str = ""
storage_type: str = "external" # "external" or "local"
storage_path: Optional[str] = None
featured: bool = False
class MovieCreate(MovieBase):
pass
class MovieUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
year: Optional[int] = None
duration_minutes: Optional[int] = None
rating: Optional[str] = None
genres: Optional[List[str]] = None
cast: Optional[List[str]] = None
director: Optional[str] = None
poster_url: Optional[str] = None
backdrop_url: Optional[str] = None
video_url: Optional[str] = None
storage_type: Optional[str] = None
storage_path: Optional[str] = None
featured: Optional[bool] = None
class Movie(MovieBase):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
created_at: str = Field(default_factory=_now_iso)
# ---------- Watchlist ----------
class WatchlistItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
user_id: str
movie_id: str
added_at: str = Field(default_factory=_now_iso)
# ---------- Progress (Continue Watching) ----------
class ProgressUpsert(BaseModel):
movie_id: str
position_seconds: float
duration_seconds: float
class Progress(BaseModel):
model_config = ConfigDict(extra="ignore")
user_id: str
movie_id: str
position_seconds: float
duration_seconds: float
updated_at: str = Field(default_factory=_now_iso)
# ---------- Requests ----------
class RequestCreate(BaseModel):
title: str
year: Optional[int] = None
notes: str = ""
class RequestUpdate(BaseModel):
status: str # pending | fulfilled | rejected
class MovieRequest(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
user_id: str
user_name: str = ""
title: str
year: Optional[int] = None
notes: str = ""
status: str = "pending"
created_at: str = Field(default_factory=_now_iso)
# ---------- Auth Tokens ----------
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserPublic
+148
View File
@@ -0,0 +1,148 @@
"""Seed data: sample movies using public-domain video sources."""
from typing import List, Dict
# All videos below are in the public domain or Creative Commons.
# Sources: Internet Archive, Blender Foundation, etc.
SAMPLE_MOVIES: List[Dict] = [
{
"title": "Big Buck Bunny",
"description": "A large and lovable rabbit deals with three tiny bullies, led by a flying squirrel, who are determined to squelch his happiness.",
"year": 2008,
"duration_minutes": 10,
"rating": "G",
"genres": ["Animation", "Comedy", "Family"],
"cast": ["Big Buck Bunny", "The Squirrel"],
"director": "Sacha Goedegebure",
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/c/c5/Big_buck_bunny_poster_big.jpg",
"backdrop_url": "https://images.unsplash.com/photo-1705147651064-36aedc005020?crop=entropy&cs=srgb&fm=jpg&ixid=M3w3NTY2ODh8MHwxfHNlYXJjaHwxfHxjaW5lbWF0aWMlMjBsYW5kc2NhcGUlMjBkYXJrfGVufDB8fHx8MTc3NzQ3MzE2M3ww&ixlib=rb-4.1.0&q=85",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
"storage_type": "external",
"featured": True,
},
{
"title": "Sintel",
"description": "A lonely young woman, Sintel, helps and befriends a dragon she names Scales. But when Scales is kidnapped by an adult dragon, Sintel embarks on a dangerous quest to find her friend.",
"year": 2010,
"duration_minutes": 15,
"rating": "PG",
"genres": ["Animation", "Fantasy", "Adventure"],
"cast": ["Halina Reijn", "Thom Hoffman"],
"director": "Colin Levy",
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Sintel_poster.jpg/440px-Sintel_poster.jpg",
"backdrop_url": "https://images.unsplash.com/photo-1542204165-65bf26472b9b?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
"storage_type": "external",
},
{
"title": "Tears of Steel",
"description": "In an apocalyptic future, a group of soldiers and scientists takes refuge in Amsterdam to try to stop an army of robots that threaten the planet.",
"year": 2012,
"duration_minutes": 12,
"rating": "PG-13",
"genres": ["Sci-Fi", "Action"],
"cast": ["Derek de Lint", "Sergio Hasselbaink"],
"director": "Ian Hubert",
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Tears_of_Steel_poster.png/440px-Tears_of_Steel_poster.png",
"backdrop_url": "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4",
"storage_type": "external",
},
{
"title": "Elephants Dream",
"description": "Two strange characters explore a capricious world of machines, hostile to their inhabitants. The first open movie.",
"year": 2006,
"duration_minutes": 11,
"rating": "PG",
"genres": ["Animation", "Sci-Fi"],
"cast": ["Cas Jansen", "Tygo Gernandt"],
"director": "Bassam Kurdali",
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e8/Elephants_Dream_s5_both.jpg/640px-Elephants_Dream_s5_both.jpg",
"backdrop_url": "https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
"storage_type": "external",
},
{
"title": "For Bigger Blazes",
"description": "A short demonstration film exploring the depths of cinematic fire and suspense.",
"year": 2015,
"duration_minutes": 1,
"rating": "G",
"genres": ["Short", "Documentary"],
"cast": [],
"director": "Google",
"poster_url": "https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=600&q=80",
"backdrop_url": "https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
"storage_type": "external",
},
{
"title": "For Bigger Escapes",
"description": "An action-packed glimpse into a high-stakes escape from the ordinary.",
"year": 2015,
"duration_minutes": 1,
"rating": "G",
"genres": ["Short", "Action"],
"cast": [],
"director": "Google",
"poster_url": "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?auto=format&fit=crop&w=600&q=80",
"backdrop_url": "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
"storage_type": "external",
},
{
"title": "For Bigger Fun",
"description": "A whirlwind ride through the brighter side of the cinematic spectrum.",
"year": 2015,
"duration_minutes": 1,
"rating": "G",
"genres": ["Short", "Comedy"],
"cast": [],
"director": "Google",
"poster_url": "https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?auto=format&fit=crop&w=600&q=80",
"backdrop_url": "https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
"storage_type": "external",
},
{
"title": "For Bigger Joyrides",
"description": "Buckle up — this is a rapid-fire reel of gravity, speed, and pure adrenaline.",
"year": 2015,
"duration_minutes": 1,
"rating": "G",
"genres": ["Short", "Action"],
"cast": [],
"director": "Google",
"poster_url": "https://images.unsplash.com/photo-1485846234645-a62644f84728?auto=format&fit=crop&w=600&q=80",
"backdrop_url": "https://images.unsplash.com/photo-1485846234645-a62644f84728?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
"storage_type": "external",
},
{
"title": "For Bigger Meltdowns",
"description": "When entropy turns up the heat, the world begins to melt — beautifully.",
"year": 2015,
"duration_minutes": 1,
"rating": "G",
"genres": ["Short", "Drama"],
"cast": [],
"director": "Google",
"poster_url": "https://images.unsplash.com/photo-1515634928627-2a4e0dae3ddf?auto=format&fit=crop&w=600&q=80",
"backdrop_url": "https://images.unsplash.com/photo-1515634928627-2a4e0dae3ddf?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
"storage_type": "external",
},
{
"title": "Subaru Outback On Street And Dirt",
"description": "A documentary exploring the texture, grit, and dust of all-terrain travel.",
"year": 2014,
"duration_minutes": 1,
"rating": "G",
"genres": ["Short", "Documentary"],
"cast": [],
"director": "Google",
"poster_url": "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&w=600&q=80",
"backdrop_url": "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&w=1920&q=80",
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4",
"storage_type": "external",
},
]
+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()
+295
View File
@@ -0,0 +1,295 @@
"""
Backend pytest suite for Kino Personal Media Server.
Tests auth, movies, watchlist, progress, requests, and stream auth.
"""
import os
import time
import uuid
import pytest
import requests
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://streamhoard.preview.emergentagent.com").rstrip("/")
ADMIN_EMAIL = "admin@kino.local"
ADMIN_PASSWORD = "kino-admin-2026"
# ---------- Fixtures ----------
@pytest.fixture(scope="session")
def api():
s = requests.Session()
s.headers.update({"Content-Type": "application/json"})
return s
@pytest.fixture(scope="session")
def admin_token(api):
r = api.post(f"{BASE_URL}/api/auth/login", json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD})
assert r.status_code == 200, f"admin login failed: {r.status_code} {r.text}"
data = r.json()
assert data["user"]["is_admin"] is True
return data["access_token"]
@pytest.fixture(scope="session")
def member_token(api):
email = f"TEST_user_{uuid.uuid4().hex[:8]}@kino.local"
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Test User"})
assert r.status_code == 200, f"register failed: {r.text}"
data = r.json()
return data["access_token"], data["user"]
@pytest.fixture
def admin_headers(admin_token):
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture
def member_headers(member_token):
tok, _ = member_token
return {"Authorization": f"Bearer {tok}"}
# ---------- Health ----------
class TestHealth:
def test_root(self, api):
r = api.get(f"{BASE_URL}/api/")
assert r.status_code == 200
assert r.json().get("status") == "ok"
# ---------- Auth ----------
class TestAuth:
def test_admin_login(self, api):
r = api.post(f"{BASE_URL}/api/auth/login", json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD})
assert r.status_code == 200
d = r.json()
assert "access_token" in d and isinstance(d["access_token"], str)
assert d["user"]["email"] == ADMIN_EMAIL
assert d["user"]["is_admin"] is True
def test_login_invalid(self, api):
r = api.post(f"{BASE_URL}/api/auth/login", json={"email": ADMIN_EMAIL, "password": "wrong"})
assert r.status_code == 401
def test_register_and_me(self, api):
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@kino.local"
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Reg User"})
assert r.status_code == 200
d = r.json()
token = d["access_token"]
assert d["user"]["email"] == email.lower()
assert d["user"]["is_admin"] is False
# /me
r2 = api.get(f"{BASE_URL}/api/auth/me", headers={"Authorization": f"Bearer {token}"})
assert r2.status_code == 200
assert r2.json()["email"] == email.lower()
def test_register_duplicate(self, api):
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@kino.local"
api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
assert r.status_code == 400
def test_me_no_token(self, api):
r = api.get(f"{BASE_URL}/api/auth/me")
assert r.status_code == 401
# ---------- Movies (public) ----------
class TestMovies:
def test_list_movies_seeded(self, api):
r = api.get(f"{BASE_URL}/api/movies")
assert r.status_code == 200
movies = r.json()
assert isinstance(movies, list)
assert len(movies) >= 10
# No _id leaked
assert all("_id" not in m for m in movies)
# Required fields
m = movies[0]
for f in ("id", "title", "video_url", "storage_type"):
assert f in m
def test_featured(self, api):
r = api.get(f"{BASE_URL}/api/movies/featured")
assert r.status_code == 200
m = r.json()
assert m["title"] == "Big Buck Bunny"
assert "_id" not in m
def test_genres(self, api):
r = api.get(f"{BASE_URL}/api/movies/genres")
assert r.status_code == 200
genres = r.json()
assert isinstance(genres, list)
assert len(genres) > 0
assert genres == sorted(genres)
def test_get_movie_404(self, api):
r = api.get(f"{BASE_URL}/api/movies/nonexistent-id-xyz")
assert r.status_code == 404
def test_get_movie_by_id(self, api):
movies = api.get(f"{BASE_URL}/api/movies").json()
mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/movies/{mid}")
assert r.status_code == 200
assert r.json()["id"] == mid
def test_search_query(self, api):
r = api.get(f"{BASE_URL}/api/movies", params={"q": "bunny"})
assert r.status_code == 200
results = r.json()
assert any("bunny" in m["title"].lower() for m in results)
# ---------- Movies admin CRUD ----------
class TestMoviesAdmin:
def test_create_requires_admin(self, api, member_headers):
r = api.post(f"{BASE_URL}/api/movies", json={"title": "X"}, headers=member_headers)
assert r.status_code == 403
def test_create_update_delete(self, api, admin_headers):
payload = {
"title": "TEST_AdminMovie",
"description": "test",
"year": 2024,
"genres": ["Test"],
"video_url": "https://example.com/x.mp4",
"storage_type": "external",
"featured": False,
}
r = api.post(f"{BASE_URL}/api/movies", json=payload, headers=admin_headers)
assert r.status_code == 200, r.text
created = r.json()
mid = created["id"]
assert created["title"] == "TEST_AdminMovie"
# GET to verify persistence
g = api.get(f"{BASE_URL}/api/movies/{mid}")
assert g.status_code == 200
assert g.json()["title"] == "TEST_AdminMovie"
# PATCH
u = api.patch(f"{BASE_URL}/api/movies/{mid}", json={"title": "TEST_AdminMovieUpdated"}, headers=admin_headers)
assert u.status_code == 200
assert u.json()["title"] == "TEST_AdminMovieUpdated"
# GET re-verify
g2 = api.get(f"{BASE_URL}/api/movies/{mid}")
assert g2.json()["title"] == "TEST_AdminMovieUpdated"
# DELETE
d = api.delete(f"{BASE_URL}/api/movies/{mid}", headers=admin_headers)
assert d.status_code == 200
# Verify 404
g3 = api.get(f"{BASE_URL}/api/movies/{mid}")
assert g3.status_code == 404
# ---------- Watchlist ----------
class TestWatchlist:
def test_watchlist_flow(self, api, member_headers):
movies = api.get(f"{BASE_URL}/api/movies").json()
mid = movies[0]["id"]
r = api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
assert r.status_code == 200
r2 = api.get(f"{BASE_URL}/api/watchlist", headers=member_headers)
assert r2.status_code == 200
wl = r2.json()
assert any(m["id"] == mid for m in wl)
# Idempotent add
r3 = api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
assert r3.status_code == 200
d = api.delete(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
assert d.status_code == 200
r4 = api.get(f"{BASE_URL}/api/watchlist", headers=member_headers)
assert all(m["id"] != mid for m in r4.json())
def test_watchlist_unauth(self, api):
r = api.get(f"{BASE_URL}/api/watchlist")
assert r.status_code == 401
# ---------- Progress ----------
class TestProgress:
def test_progress_upsert_and_continue(self, api, member_headers):
movies = api.get(f"{BASE_URL}/api/movies").json()
mid = movies[1]["id"]
r = api.post(f"{BASE_URL}/api/progress",
json={"movie_id": mid, "position_seconds": 30, "duration_seconds": 600},
headers=member_headers)
assert r.status_code == 200
r2 = api.get(f"{BASE_URL}/api/progress/continue", headers=member_headers)
assert r2.status_code == 200
rows = r2.json()
assert any(m["id"] == mid for m in rows)
# progress object attached
target = next(m for m in rows if m["id"] == mid)
assert target["progress"]["position_seconds"] == 30
# update
r3 = api.post(f"{BASE_URL}/api/progress",
json={"movie_id": mid, "position_seconds": 90, "duration_seconds": 600},
headers=member_headers)
assert r3.status_code == 200
r4 = api.get(f"{BASE_URL}/api/progress/{mid}", headers=member_headers)
assert r4.json()["position_seconds"] == 90
# ---------- Requests ----------
class TestRequests:
def test_submit_and_admin_list(self, api, member_headers, admin_headers):
payload = {"title": "TEST_Req_Movie", "year": 2023, "notes": "please add"}
r = api.post(f"{BASE_URL}/api/requests", json=payload, headers=member_headers)
assert r.status_code == 200
req = r.json()
rid = req["id"]
assert req["status"] == "pending"
mine = api.get(f"{BASE_URL}/api/requests/mine", headers=member_headers)
assert mine.status_code == 200
assert any(x["id"] == rid for x in mine.json())
# Member cannot list all
forbid = api.get(f"{BASE_URL}/api/requests", headers=member_headers)
assert forbid.status_code == 403
# Admin lists
all_r = api.get(f"{BASE_URL}/api/requests", headers=admin_headers)
assert all_r.status_code == 200
assert any(x["id"] == rid for x in all_r.json())
# Admin updates
upd = api.patch(f"{BASE_URL}/api/requests/{rid}", json={"status": "fulfilled"}, headers=admin_headers)
assert upd.status_code == 200
assert upd.json()["status"] == "fulfilled"
# ---------- Streaming ----------
class TestStream:
def test_stream_no_token(self, api):
movies = api.get(f"{BASE_URL}/api/movies").json()
mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/stream/{mid}", allow_redirects=False)
assert r.status_code == 401
def test_stream_external_returns_400(self, api, admin_token):
# All seeded movies are storage_type=external -> should 400
movies = api.get(f"{BASE_URL}/api/movies").json()
mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/stream/{mid}", params={"auth": admin_token}, allow_redirects=False)
assert r.status_code == 400
def test_upload_unauth(self, api):
# Just verify rejection without token; do NOT upload large file
r = requests.post(f"{BASE_URL}/api/upload/video", data={"title": "X"}, files={"file": ("x.mp4", b"x", "video/mp4")})
assert r.status_code in (401, 403, 422)