mirror of
https://github.com/myronblair/kino
synced 2026-06-30 17:50:29 -05:00
auto-commit for df4b0748-985b-4592-8c48-1e56102f3613
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""JWT auth helpers using bcrypt + python-jose."""
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
|
||||
|
||||
JWT_SECRET = os.environ.get("JWT_SECRET", "dev-secret")
|
||||
JWT_ALG = os.environ.get("JWT_ALG", "HS256")
|
||||
JWT_EXPIRE_HOURS = int(os.environ.get("JWT_EXPIRE_HOURS", "720"))
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_token(user_id: str, is_admin: bool = False) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"is_admin": is_admin,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
|
||||
except JWTError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e}")
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer),
|
||||
auth_query: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Reads bearer token; supports `?auth=` query for video tags."""
|
||||
token = None
|
||||
if creds and creds.credentials:
|
||||
token = creds.credentials
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
payload = decode_token(token)
|
||||
return payload["sub"]
|
||||
|
||||
|
||||
def token_from_query_or_header(authorization: Optional[str], auth: Optional[str]) -> str:
|
||||
"""Helper for endpoints that accept token via Authorization header OR ?auth= query."""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization.split(" ", 1)[1].strip()
|
||||
if auth:
|
||||
return auth
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
def require_user_from_any(authorization: Optional[str], auth: Optional[str]) -> dict:
|
||||
token = token_from_query_or_header(authorization, auth)
|
||||
return decode_token(token)
|
||||
@@ -0,0 +1,129 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------- Users ----------
|
||||
class UserCreate(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
is_admin: bool = False
|
||||
created_at: str
|
||||
|
||||
|
||||
# ---------- Movies ----------
|
||||
class MovieBase(BaseModel):
|
||||
title: str
|
||||
description: str = ""
|
||||
year: int = 2024
|
||||
duration_minutes: int = 0
|
||||
rating: str = "NR" # G / PG / PG-13 / R / NR
|
||||
genres: List[str] = []
|
||||
cast: List[str] = []
|
||||
director: str = ""
|
||||
poster_url: str = ""
|
||||
backdrop_url: str = ""
|
||||
video_url: str = ""
|
||||
storage_type: str = "external" # "external" or "local"
|
||||
storage_path: Optional[str] = None
|
||||
featured: bool = False
|
||||
|
||||
|
||||
class MovieCreate(MovieBase):
|
||||
pass
|
||||
|
||||
|
||||
class MovieUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
duration_minutes: Optional[int] = None
|
||||
rating: Optional[str] = None
|
||||
genres: Optional[List[str]] = None
|
||||
cast: Optional[List[str]] = None
|
||||
director: Optional[str] = None
|
||||
poster_url: Optional[str] = None
|
||||
backdrop_url: Optional[str] = None
|
||||
video_url: Optional[str] = None
|
||||
storage_type: Optional[str] = None
|
||||
storage_path: Optional[str] = None
|
||||
featured: Optional[bool] = None
|
||||
|
||||
|
||||
class Movie(MovieBase):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Watchlist ----------
|
||||
class WatchlistItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
user_id: str
|
||||
movie_id: str
|
||||
added_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Progress (Continue Watching) ----------
|
||||
class ProgressUpsert(BaseModel):
|
||||
movie_id: str
|
||||
position_seconds: float
|
||||
duration_seconds: float
|
||||
|
||||
|
||||
class Progress(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
user_id: str
|
||||
movie_id: str
|
||||
position_seconds: float
|
||||
duration_seconds: float
|
||||
updated_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Requests ----------
|
||||
class RequestCreate(BaseModel):
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class RequestUpdate(BaseModel):
|
||||
status: str # pending | fulfilled | rejected
|
||||
|
||||
|
||||
class MovieRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
user_id: str
|
||||
user_name: str = ""
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
notes: str = ""
|
||||
status: str = "pending"
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Auth Tokens ----------
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserPublic
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"""Seed data: sample movies using public-domain video sources."""
|
||||
from typing import List, Dict
|
||||
|
||||
# All videos below are in the public domain or Creative Commons.
|
||||
# Sources: Internet Archive, Blender Foundation, etc.
|
||||
SAMPLE_MOVIES: List[Dict] = [
|
||||
{
|
||||
"title": "Big Buck Bunny",
|
||||
"description": "A large and lovable rabbit deals with three tiny bullies, led by a flying squirrel, who are determined to squelch his happiness.",
|
||||
"year": 2008,
|
||||
"duration_minutes": 10,
|
||||
"rating": "G",
|
||||
"genres": ["Animation", "Comedy", "Family"],
|
||||
"cast": ["Big Buck Bunny", "The Squirrel"],
|
||||
"director": "Sacha Goedegebure",
|
||||
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/c/c5/Big_buck_bunny_poster_big.jpg",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1705147651064-36aedc005020?crop=entropy&cs=srgb&fm=jpg&ixid=M3w3NTY2ODh8MHwxfHNlYXJjaHwxfHxjaW5lbWF0aWMlMjBsYW5kc2NhcGUlMjBkYXJrfGVufDB8fHx8MTc3NzQ3MzE2M3ww&ixlib=rb-4.1.0&q=85",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||
"storage_type": "external",
|
||||
"featured": True,
|
||||
},
|
||||
{
|
||||
"title": "Sintel",
|
||||
"description": "A lonely young woman, Sintel, helps and befriends a dragon she names Scales. But when Scales is kidnapped by an adult dragon, Sintel embarks on a dangerous quest to find her friend.",
|
||||
"year": 2010,
|
||||
"duration_minutes": 15,
|
||||
"rating": "PG",
|
||||
"genres": ["Animation", "Fantasy", "Adventure"],
|
||||
"cast": ["Halina Reijn", "Thom Hoffman"],
|
||||
"director": "Colin Levy",
|
||||
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Sintel_poster.jpg/440px-Sintel_poster.jpg",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1542204165-65bf26472b9b?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "Tears of Steel",
|
||||
"description": "In an apocalyptic future, a group of soldiers and scientists takes refuge in Amsterdam to try to stop an army of robots that threaten the planet.",
|
||||
"year": 2012,
|
||||
"duration_minutes": 12,
|
||||
"rating": "PG-13",
|
||||
"genres": ["Sci-Fi", "Action"],
|
||||
"cast": ["Derek de Lint", "Sergio Hasselbaink"],
|
||||
"director": "Ian Hubert",
|
||||
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Tears_of_Steel_poster.png/440px-Tears_of_Steel_poster.png",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "Elephants Dream",
|
||||
"description": "Two strange characters explore a capricious world of machines, hostile to their inhabitants. The first open movie.",
|
||||
"year": 2006,
|
||||
"duration_minutes": 11,
|
||||
"rating": "PG",
|
||||
"genres": ["Animation", "Sci-Fi"],
|
||||
"cast": ["Cas Jansen", "Tygo Gernandt"],
|
||||
"director": "Bassam Kurdali",
|
||||
"poster_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e8/Elephants_Dream_s5_both.jpg/640px-Elephants_Dream_s5_both.jpg",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1478720568477-152d9b164e26?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "For Bigger Blazes",
|
||||
"description": "A short demonstration film exploring the depths of cinematic fire and suspense.",
|
||||
"year": 2015,
|
||||
"duration_minutes": 1,
|
||||
"rating": "G",
|
||||
"genres": ["Short", "Documentary"],
|
||||
"cast": [],
|
||||
"director": "Google",
|
||||
"poster_url": "https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=600&q=80",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "For Bigger Escapes",
|
||||
"description": "An action-packed glimpse into a high-stakes escape from the ordinary.",
|
||||
"year": 2015,
|
||||
"duration_minutes": 1,
|
||||
"rating": "G",
|
||||
"genres": ["Short", "Action"],
|
||||
"cast": [],
|
||||
"director": "Google",
|
||||
"poster_url": "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?auto=format&fit=crop&w=600&q=80",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "For Bigger Fun",
|
||||
"description": "A whirlwind ride through the brighter side of the cinematic spectrum.",
|
||||
"year": 2015,
|
||||
"duration_minutes": 1,
|
||||
"rating": "G",
|
||||
"genres": ["Short", "Comedy"],
|
||||
"cast": [],
|
||||
"director": "Google",
|
||||
"poster_url": "https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?auto=format&fit=crop&w=600&q=80",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "For Bigger Joyrides",
|
||||
"description": "Buckle up — this is a rapid-fire reel of gravity, speed, and pure adrenaline.",
|
||||
"year": 2015,
|
||||
"duration_minutes": 1,
|
||||
"rating": "G",
|
||||
"genres": ["Short", "Action"],
|
||||
"cast": [],
|
||||
"director": "Google",
|
||||
"poster_url": "https://images.unsplash.com/photo-1485846234645-a62644f84728?auto=format&fit=crop&w=600&q=80",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1485846234645-a62644f84728?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "For Bigger Meltdowns",
|
||||
"description": "When entropy turns up the heat, the world begins to melt — beautifully.",
|
||||
"year": 2015,
|
||||
"duration_minutes": 1,
|
||||
"rating": "G",
|
||||
"genres": ["Short", "Drama"],
|
||||
"cast": [],
|
||||
"director": "Google",
|
||||
"poster_url": "https://images.unsplash.com/photo-1515634928627-2a4e0dae3ddf?auto=format&fit=crop&w=600&q=80",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1515634928627-2a4e0dae3ddf?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
{
|
||||
"title": "Subaru Outback On Street And Dirt",
|
||||
"description": "A documentary exploring the texture, grit, and dust of all-terrain travel.",
|
||||
"year": 2014,
|
||||
"duration_minutes": 1,
|
||||
"rating": "G",
|
||||
"genres": ["Short", "Documentary"],
|
||||
"cast": [],
|
||||
"director": "Google",
|
||||
"poster_url": "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&w=600&q=80",
|
||||
"backdrop_url": "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&w=1920&q=80",
|
||||
"video_url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4",
|
||||
"storage_type": "external",
|
||||
},
|
||||
]
|
||||
+462
-66
@@ -1,89 +1,485 @@
|
||||
from fastapi import FastAPI, APIRouter
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request
|
||||
from fastapi.responses import StreamingResponse, FileResponse, Response
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from dotenv import load_dotenv
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import List
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models import (
|
||||
UserCreate, UserLogin, UserPublic, TokenResponse,
|
||||
Movie, MovieCreate, MovieUpdate,
|
||||
WatchlistItem, ProgressUpsert, Progress,
|
||||
RequestCreate, RequestUpdate, MovieRequest,
|
||||
)
|
||||
from auth import (
|
||||
hash_password, verify_password, create_token, decode_token,
|
||||
)
|
||||
from seed import SAMPLE_MOVIES
|
||||
|
||||
|
||||
# ---------- Setup ----------
|
||||
ROOT_DIR = Path(__file__).parent
|
||||
load_dotenv(ROOT_DIR / '.env')
|
||||
load_dotenv(ROOT_DIR / ".env")
|
||||
|
||||
# MongoDB connection
|
||||
mongo_url = os.environ['MONGO_URL']
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logger = logging.getLogger("kino")
|
||||
|
||||
mongo_url = os.environ["MONGO_URL"]
|
||||
client = AsyncIOMotorClient(mongo_url)
|
||||
db = client[os.environ['DB_NAME']]
|
||||
db = client[os.environ["DB_NAME"]]
|
||||
|
||||
# Create the main app without a prefix
|
||||
app = FastAPI()
|
||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", str(ROOT_DIR / "media")))
|
||||
VIDEOS_DIR = MEDIA_ROOT / "videos"
|
||||
POSTERS_DIR = MEDIA_ROOT / "posters"
|
||||
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
POSTERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a router with the /api prefix
|
||||
api_router = APIRouter(prefix="/api")
|
||||
CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
||||
|
||||
app = FastAPI(title="Kino — Personal Media Server")
|
||||
api = APIRouter(prefix="/api")
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# Define Models
|
||||
class StatusCheck(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore") # Ignore MongoDB's _id field
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
client_name: str
|
||||
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# ---------- Auth deps ----------
|
||||
async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> dict:
|
||||
if not creds:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
payload = decode_token(creds.credentials)
|
||||
user = await db.users.find_one({"id": payload["sub"]}, {"_id": 0, "password_hash": 0})
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
|
||||
class StatusCheckCreate(BaseModel):
|
||||
client_name: str
|
||||
|
||||
# Add your routes to the router instead of directly to app
|
||||
@api_router.get("/")
|
||||
async def get_current_user_optional(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> Optional[dict]:
|
||||
if not creds:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(creds.credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
return await db.users.find_one({"id": payload["sub"]}, {"_id": 0, "password_hash": 0})
|
||||
|
||||
|
||||
async def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
return user
|
||||
|
||||
|
||||
# ---------- Startup: seed admin + sample movies ----------
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
# Index for unique email
|
||||
await db.users.create_index("email", unique=True)
|
||||
await db.movies.create_index("title")
|
||||
await db.watchlist.create_index([("user_id", 1), ("movie_id", 1)], unique=True)
|
||||
await db.progress.create_index([("user_id", 1), ("movie_id", 1)], unique=True)
|
||||
|
||||
admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local")
|
||||
if not await db.users.find_one({"email": admin_email}):
|
||||
admin_user = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"email": admin_email,
|
||||
"name": os.environ.get("ADMIN_NAME", "Admin"),
|
||||
"password_hash": hash_password(os.environ.get("ADMIN_PASSWORD", "kino-admin-2026")),
|
||||
"is_admin": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await db.users.insert_one(admin_user)
|
||||
logger.info(f"Seeded admin user: {admin_email}")
|
||||
|
||||
# Seed movies if empty
|
||||
count = await db.movies.count_documents({})
|
||||
if count == 0:
|
||||
for m in SAMPLE_MOVIES:
|
||||
doc = Movie(**m).model_dump()
|
||||
await db.movies.insert_one(doc)
|
||||
logger.info(f"Seeded {len(SAMPLE_MOVIES)} sample movies")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------- Helpers ----------
|
||||
def _user_public(u: dict) -> UserPublic:
|
||||
return UserPublic(
|
||||
id=u["id"], email=u["email"], name=u["name"],
|
||||
is_admin=u.get("is_admin", False), created_at=u["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def _strip(doc: dict) -> dict:
|
||||
doc.pop("_id", None)
|
||||
return doc
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
@api.post("/auth/register", response_model=TokenResponse)
|
||||
async def register(payload: UserCreate):
|
||||
if await db.users.find_one({"email": payload.email.lower()}):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"email": payload.email.lower(),
|
||||
"name": payload.name,
|
||||
"password_hash": hash_password(payload.password),
|
||||
"is_admin": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await db.users.insert_one(user)
|
||||
token = create_token(user["id"], False)
|
||||
return TokenResponse(access_token=token, user=_user_public(user))
|
||||
|
||||
|
||||
@api.post("/auth/login", response_model=TokenResponse)
|
||||
async def login(payload: UserLogin):
|
||||
user = await db.users.find_one({"email": payload.email.lower()})
|
||||
if not user or not verify_password(payload.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
token = create_token(user["id"], user.get("is_admin", False))
|
||||
return TokenResponse(access_token=token, user=_user_public(user))
|
||||
|
||||
|
||||
@api.get("/auth/me", response_model=UserPublic)
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
return _user_public(user)
|
||||
|
||||
|
||||
# ---------- Movies ----------
|
||||
@api.get("/movies", response_model=List[Movie])
|
||||
async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
|
||||
query: dict = {}
|
||||
if genre:
|
||||
query["genres"] = {"$regex": f"^{genre}$", "$options": "i"}
|
||||
if q:
|
||||
query["$or"] = [
|
||||
{"title": {"$regex": q, "$options": "i"}},
|
||||
{"description": {"$regex": q, "$options": "i"}},
|
||||
{"director": {"$regex": q, "$options": "i"}},
|
||||
{"cast": {"$regex": q, "$options": "i"}},
|
||||
]
|
||||
docs = await db.movies.find(query, {"_id": 0}).sort("created_at", -1).to_list(limit)
|
||||
return docs
|
||||
|
||||
|
||||
@api.get("/movies/featured", response_model=Movie)
|
||||
async def get_featured():
|
||||
doc = await db.movies.find_one({"featured": True}, {"_id": 0})
|
||||
if not doc:
|
||||
doc = await db.movies.find_one({}, {"_id": 0})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="No movies")
|
||||
return doc
|
||||
|
||||
|
||||
@api.get("/movies/genres", response_model=List[str])
|
||||
async def list_genres():
|
||||
genres = await db.movies.distinct("genres")
|
||||
return sorted([g for g in genres if g])
|
||||
|
||||
|
||||
@api.get("/movies/{movie_id}", response_model=Movie)
|
||||
async def get_movie(movie_id: str):
|
||||
doc = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
return doc
|
||||
|
||||
|
||||
@api.post("/movies", response_model=Movie)
|
||||
async def create_movie(payload: MovieCreate, user: dict = Depends(require_admin)):
|
||||
movie = Movie(**payload.model_dump())
|
||||
doc = movie.model_dump()
|
||||
await db.movies.insert_one(doc)
|
||||
return _strip(doc)
|
||||
|
||||
|
||||
@api.patch("/movies/{movie_id}", response_model=Movie)
|
||||
async def update_movie(movie_id: str, payload: MovieUpdate, user: dict = Depends(require_admin)):
|
||||
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
res = await db.movies.find_one_and_update(
|
||||
{"id": movie_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
|
||||
)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
return res
|
||||
|
||||
|
||||
@api.delete("/movies/{movie_id}")
|
||||
async def delete_movie(movie_id: str, user: dict = Depends(require_admin)):
|
||||
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not movie:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
# delete local files if any
|
||||
if movie.get("storage_type") == "local" and movie.get("storage_path"):
|
||||
try:
|
||||
(VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
await db.movies.delete_one({"id": movie_id})
|
||||
await db.watchlist.delete_many({"movie_id": movie_id})
|
||||
await db.progress.delete_many({"movie_id": movie_id})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------- Upload (admin) ----------
|
||||
@api.post("/upload/video", response_model=Movie)
|
||||
async def upload_video(
|
||||
title: str = Form(...),
|
||||
description: str = Form(""),
|
||||
year: int = Form(2024),
|
||||
duration_minutes: int = Form(0),
|
||||
rating: str = Form("NR"),
|
||||
genres: str = Form(""), # comma-separated
|
||||
cast: str = Form(""),
|
||||
director: str = Form(""),
|
||||
poster_url: str = Form(""),
|
||||
backdrop_url: str = Form(""),
|
||||
featured: bool = Form(False),
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(require_admin),
|
||||
):
|
||||
ext = (file.filename.rsplit(".", 1)[-1] if "." in (file.filename or "") else "mp4").lower()
|
||||
safe_id = str(uuid.uuid4())
|
||||
fname = f"{safe_id}.{ext}"
|
||||
target = VIDEOS_DIR / fname
|
||||
|
||||
# stream save
|
||||
with target.open("wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
movie = Movie(
|
||||
title=title,
|
||||
description=description,
|
||||
year=year,
|
||||
duration_minutes=duration_minutes,
|
||||
rating=rating,
|
||||
genres=[g.strip() for g in genres.split(",") if g.strip()],
|
||||
cast=[c.strip() for c in cast.split(",") if c.strip()],
|
||||
director=director,
|
||||
poster_url=poster_url,
|
||||
backdrop_url=backdrop_url,
|
||||
video_url="", # filled with stream endpoint client-side
|
||||
storage_type="local",
|
||||
storage_path=fname,
|
||||
featured=featured,
|
||||
)
|
||||
doc = movie.model_dump()
|
||||
await db.movies.insert_one(doc)
|
||||
return _strip(doc)
|
||||
|
||||
|
||||
# ---------- Streaming with Range support ----------
|
||||
@api.get("/stream/{movie_id}")
|
||||
async def stream_movie(
|
||||
movie_id: str,
|
||||
request: Request,
|
||||
auth: Optional[str] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
# token check (header OR ?auth= for <video> tag)
|
||||
token = None
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
elif auth:
|
||||
token = auth
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
decode_token(token) # raises 401 if invalid
|
||||
|
||||
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not movie:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
|
||||
if movie.get("storage_type") != "local" or not movie.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Movie has no local file; use video_url directly")
|
||||
|
||||
file_path = VIDEOS_DIR / movie["storage_path"]
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File missing on disk")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
content_type = content_type or "video/mp4"
|
||||
|
||||
range_header = request.headers.get("range") or request.headers.get("Range")
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
try:
|
||||
start_str, end_str = range_header.replace("bytes=", "").split("-")
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else file_size - 1
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=416, detail="Invalid range")
|
||||
if start >= file_size:
|
||||
raise HTTPException(status_code=416, detail="Range out of bounds")
|
||||
end = min(end, file_size - 1)
|
||||
length = end - start + 1
|
||||
|
||||
def iter_file():
|
||||
with file_path.open("rb") as f:
|
||||
f.seek(start)
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = f.read(min(CHUNK_SIZE, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
headers = {
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(length),
|
||||
"Content-Type": content_type,
|
||||
}
|
||||
return StreamingResponse(iter_file(), status_code=206, headers=headers, media_type=content_type)
|
||||
|
||||
# full file
|
||||
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
|
||||
|
||||
|
||||
# ---------- Watchlist ----------
|
||||
@api.get("/watchlist", response_model=List[Movie])
|
||||
async def get_watchlist(user: dict = Depends(get_current_user)):
|
||||
items = await db.watchlist.find({"user_id": user["id"]}, {"_id": 0}).sort("added_at", -1).to_list(500)
|
||||
movie_ids = [i["movie_id"] for i in items]
|
||||
if not movie_ids:
|
||||
return []
|
||||
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(500)
|
||||
# preserve order
|
||||
by_id = {m["id"]: m for m in movies}
|
||||
return [by_id[mid] for mid in movie_ids if mid in by_id]
|
||||
|
||||
|
||||
@api.post("/watchlist/{movie_id}")
|
||||
async def add_watchlist(movie_id: str, user: dict = Depends(get_current_user)):
|
||||
if not await db.movies.find_one({"id": movie_id}, {"_id": 1}):
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
item = WatchlistItem(user_id=user["id"], movie_id=movie_id).model_dump()
|
||||
try:
|
||||
await db.watchlist.insert_one(item)
|
||||
except Exception:
|
||||
pass # duplicate ok
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.delete("/watchlist/{movie_id}")
|
||||
async def remove_watchlist(movie_id: str, user: dict = Depends(get_current_user)):
|
||||
await db.watchlist.delete_one({"user_id": user["id"], "movie_id": movie_id})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------- Progress (continue watching) ----------
|
||||
@api.post("/progress")
|
||||
async def upsert_progress(payload: ProgressUpsert, user: dict = Depends(get_current_user)):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await db.progress.update_one(
|
||||
{"user_id": user["id"], "movie_id": payload.movie_id},
|
||||
{"$set": {
|
||||
"user_id": user["id"],
|
||||
"movie_id": payload.movie_id,
|
||||
"position_seconds": payload.position_seconds,
|
||||
"duration_seconds": payload.duration_seconds,
|
||||
"updated_at": now,
|
||||
}},
|
||||
upsert=True,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/progress/continue", response_model=List[dict])
|
||||
async def continue_watching(user: dict = Depends(get_current_user)):
|
||||
rows = await db.progress.find(
|
||||
{"user_id": user["id"]}, {"_id": 0}
|
||||
).sort("updated_at", -1).to_list(50)
|
||||
# filter out completed (>95%)
|
||||
rows = [r for r in rows if r.get("duration_seconds", 0) == 0 or
|
||||
r["position_seconds"] / max(r["duration_seconds"], 1) < 0.95]
|
||||
if not rows:
|
||||
return []
|
||||
movie_ids = [r["movie_id"] for r in rows]
|
||||
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(50)
|
||||
by_id = {m["id"]: m for m in movies}
|
||||
out = []
|
||||
for r in rows:
|
||||
m = by_id.get(r["movie_id"])
|
||||
if m:
|
||||
out.append({**m, "progress": r})
|
||||
return out
|
||||
|
||||
|
||||
@api.get("/progress/{movie_id}")
|
||||
async def get_progress(movie_id: str, user: dict = Depends(get_current_user)):
|
||||
p = await db.progress.find_one({"user_id": user["id"], "movie_id": movie_id}, {"_id": 0})
|
||||
return p or {"position_seconds": 0, "duration_seconds": 0}
|
||||
|
||||
|
||||
# ---------- Movie Requests ----------
|
||||
@api.post("/requests", response_model=MovieRequest)
|
||||
async def submit_request(payload: RequestCreate, user: dict = Depends(get_current_user)):
|
||||
req = MovieRequest(
|
||||
user_id=user["id"], user_name=user.get("name", ""),
|
||||
title=payload.title, year=payload.year, notes=payload.notes,
|
||||
)
|
||||
doc = req.model_dump()
|
||||
await db.requests.insert_one(doc)
|
||||
return _strip(doc)
|
||||
|
||||
|
||||
@api.get("/requests/mine", response_model=List[MovieRequest])
|
||||
async def my_requests(user: dict = Depends(get_current_user)):
|
||||
docs = await db.requests.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", -1).to_list(200)
|
||||
return docs
|
||||
|
||||
|
||||
@api.get("/requests", response_model=List[MovieRequest])
|
||||
async def all_requests(user: dict = Depends(require_admin)):
|
||||
docs = await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500)
|
||||
return docs
|
||||
|
||||
|
||||
@api.patch("/requests/{request_id}", response_model=MovieRequest)
|
||||
async def update_request(request_id: str, payload: RequestUpdate, user: dict = Depends(require_admin)):
|
||||
res = await db.requests.find_one_and_update(
|
||||
{"id": request_id}, {"$set": {"status": payload.status}},
|
||||
projection={"_id": 0}, return_document=True,
|
||||
)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
return res
|
||||
|
||||
|
||||
# ---------- Health ----------
|
||||
@api.get("/")
|
||||
async def root():
|
||||
return {"message": "Hello World"}
|
||||
return {"app": "Kino", "status": "ok"}
|
||||
|
||||
@api_router.post("/status", response_model=StatusCheck)
|
||||
async def create_status_check(input: StatusCheckCreate):
|
||||
status_dict = input.model_dump()
|
||||
status_obj = StatusCheck(**status_dict)
|
||||
|
||||
# Convert to dict and serialize datetime to ISO string for MongoDB
|
||||
doc = status_obj.model_dump()
|
||||
doc['timestamp'] = doc['timestamp'].isoformat()
|
||||
|
||||
_ = await db.status_checks.insert_one(doc)
|
||||
return status_obj
|
||||
|
||||
@api_router.get("/status", response_model=List[StatusCheck])
|
||||
async def get_status_checks():
|
||||
# Exclude MongoDB's _id field from the query results
|
||||
status_checks = await db.status_checks.find({}, {"_id": 0}).to_list(1000)
|
||||
|
||||
# Convert ISO string timestamps back to datetime objects
|
||||
for check in status_checks:
|
||||
if isinstance(check['timestamp'], str):
|
||||
check['timestamp'] = datetime.fromisoformat(check['timestamp'])
|
||||
|
||||
return status_checks
|
||||
|
||||
# Include the router in the main app
|
||||
app.include_router(api_router)
|
||||
|
||||
# Mount router and CORS
|
||||
app.include_router(api)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_credentials=True,
|
||||
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
|
||||
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_db_client():
|
||||
client.close()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Backend pytest suite for Kino Personal Media Server.
|
||||
Tests auth, movies, watchlist, progress, requests, and stream auth.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://streamhoard.preview.emergentagent.com").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@kino.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
|
||||
|
||||
# ---------- Fixtures ----------
|
||||
@pytest.fixture(scope="session")
|
||||
def api():
|
||||
s = requests.Session()
|
||||
s.headers.update({"Content-Type": "application/json"})
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_token(api):
|
||||
r = api.post(f"{BASE_URL}/api/auth/login", json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 200, f"admin login failed: {r.status_code} {r.text}"
|
||||
data = r.json()
|
||||
assert data["user"]["is_admin"] is True
|
||||
return data["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def member_token(api):
|
||||
email = f"TEST_user_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Test User"})
|
||||
assert r.status_code == 200, f"register failed: {r.text}"
|
||||
data = r.json()
|
||||
return data["access_token"], data["user"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers(admin_token):
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_headers(member_token):
|
||||
tok, _ = member_token
|
||||
return {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
|
||||
# ---------- Health ----------
|
||||
class TestHealth:
|
||||
def test_root(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/")
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("status") == "ok"
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
class TestAuth:
|
||||
def test_admin_login(self, api):
|
||||
r = api.post(f"{BASE_URL}/api/auth/login", json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "access_token" in d and isinstance(d["access_token"], str)
|
||||
assert d["user"]["email"] == ADMIN_EMAIL
|
||||
assert d["user"]["is_admin"] is True
|
||||
|
||||
def test_login_invalid(self, api):
|
||||
r = api.post(f"{BASE_URL}/api/auth/login", json={"email": ADMIN_EMAIL, "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_register_and_me(self, api):
|
||||
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Reg User"})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
token = d["access_token"]
|
||||
assert d["user"]["email"] == email.lower()
|
||||
assert d["user"]["is_admin"] is False
|
||||
# /me
|
||||
r2 = api.get(f"{BASE_URL}/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["email"] == email.lower()
|
||||
|
||||
def test_register_duplicate(self, api):
|
||||
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_me_no_token(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------- Movies (public) ----------
|
||||
class TestMovies:
|
||||
def test_list_movies_seeded(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/movies")
|
||||
assert r.status_code == 200
|
||||
movies = r.json()
|
||||
assert isinstance(movies, list)
|
||||
assert len(movies) >= 10
|
||||
# No _id leaked
|
||||
assert all("_id" not in m for m in movies)
|
||||
# Required fields
|
||||
m = movies[0]
|
||||
for f in ("id", "title", "video_url", "storage_type"):
|
||||
assert f in m
|
||||
|
||||
def test_featured(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/movies/featured")
|
||||
assert r.status_code == 200
|
||||
m = r.json()
|
||||
assert m["title"] == "Big Buck Bunny"
|
||||
assert "_id" not in m
|
||||
|
||||
def test_genres(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/movies/genres")
|
||||
assert r.status_code == 200
|
||||
genres = r.json()
|
||||
assert isinstance(genres, list)
|
||||
assert len(genres) > 0
|
||||
assert genres == sorted(genres)
|
||||
|
||||
def test_get_movie_404(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/movies/nonexistent-id-xyz")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_get_movie_by_id(self, api):
|
||||
movies = api.get(f"{BASE_URL}/api/movies").json()
|
||||
mid = movies[0]["id"]
|
||||
r = api.get(f"{BASE_URL}/api/movies/{mid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == mid
|
||||
|
||||
def test_search_query(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/movies", params={"q": "bunny"})
|
||||
assert r.status_code == 200
|
||||
results = r.json()
|
||||
assert any("bunny" in m["title"].lower() for m in results)
|
||||
|
||||
|
||||
# ---------- Movies admin CRUD ----------
|
||||
class TestMoviesAdmin:
|
||||
def test_create_requires_admin(self, api, member_headers):
|
||||
r = api.post(f"{BASE_URL}/api/movies", json={"title": "X"}, headers=member_headers)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_create_update_delete(self, api, admin_headers):
|
||||
payload = {
|
||||
"title": "TEST_AdminMovie",
|
||||
"description": "test",
|
||||
"year": 2024,
|
||||
"genres": ["Test"],
|
||||
"video_url": "https://example.com/x.mp4",
|
||||
"storage_type": "external",
|
||||
"featured": False,
|
||||
}
|
||||
r = api.post(f"{BASE_URL}/api/movies", json=payload, headers=admin_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
created = r.json()
|
||||
mid = created["id"]
|
||||
assert created["title"] == "TEST_AdminMovie"
|
||||
|
||||
# GET to verify persistence
|
||||
g = api.get(f"{BASE_URL}/api/movies/{mid}")
|
||||
assert g.status_code == 200
|
||||
assert g.json()["title"] == "TEST_AdminMovie"
|
||||
|
||||
# PATCH
|
||||
u = api.patch(f"{BASE_URL}/api/movies/{mid}", json={"title": "TEST_AdminMovieUpdated"}, headers=admin_headers)
|
||||
assert u.status_code == 200
|
||||
assert u.json()["title"] == "TEST_AdminMovieUpdated"
|
||||
|
||||
# GET re-verify
|
||||
g2 = api.get(f"{BASE_URL}/api/movies/{mid}")
|
||||
assert g2.json()["title"] == "TEST_AdminMovieUpdated"
|
||||
|
||||
# DELETE
|
||||
d = api.delete(f"{BASE_URL}/api/movies/{mid}", headers=admin_headers)
|
||||
assert d.status_code == 200
|
||||
|
||||
# Verify 404
|
||||
g3 = api.get(f"{BASE_URL}/api/movies/{mid}")
|
||||
assert g3.status_code == 404
|
||||
|
||||
|
||||
# ---------- Watchlist ----------
|
||||
class TestWatchlist:
|
||||
def test_watchlist_flow(self, api, member_headers):
|
||||
movies = api.get(f"{BASE_URL}/api/movies").json()
|
||||
mid = movies[0]["id"]
|
||||
|
||||
r = api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
|
||||
assert r.status_code == 200
|
||||
|
||||
r2 = api.get(f"{BASE_URL}/api/watchlist", headers=member_headers)
|
||||
assert r2.status_code == 200
|
||||
wl = r2.json()
|
||||
assert any(m["id"] == mid for m in wl)
|
||||
|
||||
# Idempotent add
|
||||
r3 = api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
|
||||
assert r3.status_code == 200
|
||||
|
||||
d = api.delete(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
|
||||
assert d.status_code == 200
|
||||
|
||||
r4 = api.get(f"{BASE_URL}/api/watchlist", headers=member_headers)
|
||||
assert all(m["id"] != mid for m in r4.json())
|
||||
|
||||
def test_watchlist_unauth(self, api):
|
||||
r = api.get(f"{BASE_URL}/api/watchlist")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------- Progress ----------
|
||||
class TestProgress:
|
||||
def test_progress_upsert_and_continue(self, api, member_headers):
|
||||
movies = api.get(f"{BASE_URL}/api/movies").json()
|
||||
mid = movies[1]["id"]
|
||||
r = api.post(f"{BASE_URL}/api/progress",
|
||||
json={"movie_id": mid, "position_seconds": 30, "duration_seconds": 600},
|
||||
headers=member_headers)
|
||||
assert r.status_code == 200
|
||||
|
||||
r2 = api.get(f"{BASE_URL}/api/progress/continue", headers=member_headers)
|
||||
assert r2.status_code == 200
|
||||
rows = r2.json()
|
||||
assert any(m["id"] == mid for m in rows)
|
||||
# progress object attached
|
||||
target = next(m for m in rows if m["id"] == mid)
|
||||
assert target["progress"]["position_seconds"] == 30
|
||||
|
||||
# update
|
||||
r3 = api.post(f"{BASE_URL}/api/progress",
|
||||
json={"movie_id": mid, "position_seconds": 90, "duration_seconds": 600},
|
||||
headers=member_headers)
|
||||
assert r3.status_code == 200
|
||||
r4 = api.get(f"{BASE_URL}/api/progress/{mid}", headers=member_headers)
|
||||
assert r4.json()["position_seconds"] == 90
|
||||
|
||||
|
||||
# ---------- Requests ----------
|
||||
class TestRequests:
|
||||
def test_submit_and_admin_list(self, api, member_headers, admin_headers):
|
||||
payload = {"title": "TEST_Req_Movie", "year": 2023, "notes": "please add"}
|
||||
r = api.post(f"{BASE_URL}/api/requests", json=payload, headers=member_headers)
|
||||
assert r.status_code == 200
|
||||
req = r.json()
|
||||
rid = req["id"]
|
||||
assert req["status"] == "pending"
|
||||
|
||||
mine = api.get(f"{BASE_URL}/api/requests/mine", headers=member_headers)
|
||||
assert mine.status_code == 200
|
||||
assert any(x["id"] == rid for x in mine.json())
|
||||
|
||||
# Member cannot list all
|
||||
forbid = api.get(f"{BASE_URL}/api/requests", headers=member_headers)
|
||||
assert forbid.status_code == 403
|
||||
|
||||
# Admin lists
|
||||
all_r = api.get(f"{BASE_URL}/api/requests", headers=admin_headers)
|
||||
assert all_r.status_code == 200
|
||||
assert any(x["id"] == rid for x in all_r.json())
|
||||
|
||||
# Admin updates
|
||||
upd = api.patch(f"{BASE_URL}/api/requests/{rid}", json={"status": "fulfilled"}, headers=admin_headers)
|
||||
assert upd.status_code == 200
|
||||
assert upd.json()["status"] == "fulfilled"
|
||||
|
||||
|
||||
# ---------- Streaming ----------
|
||||
class TestStream:
|
||||
def test_stream_no_token(self, api):
|
||||
movies = api.get(f"{BASE_URL}/api/movies").json()
|
||||
mid = movies[0]["id"]
|
||||
r = api.get(f"{BASE_URL}/api/stream/{mid}", allow_redirects=False)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_stream_external_returns_400(self, api, admin_token):
|
||||
# All seeded movies are storage_type=external -> should 400
|
||||
movies = api.get(f"{BASE_URL}/api/movies").json()
|
||||
mid = movies[0]["id"]
|
||||
r = api.get(f"{BASE_URL}/api/stream/{mid}", params={"auth": admin_token}, allow_redirects=False)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_upload_unauth(self, api):
|
||||
# Just verify rejection without token; do NOT upload large file
|
||||
r = requests.post(f"{BASE_URL}/api/upload/video", data={"title": "X"}, files={"file": ("x.mp4", b"x", "video/mp4")})
|
||||
assert r.status_code in (401, 403, 422)
|
||||
+2
-34
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const GrainOverlay = () => <div className="grain-overlay" aria-hidden="true" />;
|
||||
export default GrainOverlay;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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()."
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user