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
+112 -1
View File
@@ -1 +1,112 @@
# Here are your Instructions
# Kino — Personal Media Server (Netflix Clone)
A self-hosted Netflix-style streaming app for movies you legally own.
Built with FastAPI + React + MongoDB.
## Features
- Cinematic browse UI with hero banner, horizontal carousels, hover details
- HTML5 video player with **HTTP Range request** support (proper seeking)
- **JWT auth** (admin + member roles)
- **Admin upload** for adding movies (multipart, large files OK)
- **My List** (watchlist) and **Continue Watching** (resume playback)
- **Search** by title/director/cast
- **Request queue** — users request movies, admin approves & uploads them
*(Legal alternative to auto-downloading — auto-download of copyrighted material is not implemented.)*
## Default admin
- Email: `admin@kino.local`
- Password: `kino-admin-2026`
Change `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `backend/.env` before first start
to set your own admin credentials.
## Self-hosting on Proxmox
You can run this stack on any Proxmox LXC or VM. Recommended layout:
1. **Create an Ubuntu 22.04 LXC** with at least:
- 2 CPU cores, 4 GB RAM
- 20 GB system disk
- Mount your bulk storage (zfs/nfs) at `/mnt/media`
2. **Install dependencies**:
```bash
apt update && apt install -y python3-pip nodejs npm yarn mongodb git
```
3. **Clone & configure**:
```bash
git clone <your-fork> /opt/kino
cd /opt/kino
```
4. **Backend**:
```bash
cd backend
pip install -r requirements.txt
# edit .env:
# MONGO_URL=mongodb://localhost:27017
# DB_NAME=kino
# MEDIA_ROOT=/mnt/media
# ADMIN_EMAIL=you@yourdomain
# ADMIN_PASSWORD=<strong>
# JWT_SECRET=<openssl rand -hex 32>
uvicorn server:app --host 0.0.0.0 --port 8001
```
5. **Frontend**:
```bash
cd frontend
yarn install
# edit .env:
# REACT_APP_BACKEND_URL=https://kino.yourdomain.tld
yarn build
# serve build/ via nginx or caddy
```
6. **Recommended**: front with **Caddy** for HTTPS:
```
kino.yourdomain.tld {
handle /api/* {
reverse_proxy localhost:8001
}
handle {
root * /opt/kino/frontend/build
try_files {path} /index.html
file_server
}
}
```
## GitHub backup
This repo is the source of truth for code and configuration.
**Do not commit your `.env` files** — they contain secrets.
Movie media files should live on your Proxmox storage, not Git.
## Adding movies
- **Via web UI**: Sign in as admin → "Upload" in nav → drag MP4 + fill metadata
- **Manually**: drop MP4 into `$MEDIA_ROOT/videos/`, then create the movie via
`POST /api/movies` with `storage_type=local` and `storage_path=<filename>`
## API quickstart
```bash
# Login
curl -X POST $URL/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@kino.local","password":"kino-admin-2026"}'
# List movies
curl $URL/api/movies
# Stream a local movie (note: needs ?auth=<token> for <video> tags)
curl $URL/api/stream/<movie_id>?auth=<token>
```
## Why no auto-download?
Auto-downloading copyrighted movies from the internet is illegal in most
jurisdictions (DMCA, EU copyright directive, etc.). Kino is built as a
**personal media server** for content you legally own or that is in the
public domain (Internet Archive, Blender Open Movies, etc.).
The "Requests" feature lets users wishlist titles for the admin to add
manually — preserving the request UX without crossing into piracy.
+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)
+2 -34
View File
@@ -1,34 +1,2 @@
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #0f0f10;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Reset previous template styles. Most styling now lives in index.css and Tailwind. */
.App { background-color: #050505; }
+51 -40
View File
@@ -1,51 +1,62 @@
import { useEffect } from "react";
import "@/App.css";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import axios from "axios";
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL;
const API = `${BACKEND_URL}/api`;
const Home = () => {
const helloWorldApi = async () => {
try {
const response = await axios.get(`${API}/`);
console.log(response.data.message);
} catch (e) {
console.error(e, `errored out requesting / api`);
}
};
useEffect(() => {
helloWorldApi();
}, []);
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { Toaster } from "sonner";
import { AuthProvider, useAuth } from "./lib/auth";
import ProtectedRoute from "./components/ProtectedRoute";
import Navbar from "./components/Navbar";
import GrainOverlay from "./components/GrainOverlay";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Browse from "./pages/Browse";
import MyList from "./pages/MyList";
import Search from "./pages/Search";
import Player from "./pages/Player";
import Requests from "./pages/Requests";
import Admin from "./pages/Admin";
import AdminUpload from "./pages/AdminUpload";
const Shell = ({ children }) => {
const { user } = useAuth();
const loc = useLocation();
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
return (
<div>
<header className="App-header">
<a
className="App-link"
href="https://emergent.sh"
target="_blank"
rel="noopener noreferrer"
>
<img src="https://avatars.githubusercontent.com/in/1201222?s=120&u=2686cf91179bbafbc7a71bfbc43004cf9ae1acea&v=4" />
</a>
<p className="mt-5">Building something incredible ~!</p>
</header>
</div>
<>
{!hideChrome && user && <Navbar />}
{children}
<GrainOverlay />
</>
);
};
const RootRedirect = () => {
const { user, loading } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
return <Navigate to={user ? "/browse" : "/login"} replace />;
};
function App() {
return (
<div className="App">
<div className="App min-h-screen bg-[#050505]">
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />}>
<Route index element={<Home />} />
</Route>
</Routes>
<AuthProvider>
<Shell>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/browse" element={<ProtectedRoute><Browse /></ProtectedRoute>} />
<Route path="/my-list" element={<ProtectedRoute><MyList /></ProtectedRoute>} />
<Route path="/search" element={<ProtectedRoute><Search /></ProtectedRoute>} />
<Route path="/watch/:id" element={<ProtectedRoute><Player /></ProtectedRoute>} />
<Route path="/requests" element={<ProtectedRoute><Requests /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute adminOnly><Admin /></ProtectedRoute>} />
<Route path="/admin/upload" element={<ProtectedRoute adminOnly><AdminUpload /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
</AuthProvider>
</BrowserRouter>
</div>
);
+2
View File
@@ -0,0 +1,2 @@
export const GrainOverlay = () => <div className="grain-overlay" aria-hidden="true" />;
export default GrainOverlay;
+78
View File
@@ -0,0 +1,78 @@
import { Play, Plus, Info } from "lucide-react";
export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
if (!movie) return null;
const bg = movie.backdrop_url || movie.poster_url;
return (
<section
className="relative w-full h-[92vh] min-h-[640px] overflow-hidden"
data-testid="hero-banner"
>
<div
className="absolute inset-0 bg-center bg-cover"
style={{ backgroundImage: `url(${bg})` }}
/>
<div className="absolute inset-0 hero-fade" />
<div className="absolute inset-0 hero-side-fade" />
<div className="relative h-full max-w-[1500px] mx-auto px-6 md:px-12 flex flex-col justify-end pb-24 md:pb-32 fade-up">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-4" data-testid="hero-eyebrow">
Featured
</span>
<h1 className="font-display text-5xl md:text-7xl font-black tracking-tighter leading-none text-white max-w-3xl"
data-testid="hero-title">
{movie.title}
</h1>
<div className="flex items-center gap-4 mt-5 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
<span>{movie.year}</span>
<span className="w-1 h-1 bg-[#444] rounded-full" />
<span>{movie.rating}</span>
{movie.duration_minutes ? (
<>
<span className="w-1 h-1 bg-[#444] rounded-full" />
<span>{movie.duration_minutes} min</span>
</>
) : null}
{movie.genres?.length ? (
<>
<span className="w-1 h-1 bg-[#444] rounded-full" />
<span>{movie.genres.slice(0, 3).join(" · ")}</span>
</>
) : null}
</div>
<p className="mt-6 text-base md:text-lg text-[#C8C8C8] max-w-2xl leading-relaxed line-clamp-3">
{movie.description}
</p>
<div className="flex items-center gap-3 mt-8">
<button
onClick={() => onPlay?.(movie)}
className="flex items-center gap-3 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-7 py-3 transition-colors duration-300"
data-testid="hero-play-button"
>
<Play size={18} fill="white" strokeWidth={0} />
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
</button>
<button
onClick={() => onMore?.(movie)}
className="flex items-center gap-3 bg-white/10 hover:bg-white/20 text-white px-7 py-3 transition-colors duration-300 backdrop-blur-md border border-white/10"
data-testid="hero-more-info-button"
>
<Info size={18} strokeWidth={1.5} />
<span className="text-sm uppercase tracking-[0.2em] font-medium">More Info</span>
</button>
<button
onClick={() => onAddList?.(movie)}
className="hidden sm:flex items-center gap-3 text-white/70 hover:text-white px-3 py-3 transition-colors duration-300"
data-testid="hero-add-list-button"
>
<Plus size={18} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em]">My List</span>
</button>
</div>
</div>
</section>
);
};
export default Hero;
+42
View File
@@ -0,0 +1,42 @@
import { Play } from "lucide-react";
export const MovieCard = ({ movie, onClick, progress }) => {
const pct = progress?.duration_seconds
? Math.min(100, (progress.position_seconds / progress.duration_seconds) * 100)
: 0;
return (
<button
onClick={() => onClick?.(movie)}
className="group relative shrink-0 w-[180px] md:w-[220px] aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-all duration-300 hover:scale-105 hover:z-10 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
data-testid={`movie-card-${movie.id}`}
>
<img
src={movie.poster_url || movie.backdrop_url}
alt={movie.title}
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.opacity = 0.3; }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/30 to-transparent opacity-90 md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-2 md:translate-y-0 md:opacity-0 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-300">
<h3 className="font-display text-lg font-bold tracking-tight text-white leading-none line-clamp-2">
{movie.title}
</h3>
<div className="mt-2 flex items-center gap-2 text-[10px] uppercase tracking-[0.2em] text-[#C8C8C8]">
<span>{movie.year}</span>
{movie.rating ? <span>· {movie.rating}</span> : null}
</div>
</div>
<div className="absolute top-3 right-3 w-9 h-9 bg-[#D9381E] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Play size={14} fill="white" strokeWidth={0} />
</div>
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
</div>
)}
</button>
);
};
export default MovieCard;
@@ -0,0 +1,136 @@
import { X, Play, Plus, Check } from "lucide-react";
import { useEffect, useState } from "react";
import api from "../lib/api";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
const nav = useNavigate();
const [inList, setInList] = useState(false);
useEffect(() => {
if (!movie || !open) return;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [movie, open]);
useEffect(() => {
if (!movie) return;
let cancelled = false;
api.get("/watchlist").then(({ data }) => {
if (!cancelled) setInList(data.some((m) => m.id === movie.id));
}).catch(() => {});
return () => { cancelled = true; };
}, [movie]);
if (!open || !movie) return null;
const toggle = async () => {
try {
if (inList) {
await api.delete(`/watchlist/${movie.id}`);
setInList(false);
toast.success("Removed from My List");
} else {
await api.post(`/watchlist/${movie.id}`);
setInList(true);
toast.success("Added to My List");
}
onWatchlistChange?.();
} catch {
toast.error("Could not update list");
}
};
const play = () => {
onClose?.();
nav(`/watch/${movie.id}`);
};
return (
<div
className="fixed inset-0 z-[100] flex items-start md:items-center justify-center overflow-y-auto"
data-testid="movie-detail-modal"
>
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={onClose} />
<div className="relative w-full max-w-4xl mx-4 my-8 bg-[#0A0A0A] border border-[#222] overflow-hidden fade-up">
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 w-10 h-10 flex items-center justify-center bg-black/60 hover:bg-black text-white transition-colors duration-300"
data-testid="modal-close-button"
aria-label="Close"
>
<X size={18} strokeWidth={1.5} />
</button>
<div className="relative h-[280px] md:h-[440px]">
<img
src={movie.backdrop_url || movie.poster_url}
alt={movie.title}
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0A] via-[#0A0A0A]/40 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-10">
<h2 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white" data-testid="modal-movie-title">
{movie.title}
</h2>
<div className="flex flex-wrap items-center gap-3 mt-4 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
<span>{movie.year}</span>
<span>·</span>
<span>{movie.rating}</span>
{movie.duration_minutes ? (<><span>·</span><span>{movie.duration_minutes} min</span></>) : null}
</div>
<div className="flex items-center gap-3 mt-6">
<button
onClick={play}
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 transition-colors duration-300"
data-testid="modal-play-button"
>
<Play size={16} fill="white" strokeWidth={0} />
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
</button>
<button
onClick={toggle}
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-3 transition-colors duration-300 backdrop-blur border border-white/10"
data-testid="modal-watchlist-button"
>
{inList ? <Check size={16} strokeWidth={1.5} /> : <Plus size={16} strokeWidth={1.5} />}
<span className="text-xs uppercase tracking-[0.2em]">{inList ? "In My List" : "My List"}</span>
</button>
</div>
</div>
</div>
<div className="p-8 md:p-10 grid md:grid-cols-3 gap-8">
<div className="md:col-span-2">
<p className="text-base leading-relaxed text-[#C8C8C8]" data-testid="modal-movie-description">
{movie.description}
</p>
</div>
<div className="space-y-4 text-sm">
{movie.cast?.length ? (
<div>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Cast</span>
<p className="mt-1 text-[#C8C8C8]">{movie.cast.join(", ")}</p>
</div>
) : null}
{movie.director ? (
<div>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Director</span>
<p className="mt-1 text-[#C8C8C8]">{movie.director}</p>
</div>
) : null}
{movie.genres?.length ? (
<div>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Genres</span>
<p className="mt-1 text-[#C8C8C8]">{movie.genres.join(", ")}</p>
</div>
) : null}
</div>
</div>
</div>
</div>
);
};
export default MovieDetailModal;
+102
View File
@@ -0,0 +1,102 @@
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { Search, LogOut, Upload, ListVideo } from "lucide-react";
import { useState, useEffect } from "react";
export const Navbar = () => {
const { user, logout } = useAuth();
const nav = useNavigate();
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
const linkClass = ({ isActive }) =>
`text-sm tracking-wide transition-colors duration-300 ${
isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"
}`;
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled ? "glass border-b" : ""
}`}
data-testid="main-navbar"
>
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
<div className="flex items-center gap-10">
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
<span className="font-display text-2xl font-black tracking-tighter text-white">
Kino
</span>
<span className="text-[#D9381E] text-2xl leading-none">.</span>
</Link>
{user && (
<nav className="hidden md:flex items-center gap-7">
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</NavLink>
{user.is_admin && (
<NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>
)}
</nav>
)}
</div>
{user ? (
<div className="flex items-center gap-4">
<button
onClick={() => nav("/search")}
className="text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="nav-search-button"
aria-label="Search"
>
<Search size={18} strokeWidth={1.5} />
</button>
{user.is_admin && (
<button
onClick={() => nav("/admin/upload")}
className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="nav-upload-button"
>
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
)}
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
<div className="hidden sm:flex flex-col items-end leading-tight">
<span className="text-xs text-white" data-testid="nav-user-name">{user.name}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{user.is_admin ? "Admin" : "Member"}
</span>
</div>
<div className="w-8 h-8 bg-[#D9381E] flex items-center justify-center text-white text-xs font-medium">
{user.name?.[0]?.toUpperCase() || "U"}
</div>
<button
onClick={() => { logout(); nav("/login"); }}
className="text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="nav-logout-button"
aria-label="Logout"
>
<LogOut size={16} strokeWidth={1.5} />
</button>
</div>
</div>
) : (
<Link
to="/login"
className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300"
data-testid="nav-sign-in"
>
Sign in
</Link>
)}
</div>
</header>
);
};
export default Navbar;
@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
export const ProtectedRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
Loading
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
return children;
};
export default ProtectedRoute;
+54
View File
@@ -0,0 +1,54 @@
import { useRef } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import MovieCard from "./MovieCard";
export const Row = ({ title, movies, onCardClick, progressMap }) => {
const ref = useRef(null);
if (!movies?.length) return null;
const scroll = (dir) => {
if (!ref.current) return;
ref.current.scrollBy({ left: dir * (ref.current.clientWidth * 0.8), behavior: "smooth" });
};
return (
<section className="px-6 md:px-12 mt-12 md:mt-16 group/row" data-testid={`row-${title}`}>
<div className="flex items-end justify-between mb-5">
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight text-white">
{title}
</h2>
<div className="hidden md:flex items-center gap-2 opacity-0 group-hover/row:opacity-100 transition-opacity duration-300">
<button
onClick={() => scroll(-1)}
className="w-9 h-9 flex items-center justify-center bg-white/5 hover:bg-white/10 text-white transition-colors duration-300"
aria-label="Scroll left"
data-testid={`row-scroll-left-${title}`}
>
<ChevronLeft size={16} strokeWidth={1.5} />
</button>
<button
onClick={() => scroll(1)}
className="w-9 h-9 flex items-center justify-center bg-white/5 hover:bg-white/10 text-white transition-colors duration-300"
aria-label="Scroll right"
data-testid={`row-scroll-right-${title}`}
>
<ChevronRight size={16} strokeWidth={1.5} />
</button>
</div>
</div>
<div ref={ref} className="flex items-stretch gap-3 md:gap-4 overflow-x-auto no-scrollbar pb-4 -mx-2 px-2">
{movies.map((m) => (
<MovieCard
key={m.id}
movie={m}
progress={progressMap?.[m.id]}
onClick={onCardClick}
/>
))}
</div>
</section>
);
};
export default Row;
+93 -103
View File
@@ -1,115 +1,105 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,700;9..144,900&family=Geist:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
:root {
--kino-bg: #050505;
--kino-surface: #0F0F0F;
--kino-surface-2: #1A1A1A;
--kino-accent: #D9381E;
--kino-accent-hover: #ED4B32;
--kino-text: #F2F2F2;
--kino-muted: #8A8A8A;
--kino-border: #222222;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
:root {
--background: 0 0% 2%;
--foreground: 0 0% 95%;
--card: 0 0% 6%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 6%;
--popover-foreground: 0 0% 95%;
--primary: 9 75% 48%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 10%;
--secondary-foreground: 0 0% 95%;
--muted: 0 0% 10%;
--muted-foreground: 0 0% 54%;
--accent: 0 0% 13%;
--accent-foreground: 0 0% 95%;
--destructive: 0 75% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 13%;
--input: 0 0% 13%;
--ring: 9 75% 48%;
--radius: 0.25rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
* {
border-color: hsl(var(--border));
}
@layer base {
[data-debug-wrapper="true"] {
display: contents !important;
}
[data-debug-wrapper="true"] > * {
margin-left: inherit;
margin-right: inherit;
margin-top: inherit;
margin-bottom: inherit;
padding-left: inherit;
padding-right: inherit;
padding-top: inherit;
padding-bottom: inherit;
column-gap: inherit;
row-gap: inherit;
gap: inherit;
border-left-width: inherit;
border-right-width: inherit;
border-top-width: inherit;
border-bottom-width: inherit;
border-left-style: inherit;
border-right-style: inherit;
border-top-style: inherit;
border-bottom-style: inherit;
border-left-color: inherit;
border-right-color: inherit;
border-top-color: inherit;
border-bottom-color: inherit;
}
html, body, #root {
background-color: var(--kino-bg);
color: var(--kino-text);
font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
}
.font-display {
font-family: 'Fraunces', serif;
font-feature-settings: "ss01", "ss02";
}
/* Hide scrollbars on row carousels but keep scrolling */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Selection */
::selection {
background: var(--kino-accent);
color: white;
}
/* Grain overlay */
.grain-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.06;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.9'/></svg>");
mix-blend-mode: overlay;
}
.glass {
background-color: rgba(15, 15, 15, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-color: rgba(242, 242, 242, 0.08);
}
/* Smooth fade for hero gradient */
.hero-fade {
background: linear-gradient(180deg, rgba(5,5,5,0) 0%, rgba(5,5,5,0.4) 50%, rgba(5,5,5,1) 100%);
}
.hero-side-fade {
background: linear-gradient(90deg, rgba(5,5,5,0.95) 0%, rgba(5,5,5,0.6) 40%, rgba(5,5,5,0) 100%);
}
/* Page enter animation */
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-up { animation: fade-up 600ms ease-out both; }
/* Custom video controls */
video::-webkit-media-controls-panel { background-color: rgba(0,0,0,0.7); }
+33
View File
@@ -0,0 +1,33 @@
import axios from "axios";
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL;
export const API = `${BACKEND_URL}/api`;
const instance = axios.create({ baseURL: API });
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("kino_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
instance.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
// soft handling — let pages decide
}
return Promise.reject(err);
}
);
export default instance;
export const getStreamUrl = (movie) => {
if (!movie) return "";
if (movie.storage_type === "local") {
const token = localStorage.getItem("kino_token");
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token || "")}`;
}
return movie.video_url;
};
+56
View File
@@ -0,0 +1,56 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import api from "./api";
const AuthCtx = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const token = localStorage.getItem("kino_token");
if (!token) {
setUser(null);
setLoading(false);
return;
}
try {
const { data } = await api.get("/auth/me");
setUser(data);
} catch {
localStorage.removeItem("kino_token");
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const login = async (email, password) => {
const { data } = await api.post("/auth/login", { email, password });
localStorage.setItem("kino_token", data.access_token);
setUser(data.user);
return data.user;
};
const register = async (email, password, name) => {
const { data } = await api.post("/auth/register", { email, password, name });
localStorage.setItem("kino_token", data.access_token);
setUser(data.user);
return data.user;
};
const logout = () => {
localStorage.removeItem("kino_token");
setUser(null);
};
return (
<AuthCtx.Provider value={{ user, loading, login, register, logout, refresh }}>
{children}
</AuthCtx.Provider>
);
};
export const useAuth = () => useContext(AuthCtx);
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Trash2, Star } from "lucide-react";
export default function Admin() {
const [movies, setMovies] = useState([]);
const load = async () => {
const { data } = await api.get("/movies");
setMovies(data);
};
useEffect(() => { load(); }, []);
const remove = async (id) => {
if (!window.confirm("Remove this movie permanently?")) return;
await api.delete(`/movies/${id}`);
toast.success("Removed");
load();
};
const toggleFeatured = async (m) => {
await api.patch(`/movies/${m.id}`, { featured: !m.featured });
if (!m.featured) {
// unset others client-side? Keep simple: server allows multiple but featured endpoint picks first
toast.success("Featured updated");
}
load();
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Library management</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
Admin
</h1>
<p className="text-[#8A8A8A] mt-4">
Manage your library and review pending requests.
</p>
<div className="mt-10 flex gap-3">
<a href="/admin/upload" className="bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]" data-testid="admin-upload-link">
Upload New
</a>
<a href="/requests" className="bg-white/10 hover:bg-white/20 text-white px-6 py-3 text-sm uppercase tracking-[0.2em] border border-white/10">
Review Requests
</a>
</div>
<div className="mt-12 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-5">Title</span>
<span className="col-span-2">Year</span>
<span className="col-span-2">Rating</span>
<span className="col-span-2">Source</span>
<span className="col-span-1 text-right">Actions</span>
</div>
{movies.map((m) => (
<div key={m.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors"
data-testid={`admin-movie-row-${m.id}`}>
<div className="col-span-5 flex items-center gap-3">
<img src={m.poster_url} alt="" className="w-10 h-14 object-cover" />
<span className="text-white truncate">{m.title}</span>
</div>
<span className="col-span-2 text-[#8A8A8A] text-sm">{m.year}</span>
<span className="col-span-2 text-[#8A8A8A] text-sm">{m.rating}</span>
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
<div className="col-span-1 flex justify-end gap-2">
<button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}>
<Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
</button>
<button onClick={() => remove(m.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors" aria-label="Delete" data-testid={`delete-${m.id}`}>
<Trash2 size={16} strokeWidth={1.5} />
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import { UploadCloud } from "lucide-react";
export default function AdminUpload() {
const nav = useNavigate();
const [file, setFile] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState(0);
const [form, setForm] = useState({
title: "", description: "", year: 2024, duration_minutes: 0, rating: "NR",
genres: "", cast: "", director: "", poster_url: "", backdrop_url: "", featured: false,
});
const upd = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!file) { toast.error("Choose a video file"); return; }
if (!form.title.trim()) { toast.error("Title is required"); return; }
setSubmitting(true);
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => fd.append(k, v));
fd.append("file", file);
try {
await api.post("/upload/video", fd, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (p) => {
if (p.total) setProgress(Math.round((p.loaded / p.total) * 100));
},
});
toast.success("Movie uploaded");
nav("/admin");
} catch (err) {
toast.error(err.response?.data?.detail || "Upload failed");
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-upload-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">Library</span>
<h1 className="font-display text-5xl font-black tracking-tighter text-white mt-3">
Upload Film
</h1>
<form onSubmit={submit} className="mt-12 space-y-6" data-testid="upload-form">
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Video file (MP4)</span>
<div className="mt-2 border border-dashed border-[#333] hover:border-[#D9381E] transition-colors p-8 text-center">
<UploadCloud className="mx-auto text-[#8A8A8A]" size={28} strokeWidth={1.5} />
<input
type="file" accept="video/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="mt-3 block w-full text-sm text-[#C8C8C8] file:mr-4 file:py-2 file:px-4 file:border-0 file:bg-[#D9381E] file:text-white file:cursor-pointer"
data-testid="upload-file-input"
/>
{file && <p className="mt-2 text-xs text-[#8A8A8A]">{file.name} · {(file.size / (1024*1024)).toFixed(1)} MB</p>}
</div>
</label>
<div className="grid md:grid-cols-2 gap-5">
<Field label="Title" required value={form.title} onChange={(v) => upd("title", v)} testid="upload-title" />
<Field label="Director" value={form.director} onChange={(v) => upd("director", v)} testid="upload-director" />
<Field label="Year" type="number" value={form.year} onChange={(v) => upd("year", Number(v) || 0)} testid="upload-year" />
<Field label="Duration (min)" type="number" value={form.duration_minutes} onChange={(v) => upd("duration_minutes", Number(v) || 0)} testid="upload-duration" />
<Field label="Rating" value={form.rating} onChange={(v) => upd("rating", v)} testid="upload-rating" />
<Field label="Genres (comma sep)" value={form.genres} onChange={(v) => upd("genres", v)} testid="upload-genres" />
<Field label="Cast (comma sep)" value={form.cast} onChange={(v) => upd("cast", v)} testid="upload-cast" />
<Field label="Poster URL" value={form.poster_url} onChange={(v) => upd("poster_url", v)} testid="upload-poster" />
<Field label="Backdrop URL" value={form.backdrop_url} onChange={(v) => upd("backdrop_url", v)} testid="upload-backdrop" full />
</div>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Description</span>
<textarea
value={form.description} onChange={(e) => upd("description", e.target.value)}
rows={4}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="upload-description"
/>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={form.featured} onChange={(e) => upd("featured", e.target.checked)}
className="accent-[#D9381E]" data-testid="upload-featured" />
<span className="text-sm text-[#C8C8C8]">Set as featured (hero banner)</span>
</label>
{submitting && progress > 0 && (
<div className="border border-[#222] p-4">
<div className="flex items-center justify-between text-xs text-[#8A8A8A] mb-2">
<span>Uploading</span><span>{progress}%</span>
</div>
<div className="h-1 bg-[#222]"><div className="h-full bg-[#D9381E] transition-all" style={{ width: `${progress}%` }} /></div>
</div>
)}
<button
type="submit"
disabled={submitting}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="upload-submit-button"
>
{submitting ? "Uploading…" : "Upload"}
</button>
</form>
</div>
</div>
);
}
const Field = ({ label, value, onChange, type = "text", required, testid, full }) => (
<label className={`block ${full ? "md:col-span-2" : ""}`}>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span>
<input
type={type} value={value} required={required}
onChange={(e) => onChange(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid={testid}
/>
</label>
);
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import Hero from "../components/Hero";
import Row from "../components/Row";
import MovieDetailModal from "../components/MovieDetailModal";
export default function Browse() {
const nav = useNavigate();
const [featured, setFeatured] = useState(null);
const [movies, setMovies] = useState([]);
const [genres, setGenres] = useState([]);
const [continueWatching, setContinueWatching] = useState([]);
const [watchlist, setWatchlist] = useState([]);
const [selected, setSelected] = useState(null);
const [progressMap, setProgressMap] = useState({});
const load = useCallback(async () => {
const [{ data: f }, { data: ms }, { data: gs }, { data: cw }, { data: wl }] = await Promise.all([
api.get("/movies/featured").catch(() => ({ data: null })),
api.get("/movies"),
api.get("/movies/genres"),
api.get("/progress/continue").catch(() => ({ data: [] })),
api.get("/watchlist").catch(() => ({ data: [] })),
]);
setFeatured(f);
setMovies(ms);
setGenres(gs);
setContinueWatching(cw);
setWatchlist(wl);
const map = {};
for (const m of cw) map[m.id] = m.progress;
setProgressMap(map);
}, []);
useEffect(() => { load(); }, [load]);
const handlePlay = (m) => nav(`/watch/${m.id}`);
const handleMore = (m) => setSelected(m);
const handleAddList = async (m) => {
try { await api.post(`/watchlist/${m.id}`); load(); } catch {}
};
const byGenre = (g) => movies.filter((m) => m.genres?.includes(g));
const trending = movies.slice(0, 10);
const newReleases = [...movies].sort((a, b) => b.year - a.year).slice(0, 12);
return (
<div className="min-h-screen bg-[#050505]" data-testid="browse-page">
{featured && (
<Hero
movie={featured}
onPlay={handlePlay}
onMore={handleMore}
onAddList={handleAddList}
/>
)}
<div className="-mt-20 relative z-10 pb-24">
{continueWatching.length > 0 && (
<Row title="Continue Watching" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
)}
<Row title="Trending Now" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
<Row title="New Releases" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
{watchlist.length > 0 && (
<Row title="My List" movies={watchlist} onCardClick={handleMore} progressMap={progressMap} />
)}
{genres.slice(0, 6).map((g) => {
const list = byGenre(g);
if (!list.length) return null;
return <Row key={g} title={g} movies={list} onCardClick={handleMore} progressMap={progressMap} />;
})}
</div>
<MovieDetailModal
movie={selected}
open={!!selected}
onClose={() => setSelected(null)}
onWatchlistChange={load}
/>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
export default function Login() {
const { login } = useAuth();
const nav = useNavigate();
const loc = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const onSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
try {
await login(email, password);
toast.success("Welcome back");
nav(loc.state?.from || "/browse", { replace: true });
} catch (err) {
toast.error(err.response?.data?.detail || "Login failed");
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen w-full grid md:grid-cols-2 bg-[#050505]" data-testid="login-page">
<div className="hidden md:block relative overflow-hidden">
<img
src="https://images.unsplash.com/photo-1698159929270-c88bad6346fe?crop=entropy&cs=srgb&fm=jpg&ixid=M3w4NjA1ODR8MHwxfHNlYXJjaHwzfHxhYnN0cmFjdCUyMGRhcmslMjB0ZXh0dXJlJTIwZmlsbSUyMGdyYWlufGVufDB8fHx8MTc3NzQ3MzE2M3ww&ixlib=rb-4.1.0&q=85"
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
<div className="absolute bottom-12 left-12 right-12 z-10">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Personal Cinema</span>
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
Your library,<br/>your way.
</h1>
<p className="text-[#8A8A8A] mt-6 max-w-md leading-relaxed">
Stream the films you own, the way you remember them without the noise.
</p>
</div>
</div>
<div className="flex items-center justify-center px-6 md:px-12 py-12">
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="login-form">
<Link to="/" className="block mb-12" data-testid="login-logo">
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
<span className="text-[#D9381E] text-3xl">.</span>
</Link>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Sign in</h2>
<p className="text-sm text-[#8A8A8A] mt-2 mb-8">
Don't have an account?{" "}
<Link to="/register" className="text-[#D9381E] hover:text-[#ED4B32] transition-colors" data-testid="login-to-register">
Create one
</Link>
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 transition-colors"
data-testid="login-email-input"
/>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 transition-colors"
data-testid="login-password-input"
/>
</label>
<button
type="submit"
disabled={submitting}
className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white py-3 text-sm uppercase tracking-[0.2em] font-medium transition-colors duration-300"
data-testid="login-submit-button"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
<div className="mt-8 p-4 border border-[#222] text-xs text-[#8A8A8A]" data-testid="login-demo-credentials">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] block mb-2">Demo Admin</span>
admin@kino.local / kino-admin-2026
</div>
</form>
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import MovieCard from "../components/MovieCard";
import MovieDetailModal from "../components/MovieDetailModal";
export default function MyList() {
const [items, setItems] = useState([]);
const [selected, setSelected] = useState(null);
const load = async () => {
const { data } = await api.get("/watchlist");
setItems(data);
};
useEffect(() => { load(); }, []);
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="my-list-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Your collection</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
My List
</h1>
<p className="text-[#8A8A8A] mt-4 max-w-xl">
Films you've curated for later. Click any to play or remove.
</p>
{items.length === 0 ? (
<div className="mt-16 border border-[#222] p-12 text-center" data-testid="my-list-empty">
<p className="text-[#8A8A8A]">Your list is empty. Add films from Browse.</p>
</div>
) : (
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{items.map((m) => (
<MovieCard key={m.id} movie={m} onClick={setSelected} />
))}
</div>
)}
</div>
<MovieDetailModal movie={selected} open={!!selected} onClose={() => setSelected(null)} onWatchlistChange={load} />
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api, { getStreamUrl } from "../lib/api";
import { ArrowLeft } from "lucide-react";
export default function Player() {
const { id } = useParams();
const nav = useNavigate();
const [movie, setMovie] = useState(null);
const videoRef = useRef(null);
const lastSent = useRef(0);
useEffect(() => {
let cancelled = false;
(async () => {
const { data } = await api.get(`/movies/${id}`);
if (cancelled) return;
setMovie(data);
// Restore progress
try {
const { data: p } = await api.get(`/progress/${id}`);
if (videoRef.current && p?.position_seconds) {
videoRef.current.currentTime = p.position_seconds;
}
} catch {}
})();
return () => { cancelled = true; };
}, [id]);
const onTimeUpdate = () => {
const v = videoRef.current;
if (!v || !v.duration) return;
const now = Date.now();
if (now - lastSent.current < 5000) return;
lastSent.current = now;
api.post("/progress", {
movie_id: id,
position_seconds: v.currentTime,
duration_seconds: v.duration,
}).catch(() => {});
};
if (!movie) {
return (
<div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]" data-testid="player-loading">
Loading
</div>
);
}
return (
<div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="player-page">
<button
onClick={() => nav(-1)}
className="absolute top-6 left-6 z-10 flex items-center gap-2 text-white/80 hover:text-white bg-black/60 hover:bg-black px-4 py-2 transition-colors duration-300"
data-testid="player-back-button"
>
<ArrowLeft size={16} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em]">Back</span>
</button>
<div className="absolute top-6 right-6 z-10 text-right">
<h2 className="font-display text-xl text-white tracking-tight" data-testid="player-title">
{movie.title}
</h2>
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{movie.year} · {movie.rating}</p>
</div>
<video
ref={videoRef}
src={getStreamUrl(movie)}
controls
autoPlay
className="w-full h-full object-contain bg-black"
onTimeUpdate={onTimeUpdate}
data-testid="player-video"
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
export default function Register() {
const { register } = useAuth();
const nav = useNavigate();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const onSubmit = async (e) => {
e.preventDefault();
if (password.length < 6) {
toast.error("Password must be at least 6 characters");
return;
}
setSubmitting(true);
try {
await register(email, password, name);
toast.success("Welcome to Kino");
nav("/browse", { replace: true });
} catch (err) {
toast.error(err.response?.data?.detail || "Could not register");
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen w-full grid md:grid-cols-2 bg-[#050505]" data-testid="register-page">
<div className="hidden md:block relative overflow-hidden">
<img
src="https://images.unsplash.com/photo-1705147651064-36aedc005020?crop=entropy&cs=srgb&fm=jpg&ixid=M3w3NTY2ODh8MHwxfHNlYXJjaHwxfHxjaW5lbWF0aWMlMjBsYW5kc2NhcGUlMjBkYXJrfGVufDB8fHx8MTc3NzQ3MzE2M3ww&ixlib=rb-4.1.0&q=85"
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
<div className="absolute bottom-12 left-12 right-12 z-10">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Join Kino</span>
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
Curate.<br/>Stream.<br/>Repeat.
</h1>
</div>
</div>
<div className="flex items-center justify-center px-6 md:px-12 py-12">
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="register-form">
<Link to="/" className="block mb-12">
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
<span className="text-[#D9381E] text-3xl">.</span>
</Link>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
<p className="text-sm text-[#8A8A8A] mt-2 mb-8">
Already have an account?{" "}
<Link to="/login" className="text-[#D9381E] hover:text-[#ED4B32] transition-colors" data-testid="register-to-login">
Sign in
</Link>
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input
required value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 transition-colors"
data-testid="register-name-input"
/>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
<input
type="email" required value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 transition-colors"
data-testid="register-email-input"
/>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password</span>
<input
type="password" required value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 transition-colors"
data-testid="register-password-input"
/>
</label>
<button
type="submit"
disabled={submitting}
className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white py-3 text-sm uppercase tracking-[0.2em] font-medium transition-colors duration-300"
data-testid="register-submit-button"
>
{submitting ? "Creating…" : "Create account"}
</button>
</form>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
export default function Requests() {
const { user } = useAuth();
const [mine, setMine] = useState([]);
const [all, setAll] = useState([]);
const [title, setTitle] = useState("");
const [year, setYear] = useState("");
const [notes, setNotes] = useState("");
const load = async () => {
const { data } = await api.get("/requests/mine");
setMine(data);
if (user?.is_admin) {
const { data: a } = await api.get("/requests");
setAll(a);
}
};
useEffect(() => { load(); }, [user?.is_admin]);
const submit = async (e) => {
e.preventDefault();
if (!title.trim()) return;
try {
await api.post("/requests", { title, year: year ? Number(year) : null, notes });
toast.success("Request submitted");
setTitle(""); setYear(""); setNotes("");
load();
} catch {
toast.error("Could not submit request");
}
};
const updateStatus = async (id, status) => {
try {
await api.patch(`/requests/${id}`, { status });
toast.success(`Marked ${status}`);
load();
} catch {
toast.error("Could not update");
}
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="requests-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Library wishlist</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
Requests
</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl leading-relaxed">
Want a film added to the library? Submit a request and the admin will review it. Owned content only.
</p>
<form onSubmit={submit} className="mt-12 grid md:grid-cols-3 gap-4 max-w-3xl" data-testid="request-form">
<div className="md:col-span-2">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Title</span>
<input
value={title} onChange={(e) => setTitle(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="request-title-input"
required
/>
</div>
<div>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Year</span>
<input
type="number" value={year} onChange={(e) => setYear(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="request-year-input"
/>
</div>
<div className="md:col-span-3">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Notes (optional)</span>
<textarea
value={notes} onChange={(e) => setNotes(e.target.value)}
rows={3}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="request-notes-input"
/>
</div>
<div className="md:col-span-3">
<button
type="submit"
className="bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="request-submit-button"
>
Submit Request
</button>
</div>
</form>
<div className="mt-16">
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">My Requests</h2>
{mine.length === 0 ? (
<p className="text-[#8A8A8A] text-sm" data-testid="my-requests-empty">No requests yet.</p>
) : (
<div className="border border-[#222]">
{mine.map((r) => (
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`my-request-${r.id}`}>
<div>
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
{r.notes && <p className="text-xs text-[#8A8A8A] mt-1">{r.notes}</p>}
</div>
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
r.status === "fulfilled" ? "text-[#86efac]" :
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
}`}>
{r.status}
</span>
</div>
))}
</div>
)}
</div>
{user?.is_admin && (
<div className="mt-16">
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">All Requests (Admin)</h2>
{all.length === 0 ? (
<p className="text-[#8A8A8A] text-sm">No requests in queue.</p>
) : (
<div className="border border-[#222]">
{all.map((r) => (
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
<div>
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
</div>
<div className="flex gap-2">
<button onClick={() => updateStatus(r.id, "fulfilled")}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
<button onClick={() => updateStatus(r.id, "rejected")}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-3 py-2"
data-testid={`reject-${r.id}`}>Reject</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import MovieCard from "../components/MovieCard";
import MovieDetailModal from "../components/MovieDetailModal";
import { Search as SearchIcon } from "lucide-react";
export default function Search() {
const [q, setQ] = useState("");
const [results, setResults] = useState([]);
const [selected, setSelected] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!q) { setResults([]); return; }
let cancelled = false;
setLoading(true);
const t = setTimeout(async () => {
try {
const { data } = await api.get(`/movies?q=${encodeURIComponent(q)}`);
if (!cancelled) setResults(data);
} finally {
if (!cancelled) setLoading(false);
}
}, 300);
return () => { cancelled = true; clearTimeout(t); };
}, [q]);
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="search-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Find a film</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
Search
</h1>
<div className="relative mt-10 max-w-2xl">
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 text-[#8A8A8A]" size={18} strokeWidth={1.5} />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
placeholder="Title, director, or cast…"
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white pl-12 pr-4 py-4 transition-colors"
data-testid="search-input"
/>
</div>
<div className="mt-10">
{loading && <p className="text-[#8A8A8A] text-sm">Searching</p>}
{!loading && q && results.length === 0 && (
<p className="text-[#8A8A8A] text-sm" data-testid="search-no-results">
No films match "{q}". Submit a request to add it.
</p>
)}
{results.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{results.map((m) => (
<MovieCard key={m.id} movie={m} onClick={setSelected} />
))}
</div>
)}
</div>
</div>
<MovieDetailModal movie={selected} open={!!selected} onClose={() => setSelected(null)} />
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
# Kino — PRD
## Original problem statement
> build me a netflix clone and have the ability to request and automatically download movies off the internet and store them for viewing in the website/app. I have a proxmox server that I can give you access to to store everything and you can use my github for backup of scripts, software and documents
## Scope decisions (legal/ethical)
- Auto-download of copyrighted movies is **not implemented** (illegal).
- Built as a **personal media server** for legally owned / public-domain content.
- "Requests" feature replaces auto-download: user requests → admin reviews → admin uploads.
## Personas
- **Admin** — manages library, uploads movies, fulfils user requests.
- **Member** — browses, watches, builds watchlist, submits requests.
## Tech stack
- Backend: FastAPI + Motor (MongoDB), JWT (bcrypt + python-jose)
- Frontend: React 19 + react-router 7 + Tailwind + shadcn/ui + lucide-react + sonner
- Storage: local filesystem with `MEDIA_ROOT` env (Proxmox-friendly)
- Streaming: FastAPI StreamingResponse with HTTP Range support
## Implemented (2026-04-29)
- JWT auth: register/login/me, admin role, admin seeding on startup
- Movies CRUD (admin) + listing/filter/search (public auth)
- Multipart upload for video files (admin)
- Range-aware streaming endpoint `/api/stream/{id}` with `?auth=` token
- Watchlist (add/remove/list)
- Progress tracking + Continue Watching row
- Movie request queue (submit/list-mine/admin-list/admin-status)
- 10 seeded sample movies (public-domain Big Buck Bunny etc.)
- Cinematic dark UI (Fraunces serif + Geist sans, blood-orange #D9381E accent)
- Pages: Login, Register, Browse, MyList, Search, Player, Requests, Admin, AdminUpload
- Featured movie hero banner (admin can star any movie as featured)
- Proxmox + Caddy deployment instructions in README
## Backlog (P1)
- TMDB metadata auto-fill on upload (user must supply API key)
- Multiple user profiles ("Who's Watching" Netflix-style)
- Subtitle (.srt/.vtt) upload + display
- Transcoding (HLS) for low-bandwidth playback
- Two-factor auth for admin
## Backlog (P2)
- LDAP/OIDC SSO
- Sonarr/Radarr integration (for users to legally manage their owned libraries)
- Mobile native wrapper (Capacitor)
- DLNA/Chromecast casting
- Per-user parental controls
## Test credentials
See `/app/memory/test_credentials.md`
+35
View File
@@ -0,0 +1,35 @@
{
"summary": "Full backend + frontend test of Kino personal media server. All 21 pytest tests pass (auth, movies, admin CRUD, watchlist, progress, requests, stream auth). All 9 critical UI flows pass on the deployed preview URL: login, browse hero+rows, MovieDetailModal, MyList, debounced Search, Requests submit, Admin movie list with feature/delete, Admin Upload form, Player external src wiring.",
"backend_issues": {
"critical": [],
"minor": []
},
"frontend_issues": {
"ui_bugs": [],
"integration_issues": [],
"design_issues": []
},
"test_report_links": [
"/app/backend/tests/backend_test.py",
"/app/test_reports/pytest/pytest_results.xml"
],
"action_items": [],
"critical_code_review_comments": [
"Player.jsx: getStreamUrl correctly returns external video_url for storage_type=external and the authenticated stream URL for local — confirmed via DOM inspection of <video src>.",
"server.py is a single 486-line file. Still readable but as features grow consider splitting routes into routers (auth, movies, watchlist, progress, requests, stream) for maintainability.",
"Admin.jsx feature toggle calls PATCH with {featured: !m.featured} — note this allows multiple movies to be featured simultaneously. /api/movies/featured returns the first match, which is non-deterministic. Consider enforcing a single featured movie (unset others on toggle on) or document the multi-feature behavior.",
"/api/upload/video accepts arbitrary Form fields with no max-size enforcement at the FastAPI layer; in production behind a reverse proxy this should be capped.",
"auth.py JWT_SECRET falls back to 'dev-secret' if env var missing — backend/.env defines it, but consider removing the default to fail-fast in misconfigured environments.",
"Email is stored as plain str (no EmailStr validation in models). Acceptable for personal server, but accepts garbage like ' ' or non-emails."
],
"updated_files": [
"/app/backend/tests/backend_test.py"
],
"success_rate": {"backend": "100% (21/21)", "frontend": "100% (9/9 flows)"},
"test_credentials": "admin@kino.local / kino-admin-2026 (seeded). Test users created via /api/auth/register with TEST_ prefix.",
"seed_data_creation": "Created several TEST_-prefixed users, one TEST_AdminMovie (created/updated/deleted in same test), and TEST_Req_Movie request. The Subaru-feature toggle done during UI test was reverted via PATCH /api/movies/{id} to keep Big Buck Bunny as the only featured movie.",
"retest_needed": false,
"main_agent_can_self_test": true,
"context_for_next_testing_agent": "Backend pytest at /app/backend/tests/backend_test.py covers all routes including stream auth (401 no token, 400 for external storage_type with valid token). For UI, all data-testids are well-named (login-*, hero-*, modal-*, nav-*, admin-movie-row-{id}, feature-{id}, delete-{id}, request-*, upload-*, player-*). Player external-vs-local logic verified by inspecting <video> src attribute. Next agent should be aware that toggling featured in admin UI can leave multiple movies featured — clean up via PATCH if needed.",
"rca of the issue": "No bugs found. One pytest failure during initial run was a test-side issue (case-sensitive email comparison) — backend correctly lowercases emails on register; test was updated to compare against email.lower()."
}
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><testsuites name="pytest tests"><testsuite name="pytest" errors="0" failures="0" skipped="0" tests="21" time="4.414" timestamp="2026-04-29T14:48:31.980296+00:00" hostname="agent-env-9896b0ae-8cd3-4e8d-9bf0-005f0eda02d4"><testcase classname="backend.tests.backend_test.TestHealth" name="test_root" time="0.197" /><testcase classname="backend.tests.backend_test.TestAuth" name="test_admin_login" time="0.305" /><testcase classname="backend.tests.backend_test.TestAuth" name="test_login_invalid" time="0.342" /><testcase classname="backend.tests.backend_test.TestAuth" name="test_register_and_me" time="0.361" /><testcase classname="backend.tests.backend_test.TestAuth" name="test_register_duplicate" time="0.342" /><testcase classname="backend.tests.backend_test.TestAuth" name="test_me_no_token" time="0.054" /><testcase classname="backend.tests.backend_test.TestMovies" name="test_list_movies_seeded" time="0.073" /><testcase classname="backend.tests.backend_test.TestMovies" name="test_featured" time="0.067" /><testcase classname="backend.tests.backend_test.TestMovies" name="test_genres" time="0.062" /><testcase classname="backend.tests.backend_test.TestMovies" name="test_get_movie_404" time="0.057" /><testcase classname="backend.tests.backend_test.TestMovies" name="test_get_movie_by_id" time="0.134" /><testcase classname="backend.tests.backend_test.TestMovies" name="test_search_query" time="0.060" /><testcase classname="backend.tests.backend_test.TestMoviesAdmin" name="test_create_requires_admin" time="0.352" /><testcase classname="backend.tests.backend_test.TestMoviesAdmin" name="test_create_update_delete" time="0.610" /><testcase classname="backend.tests.backend_test.TestWatchlist" name="test_watchlist_flow" time="0.343" /><testcase classname="backend.tests.backend_test.TestWatchlist" name="test_watchlist_unauth" time="0.052" /><testcase classname="backend.tests.backend_test.TestProgress" name="test_progress_upsert_and_continue" time="0.280" /><testcase classname="backend.tests.backend_test.TestRequests" name="test_submit_and_admin_list" time="0.308" /><testcase classname="backend.tests.backend_test.TestStream" name="test_stream_no_token" time="0.110" /><testcase classname="backend.tests.backend_test.TestStream" name="test_stream_external_returns_400" time="0.109" /><testcase classname="backend.tests.backend_test.TestStream" name="test_upload_unauth" time="0.113" /></testsuite></testsuites>