mirror of
https://github.com/myronblair/kino-app
synced 2026-06-30 17:50:16 -05:00
auto-commit for df4b0748-985b-4592-8c48-1e56102f3613
This commit is contained in:
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user