auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc

This commit is contained in:
emergent-agent-e1
2026-04-29 16:01:20 +00:00
parent 1d4bd4f513
commit cdc8c8955f
19 changed files with 1933 additions and 339 deletions
+90 -5
View File
@@ -29,22 +29,55 @@ class UserPublic(BaseModel):
created_at: str created_at: str
# ---------- Profiles ("Who's Watching") ----------
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
class ProfileCreate(BaseModel):
name: str
avatar_color: str = "#D9381E"
is_kids: bool = False
max_rating: str = "NR" # one of RATING_ORDER
class ProfileUpdate(BaseModel):
name: Optional[str] = None
avatar_color: Optional[str] = None
is_kids: Optional[bool] = None
max_rating: Optional[str] = None
class Profile(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
user_id: str
name: str
avatar_color: str = "#D9381E"
is_kids: bool = False
max_rating: str = "NR"
created_at: str = Field(default_factory=_now_iso)
# ---------- Movies ---------- # ---------- Movies ----------
class MovieBase(BaseModel): class MovieBase(BaseModel):
title: str title: str
description: str = "" description: str = ""
year: int = 2024 year: int = 2024
duration_minutes: int = 0 duration_minutes: int = 0
rating: str = "NR" # G / PG / PG-13 / R / NR rating: str = "NR"
genres: List[str] = [] genres: List[str] = []
cast: List[str] = [] cast: List[str] = []
director: str = "" director: str = ""
poster_url: str = "" poster_url: str = ""
backdrop_url: str = "" backdrop_url: str = ""
video_url: str = "" video_url: str = ""
storage_type: str = "external" # "external" or "local" storage_type: str = "external" # external | local | radarr
storage_path: Optional[str] = None storage_path: Optional[str] = None
featured: bool = False featured: bool = False
tmdb_id: Optional[int] = None
radarr_id: Optional[int] = None
hls_path: Optional[str] = None
hls_status: Optional[str] = None # pending|running|done|failed
class MovieCreate(MovieBase): class MovieCreate(MovieBase):
@@ -66,6 +99,7 @@ class MovieUpdate(BaseModel):
storage_type: Optional[str] = None storage_type: Optional[str] = None
storage_path: Optional[str] = None storage_path: Optional[str] = None
featured: Optional[bool] = None featured: Optional[bool] = None
tmdb_id: Optional[int] = None
class Movie(MovieBase): class Movie(MovieBase):
@@ -74,16 +108,28 @@ class Movie(MovieBase):
created_at: str = Field(default_factory=_now_iso) created_at: str = Field(default_factory=_now_iso)
# ---------- Watchlist ---------- # ---------- Subtitles ----------
class Subtitle(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
movie_id: str
language: str = "en" # ISO code
label: str = "English"
storage_path: str # filename inside MEDIA_ROOT/subtitles
is_default: bool = False
created_at: str = Field(default_factory=_now_iso)
# ---------- Watchlist / Progress (now profile-scoped) ----------
class WatchlistItem(BaseModel): class WatchlistItem(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
profile_id: str
user_id: str user_id: str
movie_id: str movie_id: str
added_at: str = Field(default_factory=_now_iso) added_at: str = Field(default_factory=_now_iso)
# ---------- Progress (Continue Watching) ----------
class ProgressUpsert(BaseModel): class ProgressUpsert(BaseModel):
movie_id: str movie_id: str
position_seconds: float position_seconds: float
@@ -92,6 +138,7 @@ class ProgressUpsert(BaseModel):
class Progress(BaseModel): class Progress(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
profile_id: str
user_id: str user_id: str
movie_id: str movie_id: str
position_seconds: float position_seconds: float
@@ -107,7 +154,7 @@ class RequestCreate(BaseModel):
class RequestUpdate(BaseModel): class RequestUpdate(BaseModel):
status: str # pending | fulfilled | rejected status: str
class MovieRequest(BaseModel): class MovieRequest(BaseModel):
@@ -122,8 +169,46 @@ class MovieRequest(BaseModel):
created_at: str = Field(default_factory=_now_iso) created_at: str = Field(default_factory=_now_iso)
# ---------- App Settings (admin) ----------
class AppSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
tmdb_api_key: str = ""
radarr_url: str = ""
radarr_api_key: str = ""
class AppSettingsPublic(BaseModel):
"""Settings visible to admin UI (does not redact, since admin already has access)."""
tmdb_configured: bool = False
radarr_configured: bool = False
tmdb_api_key: str = ""
radarr_url: str = ""
radarr_api_key: str = ""
# ---------- Auth Tokens ---------- # ---------- Auth Tokens ----------
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: UserPublic user: UserPublic
# ---------- TMDB / Radarr DTOs ----------
class TMDBSearchResult(BaseModel):
tmdb_id: int
title: str
year: Optional[int] = None
overview: str = ""
poster_url: str = ""
backdrop_url: str = ""
class RadarrMovieDTO(BaseModel):
radarr_id: int
title: str
year: Optional[int] = None
overview: str = ""
has_file: bool = False
file_path: Optional[str] = None
poster_url: str = ""
tmdb_id: Optional[int] = None
+42
View File
@@ -0,0 +1,42 @@
"""Radarr v3 API client. https://radarr.video/docs/api/"""
import requests
from typing import List, Dict, Optional
def _h(api_key: str) -> dict:
return {"X-Api-Key": api_key, "Content-Type": "application/json"}
def list_movies(base_url: str, api_key: str) -> List[Dict]:
base_url = base_url.rstrip("/")
r = requests.get(f"{base_url}/api/v3/movie", headers=_h(api_key), timeout=20)
r.raise_for_status()
data = r.json() or []
out = []
for m in data:
poster = ""
for img in m.get("images", []) or []:
if img.get("coverType") == "poster":
poster = img.get("remoteUrl") or img.get("url") or ""
break
movie_file = m.get("movieFile") or {}
out.append({
"radarr_id": m["id"],
"title": m.get("title") or "",
"year": m.get("year"),
"overview": m.get("overview") or "",
"has_file": bool(m.get("hasFile")),
"file_path": movie_file.get("path") if movie_file else None,
"poster_url": poster,
"tmdb_id": m.get("tmdbId"),
})
return out
def test_connection(base_url: str, api_key: str) -> bool:
base_url = base_url.rstrip("/")
try:
r = requests.get(f"{base_url}/api/v3/system/status", headers=_h(api_key), timeout=10)
return r.ok
except Exception:
return False
+444 -137
View File
@@ -2,11 +2,12 @@ import os
import uuid import uuid
import logging import logging
import mimetypes import mimetypes
import asyncio
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request, BackgroundTasks
from fastapi.responses import StreamingResponse, FileResponse, Response from fastapi.responses import StreamingResponse, FileResponse, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -15,17 +16,21 @@ from motor.motor_asyncio import AsyncIOMotorClient
from models import ( from models import (
UserCreate, UserLogin, UserPublic, TokenResponse, UserCreate, UserLogin, UserPublic, TokenResponse,
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
Movie, MovieCreate, MovieUpdate, Movie, MovieCreate, MovieUpdate,
WatchlistItem, ProgressUpsert, Progress, Subtitle,
WatchlistItem, ProgressUpsert,
RequestCreate, RequestUpdate, MovieRequest, RequestCreate, RequestUpdate, MovieRequest,
AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO,
) )
from auth import ( from auth import hash_password, verify_password, create_token, decode_token
hash_password, verify_password, create_token, decode_token,
)
from seed import SAMPLE_MOVIES from seed import SAMPLE_MOVIES
import tmdb as tmdb_client
import radarr as radarr_client
from transcode import transcode_to_hls, srt_to_vtt
# ---------- Setup ----------
ROOT_DIR = Path(__file__).parent ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / ".env") load_dotenv(ROOT_DIR / ".env")
@@ -39,12 +44,14 @@ db = client[os.environ["DB_NAME"]]
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", str(ROOT_DIR / "media"))) MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", str(ROOT_DIR / "media")))
VIDEOS_DIR = MEDIA_ROOT / "videos" VIDEOS_DIR = MEDIA_ROOT / "videos"
POSTERS_DIR = MEDIA_ROOT / "posters" POSTERS_DIR = MEDIA_ROOT / "posters"
VIDEOS_DIR.mkdir(parents=True, exist_ok=True) SUBS_DIR = MEDIA_ROOT / "subtitles"
POSTERS_DIR.mkdir(parents=True, exist_ok=True) HLS_DIR = MEDIA_ROOT / "hls"
for d in (VIDEOS_DIR, POSTERS_DIR, SUBS_DIR, HLS_DIR):
d.mkdir(parents=True, exist_ok=True)
CHUNK_SIZE = 1024 * 1024 # 1 MiB CHUNK_SIZE = 1024 * 1024
app = FastAPI(title="Kino — Personal Media Server") app = FastAPI(title="Kino")
api = APIRouter(prefix="/api") api = APIRouter(prefix="/api")
bearer = HTTPBearer(auto_error=False) bearer = HTTPBearer(auto_error=False)
@@ -60,30 +67,46 @@ async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depen
return user return user
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: async def require_admin(user: dict = Depends(get_current_user)) -> dict:
if not user.get("is_admin"): if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
return user return user
# ---------- Startup: seed admin + sample movies ---------- async def get_active_profile(
x_profile_id: Optional[str] = Header(None),
user: dict = Depends(get_current_user),
) -> dict:
"""Resolve current profile via X-Profile-Id header, else default profile."""
if x_profile_id:
p = await db.profiles.find_one({"id": x_profile_id, "user_id": user["id"]}, {"_id": 0})
if p:
return p
p = await db.profiles.find_one({"user_id": user["id"]}, {"_id": 0})
if not p:
# auto-create default
prof = Profile(user_id=user["id"], name=user.get("name", "Main")).model_dump()
await db.profiles.insert_one(prof)
prof.pop("_id", None)
return prof
return p
# ---------- Settings helper ----------
async def get_settings() -> dict:
doc = await db.settings.find_one({"id": "app"}, {"_id": 0})
if not doc:
return AppSettings().model_dump() | {"id": "app"}
return doc
# ---------- Startup ----------
@app.on_event("startup") @app.on_event("startup")
async def on_startup(): async def on_startup():
# Index for unique email
await db.users.create_index("email", unique=True) await db.users.create_index("email", unique=True)
await db.movies.create_index("title") await db.movies.create_index("title")
await db.watchlist.create_index([("user_id", 1), ("movie_id", 1)], unique=True) await db.profiles.create_index("user_id")
await db.progress.create_index([("user_id", 1), ("movie_id", 1)], unique=True) await db.subtitles.create_index("movie_id")
admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local") admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local")
if not await db.users.find_one({"email": admin_email}): if not await db.users.find_one({"email": admin_email}):
@@ -96,15 +119,44 @@ async def on_startup():
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
} }
await db.users.insert_one(admin_user) await db.users.insert_one(admin_user)
logger.info(f"Seeded admin user: {admin_email}") logger.info(f"Seeded admin: {admin_email}")
# Seed movies if empty if await db.movies.count_documents({}) == 0:
count = await db.movies.count_documents({})
if count == 0:
for m in SAMPLE_MOVIES: for m in SAMPLE_MOVIES:
doc = Movie(**m).model_dump() await db.movies.insert_one(Movie(**m).model_dump())
await db.movies.insert_one(doc) logger.info(f"Seeded {len(SAMPLE_MOVIES)} movies")
logger.info(f"Seeded {len(SAMPLE_MOVIES)} sample movies")
# ---- Migration: ensure every user has a default profile, backfill watchlist/progress ----
async for u in db.users.find({}, {"_id": 0, "id": 1, "name": 1}):
if not await db.profiles.find_one({"user_id": u["id"]}):
prof = Profile(user_id=u["id"], name=u.get("name") or "Main").model_dump()
await db.profiles.insert_one(prof)
await db.watchlist.update_many(
{"user_id": u["id"], "$or": [{"profile_id": {"$exists": False}}, {"profile_id": None}]},
{"$set": {"profile_id": prof["id"]}},
)
await db.progress.update_many(
{"user_id": u["id"], "$or": [{"profile_id": {"$exists": False}}, {"profile_id": None}]},
{"$set": {"profile_id": prof["id"]}},
)
# Now build unique indexes (migration above ensures no null collisions)
try:
await db.watchlist.drop_index("user_id_1_movie_id_1")
except Exception:
pass
try:
await db.watchlist.create_index([("profile_id", 1), ("movie_id", 1)], unique=True)
except Exception as e:
logger.warning(f"watchlist unique index skipped: {e}")
try:
await db.progress.drop_index("user_id_1_movie_id_1")
except Exception:
pass
try:
await db.progress.create_index([("profile_id", 1), ("movie_id", 1)], unique=True)
except Exception as e:
logger.warning(f"progress unique index skipped: {e}")
@app.on_event("shutdown") @app.on_event("shutdown")
@@ -112,7 +164,6 @@ async def on_shutdown():
client.close() client.close()
# ---------- Helpers ----------
def _user_public(u: dict) -> UserPublic: def _user_public(u: dict) -> UserPublic:
return UserPublic( return UserPublic(
id=u["id"], email=u["email"], name=u["name"], id=u["id"], email=u["email"], name=u["name"],
@@ -120,12 +171,23 @@ def _user_public(u: dict) -> UserPublic:
) )
def _strip(doc: dict) -> dict: def _strip(d: dict) -> dict:
doc.pop("_id", None) d.pop("_id", None)
return doc return d
# ---------- Auth ---------- def _rating_allowed(profile: dict, rating: str) -> bool:
"""Profile.max_rating is the strictest a profile can see."""
cap = profile.get("max_rating", "NR")
if cap == "NR":
return True
try:
return RATING_ORDER.index(rating) <= RATING_ORDER.index(cap)
except ValueError:
return True # unknown rating → allow
# ============ AUTH ============
@api.post("/auth/register", response_model=TokenResponse) @api.post("/auth/register", response_model=TokenResponse)
async def register(payload: UserCreate): async def register(payload: UserCreate):
if await db.users.find_one({"email": payload.email.lower()}): if await db.users.find_one({"email": payload.email.lower()}):
@@ -139,8 +201,9 @@ async def register(payload: UserCreate):
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
} }
await db.users.insert_one(user) await db.users.insert_one(user)
token = create_token(user["id"], False) # auto-create default profile
return TokenResponse(access_token=token, user=_user_public(user)) await db.profiles.insert_one(Profile(user_id=user["id"], name=user["name"]).model_dump())
return TokenResponse(access_token=create_token(user["id"], False), user=_user_public(user))
@api.post("/auth/login", response_model=TokenResponse) @api.post("/auth/login", response_model=TokenResponse)
@@ -148,8 +211,7 @@ async def login(payload: UserLogin):
user = await db.users.find_one({"email": payload.email.lower()}) user = await db.users.find_one({"email": payload.email.lower()})
if not user or not verify_password(payload.password, user["password_hash"]): if not user or not verify_password(payload.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid email or password") raise HTTPException(status_code=401, detail="Invalid email or password")
token = create_token(user["id"], user.get("is_admin", False)) return TokenResponse(access_token=create_token(user["id"], user.get("is_admin", False)), user=_user_public(user))
return TokenResponse(access_token=token, user=_user_public(user))
@api.get("/auth/me", response_model=UserPublic) @api.get("/auth/me", response_model=UserPublic)
@@ -157,10 +219,65 @@ async def me(user: dict = Depends(get_current_user)):
return _user_public(user) return _user_public(user)
# ---------- Movies ---------- # ============ PROFILES ============
@api.get("/profiles", response_model=List[Profile])
async def list_profiles(user: dict = Depends(get_current_user)):
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(20)
return docs
@api.post("/profiles", response_model=Profile)
async def create_profile(payload: ProfileCreate, user: dict = Depends(get_current_user)):
count = await db.profiles.count_documents({"user_id": user["id"]})
if count >= 5:
raise HTTPException(status_code=400, detail="Max 5 profiles per account")
prof = Profile(user_id=user["id"], **payload.model_dump()).model_dump()
await db.profiles.insert_one(prof)
return _strip(prof)
@api.patch("/profiles/{pid}", response_model=Profile)
async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)):
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.profiles.find_one_and_update(
{"id": pid, "user_id": user["id"]},
{"$set": updates},
projection={"_id": 0}, return_document=True,
)
if not res:
raise HTTPException(status_code=404, detail="Profile not found")
return res
@api.delete("/profiles/{pid}")
async def delete_profile(pid: str, user: dict = Depends(get_current_user)):
if await db.profiles.count_documents({"user_id": user["id"]}) <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the only profile")
res = await db.profiles.delete_one({"id": pid, "user_id": user["id"]})
if res.deleted_count == 0:
raise HTTPException(status_code=404, detail="Profile not found")
await db.watchlist.delete_many({"profile_id": pid})
await db.progress.delete_many({"profile_id": pid})
return {"ok": True}
# ============ MOVIES ============
def _movie_filter_for_profile(profile: dict) -> dict:
cap = profile.get("max_rating", "NR")
if cap == "NR":
return {}
allowed = RATING_ORDER[:RATING_ORDER.index(cap) + 1]
return {"rating": {"$in": allowed + ["NR"]}}
@api.get("/movies", response_model=List[Movie]) @api.get("/movies", response_model=List[Movie])
async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200): async def list_movies(
query: dict = {} genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200,
profile: dict = Depends(get_active_profile),
):
query: dict = _movie_filter_for_profile(profile)
if genre: if genre:
query["genres"] = {"$regex": f"^{genre}$", "$options": "i"} query["genres"] = {"$regex": f"^{genre}$", "$options": "i"}
if q: if q:
@@ -175,10 +292,11 @@ async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limi
@api.get("/movies/featured", response_model=Movie) @api.get("/movies/featured", response_model=Movie)
async def get_featured(): async def get_featured(profile: dict = Depends(get_active_profile)):
doc = await db.movies.find_one({"featured": True}, {"_id": 0}) base = _movie_filter_for_profile(profile)
doc = await db.movies.find_one({**base, "featured": True}, {"_id": 0})
if not doc: if not doc:
doc = await db.movies.find_one({}, {"_id": 0}) doc = await db.movies.find_one(base, {"_id": 0})
if not doc: if not doc:
raise HTTPException(status_code=404, detail="No movies") raise HTTPException(status_code=404, detail="No movies")
return doc return doc
@@ -224,19 +342,20 @@ async def delete_movie(movie_id: str, user: dict = Depends(require_admin)):
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0}) movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: if not movie:
raise HTTPException(status_code=404, detail="Movie not found") 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"): if movie.get("storage_type") == "local" and movie.get("storage_path"):
try: try: (VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True)
(VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True) except Exception: pass
except Exception: if movie.get("hls_path"):
pass import shutil
shutil.rmtree(HLS_DIR / movie_id, ignore_errors=True)
await db.movies.delete_one({"id": movie_id}) await db.movies.delete_one({"id": movie_id})
await db.watchlist.delete_many({"movie_id": movie_id}) await db.watchlist.delete_many({"movie_id": movie_id})
await db.progress.delete_many({"movie_id": movie_id}) await db.progress.delete_many({"movie_id": movie_id})
await db.subtitles.delete_many({"movie_id": movie_id})
return {"ok": True} return {"ok": True}
# ---------- Upload (admin) ---------- # ============ UPLOAD VIDEO ============
@api.post("/upload/video", response_model=Movie) @api.post("/upload/video", response_model=Movie)
async def upload_video( async def upload_video(
title: str = Form(...), title: str = Form(...),
@@ -244,12 +363,13 @@ async def upload_video(
year: int = Form(2024), year: int = Form(2024),
duration_minutes: int = Form(0), duration_minutes: int = Form(0),
rating: str = Form("NR"), rating: str = Form("NR"),
genres: str = Form(""), # comma-separated genres: str = Form(""),
cast: str = Form(""), cast: str = Form(""),
director: str = Form(""), director: str = Form(""),
poster_url: str = Form(""), poster_url: str = Form(""),
backdrop_url: str = Form(""), backdrop_url: str = Form(""),
featured: bool = Form(False), featured: bool = Form(False),
tmdb_id: Optional[int] = Form(None),
file: UploadFile = File(...), file: UploadFile = File(...),
user: dict = Depends(require_admin), user: dict = Depends(require_admin),
): ):
@@ -257,62 +377,50 @@ async def upload_video(
safe_id = str(uuid.uuid4()) safe_id = str(uuid.uuid4())
fname = f"{safe_id}.{ext}" fname = f"{safe_id}.{ext}"
target = VIDEOS_DIR / fname target = VIDEOS_DIR / fname
# stream save
with target.open("wb") as f: with target.open("wb") as f:
while True: while True:
chunk = await file.read(CHUNK_SIZE) chunk = await file.read(CHUNK_SIZE)
if not chunk: if not chunk: break
break
f.write(chunk) f.write(chunk)
movie = Movie( movie = Movie(
title=title, title=title, description=description, year=year,
description=description, duration_minutes=duration_minutes, rating=rating,
year=year,
duration_minutes=duration_minutes,
rating=rating,
genres=[g.strip() for g in genres.split(",") if g.strip()], genres=[g.strip() for g in genres.split(",") if g.strip()],
cast=[c.strip() for c in cast.split(",") if c.strip()], cast=[c.strip() for c in cast.split(",") if c.strip()],
director=director, director=director, poster_url=poster_url, backdrop_url=backdrop_url,
poster_url=poster_url, video_url="", storage_type="local", storage_path=fname,
backdrop_url=backdrop_url, featured=featured, tmdb_id=tmdb_id,
video_url="", # filled with stream endpoint client-side
storage_type="local",
storage_path=fname,
featured=featured,
) )
doc = movie.model_dump() doc = movie.model_dump()
await db.movies.insert_one(doc) await db.movies.insert_one(doc)
return _strip(doc) return _strip(doc)
# ---------- Streaming with Range support ---------- # ============ STREAM (Range-aware) ============
@api.get("/stream/{movie_id}") @api.get("/stream/{movie_id}")
async def stream_movie( async def stream_movie(
movie_id: str, movie_id: str, request: Request,
request: Request,
auth: Optional[str] = Query(None), auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None), authorization: Optional[str] = Header(None),
): ):
# token check (header OR ?auth= for <video> tag)
token = None token = None
if authorization and authorization.lower().startswith("bearer "): if authorization and authorization.lower().startswith("bearer "):
token = authorization.split(" ", 1)[1].strip() token = authorization.split(" ", 1)[1].strip()
elif auth: elif auth:
token = auth token = auth
if not token: if not token: raise HTTPException(status_code=401, detail="Not authenticated")
raise HTTPException(status_code=401, detail="Not authenticated") decode_token(token)
decode_token(token) # raises 401 if invalid
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0}) movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: if not movie: raise HTTPException(status_code=404, detail="Movie not found")
raise HTTPException(status_code=404, detail="Movie not found") if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
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") raise HTTPException(status_code=400, detail="Movie has no local file; use video_url directly")
file_path = VIDEOS_DIR / movie["storage_path"] if movie.get("storage_type") == "radarr":
file_path = Path(movie["storage_path"]) # absolute path on Radarr-mounted storage
else:
file_path = VIDEOS_DIR / movie["storage_path"]
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=404, detail="File missing on disk") raise HTTPException(status_code=404, detail="File missing on disk")
@@ -339,106 +447,205 @@ async def stream_movie(
remaining = length remaining = length
while remaining > 0: while remaining > 0:
chunk = f.read(min(CHUNK_SIZE, remaining)) chunk = f.read(min(CHUNK_SIZE, remaining))
if not chunk: if not chunk: break
break
remaining -= len(chunk) remaining -= len(chunk)
yield chunk yield chunk
headers = { return StreamingResponse(iter_file(), status_code=206, media_type=content_type, headers={
"Content-Range": f"bytes {start}-{end}/{file_size}", "Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": str(length), "Content-Length": str(length),
"Content-Type": content_type, "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"}) return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
# ---------- Watchlist ---------- # ============ HLS ============
async def _set_hls_status(movie_id: str, status: str, path: Optional[str] = None, error: Optional[str] = None):
update = {"hls_status": status}
if path is not None: update["hls_path"] = path
await db.movies.update_one({"id": movie_id}, {"$set": update})
if error:
logger.warning(f"HLS {movie_id}: {error}")
async def _run_transcode(movie_id: str, source: Path):
out_dir = HLS_DIR / movie_id
async def cb(status, error=None):
if status == "done":
await _set_hls_status(movie_id, "done", path=f"{movie_id}/playlist.m3u8")
else:
await _set_hls_status(movie_id, status, error=error)
await transcode_to_hls(source, out_dir, cb)
@api.post("/movies/{movie_id}/transcode")
async def trigger_transcode(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")
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
if movie.get("hls_status") == "running":
raise HTTPException(status_code=409, detail="Already transcoding")
source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"]
await _set_hls_status(movie_id, "pending")
asyncio.create_task(_run_transcode(movie_id, source))
return {"ok": True, "status": "pending"}
@api.get("/movies/{movie_id}/hls/{filename:path}")
async def serve_hls(
movie_id: str, filename: str,
auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
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)
target = (HLS_DIR / movie_id / filename).resolve()
base = (HLS_DIR / movie_id).resolve()
if not str(target).startswith(str(base)):
raise HTTPException(status_code=400, detail="Invalid path")
if not target.is_file():
raise HTTPException(status_code=404, detail="HLS file not found")
media_type = "application/vnd.apple.mpegurl" if filename.endswith(".m3u8") else "video/mp2t"
return FileResponse(str(target), media_type=media_type)
# ============ SUBTITLES ============
@api.get("/movies/{movie_id}/subtitles", response_model=List[Subtitle])
async def list_subs(movie_id: str, user: dict = Depends(get_current_user)):
docs = await db.subtitles.find({"movie_id": movie_id}, {"_id": 0}).sort("created_at", 1).to_list(20)
return docs
@api.post("/movies/{movie_id}/subtitles", response_model=Subtitle)
async def upload_sub(
movie_id: str,
language: str = Form("en"),
label: str = Form("English"),
is_default: bool = Form(False),
file: UploadFile = File(...),
user: dict = Depends(require_admin),
):
if not await db.movies.find_one({"id": movie_id}, {"_id": 1}):
raise HTTPException(status_code=404, detail="Movie not found")
raw = await file.read()
text = raw.decode("utf-8", errors="ignore")
is_srt = (file.filename or "").lower().endswith(".srt") or "WEBVTT" not in text[:20]
if is_srt:
text = srt_to_vtt(text)
sid = str(uuid.uuid4())
fname = f"{sid}.vtt"
(SUBS_DIR / fname).write_text(text, encoding="utf-8")
sub = Subtitle(id=sid, movie_id=movie_id, language=language, label=label,
storage_path=fname, is_default=is_default).model_dump()
if is_default:
await db.subtitles.update_many({"movie_id": movie_id}, {"$set": {"is_default": False}})
await db.subtitles.insert_one(sub)
return _strip(sub)
@api.delete("/subtitles/{sub_id}")
async def delete_sub(sub_id: str, user: dict = Depends(require_admin)):
sub = await db.subtitles.find_one({"id": sub_id}, {"_id": 0})
if not sub: raise HTTPException(status_code=404, detail="Subtitle not found")
try: (SUBS_DIR / sub["storage_path"]).unlink(missing_ok=True)
except Exception: pass
await db.subtitles.delete_one({"id": sub_id})
return {"ok": True}
@api.get("/subtitles/{sub_id}/file")
async def serve_sub(
sub_id: str,
auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
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)
sub = await db.subtitles.find_one({"id": sub_id}, {"_id": 0})
if not sub: raise HTTPException(status_code=404, detail="Subtitle not found")
return FileResponse(str(SUBS_DIR / sub["storage_path"]), media_type="text/vtt")
# ============ WATCHLIST ============
@api.get("/watchlist", response_model=List[Movie]) @api.get("/watchlist", response_model=List[Movie])
async def get_watchlist(user: dict = Depends(get_current_user)): async def get_watchlist(profile: dict = Depends(get_active_profile)):
items = await db.watchlist.find({"user_id": user["id"]}, {"_id": 0}).sort("added_at", -1).to_list(500) items = await db.watchlist.find({"profile_id": profile["id"]}, {"_id": 0}).sort("added_at", -1).to_list(500)
movie_ids = [i["movie_id"] for i in items] movie_ids = [i["movie_id"] for i in items]
if not movie_ids: if not movie_ids: return []
return []
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(500) 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} by_id = {m["id"]: m for m in movies}
return [by_id[mid] for mid in movie_ids if mid in by_id] return [by_id[mid] for mid in movie_ids if mid in by_id]
@api.post("/watchlist/{movie_id}") @api.post("/watchlist/{movie_id}")
async def add_watchlist(movie_id: str, user: dict = Depends(get_current_user)): async def add_watchlist(movie_id: str, profile: dict = Depends(get_active_profile)):
if not await db.movies.find_one({"id": movie_id}, {"_id": 1}): if not await db.movies.find_one({"id": movie_id}, {"_id": 1}):
raise HTTPException(status_code=404, detail="Movie not found") raise HTTPException(status_code=404, detail="Movie not found")
item = WatchlistItem(user_id=user["id"], movie_id=movie_id).model_dump() item = WatchlistItem(profile_id=profile["id"], user_id=profile["user_id"], movie_id=movie_id).model_dump()
try: try: await db.watchlist.insert_one(item)
await db.watchlist.insert_one(item) except Exception: pass
except Exception:
pass # duplicate ok
return {"ok": True} return {"ok": True}
@api.delete("/watchlist/{movie_id}") @api.delete("/watchlist/{movie_id}")
async def remove_watchlist(movie_id: str, user: dict = Depends(get_current_user)): async def remove_watchlist(movie_id: str, profile: dict = Depends(get_active_profile)):
await db.watchlist.delete_one({"user_id": user["id"], "movie_id": movie_id}) await db.watchlist.delete_one({"profile_id": profile["id"], "movie_id": movie_id})
return {"ok": True} return {"ok": True}
# ---------- Progress (continue watching) ---------- # ============ PROGRESS ============
@api.post("/progress") @api.post("/progress")
async def upsert_progress(payload: ProgressUpsert, user: dict = Depends(get_current_user)): async def upsert_progress(payload: ProgressUpsert, profile: dict = Depends(get_active_profile)):
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await db.progress.update_one( await db.progress.update_one(
{"user_id": user["id"], "movie_id": payload.movie_id}, {"profile_id": profile["id"], "movie_id": payload.movie_id},
{"$set": { {"$set": {
"user_id": user["id"], "profile_id": profile["id"], "user_id": profile["user_id"],
"movie_id": payload.movie_id, "movie_id": payload.movie_id,
"position_seconds": payload.position_seconds, "position_seconds": payload.position_seconds,
"duration_seconds": payload.duration_seconds, "duration_seconds": payload.duration_seconds,
"updated_at": now, "updated_at": now,
}}, }}, upsert=True,
upsert=True,
) )
return {"ok": True} return {"ok": True}
@api.get("/progress/continue", response_model=List[dict]) @api.get("/progress/continue")
async def continue_watching(user: dict = Depends(get_current_user)): async def continue_watching(profile: dict = Depends(get_active_profile)):
rows = await db.progress.find( rows = await db.progress.find({"profile_id": profile["id"]}, {"_id": 0}).sort("updated_at", -1).to_list(50)
{"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 rows = [r for r in rows if r.get("duration_seconds", 0) == 0 or
r["position_seconds"] / max(r["duration_seconds"], 1) < 0.95] r["position_seconds"] / max(r["duration_seconds"], 1) < 0.95]
if not rows: if not rows: return []
return []
movie_ids = [r["movie_id"] for r in rows] movie_ids = [r["movie_id"] for r in rows]
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(50) movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(50)
by_id = {m["id"]: m for m in movies} by_id = {m["id"]: m for m in movies}
out = [] return [{**by_id[r["movie_id"]], "progress": r} for r in rows if r["movie_id"] in by_id]
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}") @api.get("/progress/{movie_id}")
async def get_progress(movie_id: str, user: dict = Depends(get_current_user)): async def get_progress(movie_id: str, profile: dict = Depends(get_active_profile)):
p = await db.progress.find_one({"user_id": user["id"], "movie_id": movie_id}, {"_id": 0}) p = await db.progress.find_one({"profile_id": profile["id"], "movie_id": movie_id}, {"_id": 0})
return p or {"position_seconds": 0, "duration_seconds": 0} return p or {"position_seconds": 0, "duration_seconds": 0}
# ---------- Movie Requests ---------- # ============ REQUESTS ============
@api.post("/requests", response_model=MovieRequest) @api.post("/requests", response_model=MovieRequest)
async def submit_request(payload: RequestCreate, user: dict = Depends(get_current_user)): async def submit_request(payload: RequestCreate, user: dict = Depends(get_current_user)):
req = MovieRequest( req = MovieRequest(user_id=user["id"], user_name=user.get("name", ""),
user_id=user["id"], user_name=user.get("name", ""), title=payload.title, year=payload.year, notes=payload.notes)
title=payload.title, year=payload.year, notes=payload.notes,
)
doc = req.model_dump() doc = req.model_dump()
await db.requests.insert_one(doc) await db.requests.insert_one(doc)
return _strip(doc) return _strip(doc)
@@ -446,14 +653,12 @@ async def submit_request(payload: RequestCreate, user: dict = Depends(get_curren
@api.get("/requests/mine", response_model=List[MovieRequest]) @api.get("/requests/mine", response_model=List[MovieRequest])
async def my_requests(user: dict = Depends(get_current_user)): 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 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]) @api.get("/requests", response_model=List[MovieRequest])
async def all_requests(user: dict = Depends(require_admin)): async def all_requests(user: dict = Depends(require_admin)):
docs = await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500) return await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500)
return docs
@api.patch("/requests/{request_id}", response_model=MovieRequest) @api.patch("/requests/{request_id}", response_model=MovieRequest)
@@ -462,18 +667,120 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
{"id": request_id}, {"$set": {"status": payload.status}}, {"id": request_id}, {"$set": {"status": payload.status}},
projection={"_id": 0}, return_document=True, projection={"_id": 0}, return_document=True,
) )
if not res: if not res: raise HTTPException(status_code=404, detail="Request not found")
raise HTTPException(status_code=404, detail="Request not found")
return res return res
# ---------- Health ---------- # ============ SETTINGS (admin) ============
@api.get("/settings", response_model=AppSettingsPublic)
async def read_settings(user: dict = Depends(require_admin)):
s = await get_settings()
return AppSettingsPublic(
tmdb_configured=bool(s.get("tmdb_api_key")),
radarr_configured=bool(s.get("radarr_api_key") and s.get("radarr_url")),
tmdb_api_key=s.get("tmdb_api_key", ""),
radarr_url=s.get("radarr_url", ""),
radarr_api_key=s.get("radarr_api_key", ""),
)
@api.put("/settings", response_model=AppSettingsPublic)
async def update_settings(payload: AppSettings, user: dict = Depends(require_admin)):
doc = payload.model_dump()
doc["id"] = "app"
await db.settings.update_one({"id": "app"}, {"$set": doc}, upsert=True)
return AppSettingsPublic(
tmdb_configured=bool(doc.get("tmdb_api_key")),
radarr_configured=bool(doc.get("radarr_api_key") and doc.get("radarr_url")),
tmdb_api_key=doc.get("tmdb_api_key", ""),
radarr_url=doc.get("radarr_url", ""),
radarr_api_key=doc.get("radarr_api_key", ""),
)
# ============ TMDB (admin) ============
@api.get("/tmdb/search", response_model=List[TMDBSearchResult])
async def tmdb_search(q: str, user: dict = Depends(require_admin)):
s = await get_settings()
key = s.get("tmdb_api_key", "")
if not key: raise HTTPException(status_code=400, detail="TMDB API key not configured")
try:
results = await asyncio.to_thread(tmdb_client.search_movies, key, q)
except Exception as e:
raise HTTPException(status_code=502, detail=f"TMDB error: {e}")
return results
@api.get("/tmdb/movie/{tmdb_id}")
async def tmdb_movie(tmdb_id: int, user: dict = Depends(require_admin)):
s = await get_settings()
key = s.get("tmdb_api_key", "")
if not key: raise HTTPException(status_code=400, detail="TMDB API key not configured")
try:
return await asyncio.to_thread(tmdb_client.get_movie, key, tmdb_id)
except Exception as e:
raise HTTPException(status_code=502, detail=f"TMDB error: {e}")
# ============ RADARR (admin) ============
@api.post("/radarr/test")
async def radarr_test(user: dict = Depends(require_admin)):
s = await get_settings()
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
ok = await asyncio.to_thread(radarr_client.test_connection, s["radarr_url"], s["radarr_api_key"])
return {"ok": ok}
@api.get("/radarr/movies", response_model=List[RadarrMovieDTO])
async def radarr_list(user: dict = Depends(require_admin)):
s = await get_settings()
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
return await asyncio.to_thread(radarr_client.list_movies, s["radarr_url"], s["radarr_api_key"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr error: {e}")
@api.post("/radarr/import")
async def radarr_import(payload: dict, user: dict = Depends(require_admin)):
"""Body: {radarr_ids: [int]}"""
radarr_ids = payload.get("radarr_ids") or []
if not radarr_ids:
raise HTTPException(status_code=400, detail="No radarr_ids provided")
s = await get_settings()
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
movies = await asyncio.to_thread(radarr_client.list_movies, s["radarr_url"], s["radarr_api_key"])
by_id = {m["radarr_id"]: m for m in movies}
created = 0
for rid in radarr_ids:
m = by_id.get(int(rid))
if not m or not m.get("has_file") or not m.get("file_path"): continue
# Skip if already imported
if await db.movies.find_one({"radarr_id": m["radarr_id"]}, {"_id": 1}): continue
movie = Movie(
title=m["title"], description=m.get("overview", ""),
year=m.get("year") or 2024, rating="NR",
genres=[], cast=[], director="",
poster_url=m.get("poster_url", ""),
backdrop_url=m.get("poster_url", ""),
video_url="", storage_type="radarr",
storage_path=m["file_path"], # absolute path on Radarr-mounted storage
radarr_id=m["radarr_id"], tmdb_id=m.get("tmdb_id"),
).model_dump()
await db.movies.insert_one(movie)
created += 1
return {"ok": True, "imported": created}
# ============ HEALTH ============
@api.get("/") @api.get("/")
async def root(): async def root():
return {"app": "Kino", "status": "ok"} return {"app": "Kino", "status": "ok"}
# Mount router and CORS
app.include_router(api) app.include_router(api)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+16 -16
View File
@@ -98,8 +98,8 @@ class TestAuth:
# ---------- Movies (public) ---------- # ---------- Movies (public) ----------
class TestMovies: class TestMovies:
def test_list_movies_seeded(self, api): def test_list_movies_seeded(self, api, admin_headers):
r = api.get(f"{BASE_URL}/api/movies") r = api.get(f"{BASE_URL}/api/movies", headers=admin_headers)
assert r.status_code == 200 assert r.status_code == 200
movies = r.json() movies = r.json()
assert isinstance(movies, list) assert isinstance(movies, list)
@@ -111,8 +111,8 @@ class TestMovies:
for f in ("id", "title", "video_url", "storage_type"): for f in ("id", "title", "video_url", "storage_type"):
assert f in m assert f in m
def test_featured(self, api): def test_featured(self, api, admin_headers):
r = api.get(f"{BASE_URL}/api/movies/featured") r = api.get(f"{BASE_URL}/api/movies/featured", headers=admin_headers)
assert r.status_code == 200 assert r.status_code == 200
m = r.json() m = r.json()
assert m["title"] == "Big Buck Bunny" assert m["title"] == "Big Buck Bunny"
@@ -130,15 +130,15 @@ class TestMovies:
r = api.get(f"{BASE_URL}/api/movies/nonexistent-id-xyz") r = api.get(f"{BASE_URL}/api/movies/nonexistent-id-xyz")
assert r.status_code == 404 assert r.status_code == 404
def test_get_movie_by_id(self, api): def test_get_movie_by_id(self, api, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies").json() movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[0]["id"] mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/movies/{mid}") r = api.get(f"{BASE_URL}/api/movies/{mid}")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["id"] == mid assert r.json()["id"] == mid
def test_search_query(self, api): def test_search_query(self, api, admin_headers):
r = api.get(f"{BASE_URL}/api/movies", params={"q": "bunny"}) r = api.get(f"{BASE_URL}/api/movies", params={"q": "bunny"}, headers=admin_headers)
assert r.status_code == 200 assert r.status_code == 200
results = r.json() results = r.json()
assert any("bunny" in m["title"].lower() for m in results) assert any("bunny" in m["title"].lower() for m in results)
@@ -191,8 +191,8 @@ class TestMoviesAdmin:
# ---------- Watchlist ---------- # ---------- Watchlist ----------
class TestWatchlist: class TestWatchlist:
def test_watchlist_flow(self, api, member_headers): def test_watchlist_flow(self, api, member_headers, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies").json() movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[0]["id"] mid = movies[0]["id"]
r = api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers) r = api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=member_headers)
@@ -220,8 +220,8 @@ class TestWatchlist:
# ---------- Progress ---------- # ---------- Progress ----------
class TestProgress: class TestProgress:
def test_progress_upsert_and_continue(self, api, member_headers): def test_progress_upsert_and_continue(self, api, member_headers, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies").json() movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[1]["id"] mid = movies[1]["id"]
r = api.post(f"{BASE_URL}/api/progress", r = api.post(f"{BASE_URL}/api/progress",
json={"movie_id": mid, "position_seconds": 30, "duration_seconds": 600}, json={"movie_id": mid, "position_seconds": 30, "duration_seconds": 600},
@@ -276,15 +276,15 @@ class TestRequests:
# ---------- Streaming ---------- # ---------- Streaming ----------
class TestStream: class TestStream:
def test_stream_no_token(self, api): def test_stream_no_token(self, api, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies").json() movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[0]["id"] mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/stream/{mid}", allow_redirects=False) r = api.get(f"{BASE_URL}/api/stream/{mid}", allow_redirects=False)
assert r.status_code == 401 assert r.status_code == 401
def test_stream_external_returns_400(self, api, admin_token): def test_stream_external_returns_400(self, api, admin_token, admin_headers):
# All seeded movies are storage_type=external -> should 400 # All seeded movies are storage_type=external -> should 400
movies = api.get(f"{BASE_URL}/api/movies").json() movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[0]["id"] mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/stream/{mid}", params={"auth": admin_token}, allow_redirects=False) r = api.get(f"{BASE_URL}/api/stream/{mid}", params={"auth": admin_token}, allow_redirects=False)
assert r.status_code == 400 assert r.status_code == 400
+334
View File
@@ -0,0 +1,334 @@
"""
Phase 2 backend tests for Kino: profiles, parental control, settings,
TMDB/Radarr unconfigured paths, subtitles, HLS endpoint contract.
"""
import os
import io
import uuid
import pytest
import requests
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "").rstrip("/")
ADMIN_EMAIL = "admin@kino.local"
ADMIN_PASSWORD = "kino-admin-2026"
# ---------- Fixtures ----------
@pytest.fixture(scope="module")
def api():
s = requests.Session()
return s
@pytest.fixture(scope="module")
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
return r.json()["access_token"]
@pytest.fixture(scope="module")
def admin_headers(admin_token):
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture(scope="module")
def member(api):
"""Create a fresh member for profile-scoped tests."""
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@kino.local"
r = api.post(f"{BASE_URL}/api/auth/register",
json={"email": email, "password": "pass1234", "name": "P2 User"})
assert r.status_code == 200
d = r.json()
return d["access_token"], d["user"]["id"]
@pytest.fixture(scope="module")
def member_headers(member):
tok, _ = member
return {"Authorization": f"Bearer {tok}"}
# ---------- PROFILES ----------
class TestProfiles:
def test_default_profile_auto_created(self, api, member_headers):
r = api.get(f"{BASE_URL}/api/profiles", headers=member_headers)
assert r.status_code == 200
profiles = r.json()
assert isinstance(profiles, list)
assert len(profiles) >= 1
assert profiles[0]["name"] == "P2 User"
assert profiles[0]["max_rating"] == "NR"
assert "id" in profiles[0]
def test_create_profile(self, api, member_headers):
r = api.post(f"{BASE_URL}/api/profiles",
json={"name": "TEST_Kid", "is_kids": True,
"max_rating": "PG", "avatar_color": "#22aa55"},
headers=member_headers)
assert r.status_code == 200, r.text
p = r.json()
assert p["name"] == "TEST_Kid"
assert p["is_kids"] is True
assert p["max_rating"] == "PG"
# persistence
listing = api.get(f"{BASE_URL}/api/profiles", headers=member_headers).json()
assert any(x["id"] == p["id"] for x in listing)
def test_update_profile(self, api, member_headers):
# create then patch
c = api.post(f"{BASE_URL}/api/profiles",
json={"name": "TEST_PatchMe"}, headers=member_headers).json()
r = api.patch(f"{BASE_URL}/api/profiles/{c['id']}",
json={"name": "TEST_Patched", "max_rating": "PG-13"},
headers=member_headers)
assert r.status_code == 200
assert r.json()["name"] == "TEST_Patched"
assert r.json()["max_rating"] == "PG-13"
def test_max_5_profiles(self, api, member_headers):
# current count
existing = api.get(f"{BASE_URL}/api/profiles", headers=member_headers).json()
# create up to 5
for i in range(max(0, 5 - len(existing))):
api.post(f"{BASE_URL}/api/profiles",
json={"name": f"TEST_P{i}"}, headers=member_headers)
# 6th must fail
r = api.post(f"{BASE_URL}/api/profiles",
json={"name": "TEST_Overflow"}, headers=member_headers)
assert r.status_code == 400
def test_cannot_delete_last_profile(self, api):
# fresh user with single profile
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@kino.local"
d = api.post(f"{BASE_URL}/api/auth/register",
json={"email": email, "password": "pass1234", "name": "Solo"}).json()
h = {"Authorization": f"Bearer {d['access_token']}"}
plist = api.get(f"{BASE_URL}/api/profiles", headers=h).json()
assert len(plist) == 1
r = api.delete(f"{BASE_URL}/api/profiles/{plist[0]['id']}", headers=h)
assert r.status_code == 400
def test_delete_profile_cleans_watchlist(self, api, member_headers):
# ensure room (we may be at 5-profile cap from prior test)
existing = api.get(f"{BASE_URL}/api/profiles", headers=member_headers).json()
# delete any TEST_P* profiles to free a slot
for x in existing:
if x["name"].startswith("TEST_P") and len(existing) > 1:
api.delete(f"{BASE_URL}/api/profiles/{x['id']}", headers=member_headers)
existing = api.get(f"{BASE_URL}/api/profiles", headers=member_headers).json()
if len(existing) < 5:
break
p = api.post(f"{BASE_URL}/api/profiles",
json={"name": "TEST_Del"}, headers=member_headers).json()
assert "id" in p, f"profile create failed: {p}"
movies = api.get(f"{BASE_URL}/api/movies", headers=member_headers).json()
mid = movies[0]["id"]
h = {**member_headers, "X-Profile-Id": p["id"]}
api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=h)
wl = api.get(f"{BASE_URL}/api/watchlist", headers=h).json()
assert any(x["id"] == mid for x in wl)
r = api.delete(f"{BASE_URL}/api/profiles/{p['id']}", headers=member_headers)
assert r.status_code == 200
# ---------- Parental control ----------
class TestParentalControl:
def _kid_profile(self, api, headers):
# find or create a PG kid profile
plist = api.get(f"{BASE_URL}/api/profiles", headers=headers).json()
for p in plist:
if p.get("max_rating") == "PG":
return p
return api.post(f"{BASE_URL}/api/profiles",
json={"name": "TEST_KidsRC", "is_kids": True,
"max_rating": "PG"}, headers=headers).json()
def test_movies_filtered_by_kids_profile(self, api, member_headers):
kid = self._kid_profile(api, member_headers)
h = {**member_headers, "X-Profile-Id": kid["id"]}
r = api.get(f"{BASE_URL}/api/movies", headers=h)
assert r.status_code == 200
movies = r.json()
# only G/PG/NR allowed
for m in movies:
assert m["rating"] in ("G", "PG", "NR"), f"Disallowed rating: {m['rating']}"
def test_featured_respects_cap(self, api, member_headers):
kid = self._kid_profile(api, member_headers)
h = {**member_headers, "X-Profile-Id": kid["id"]}
r = api.get(f"{BASE_URL}/api/movies/featured", headers=h)
# may return 200 or 404 if no featured G/PG/NR exists; both acceptable
assert r.status_code in (200, 404)
if r.status_code == 200:
assert r.json()["rating"] in ("G", "PG", "NR")
def test_watchlist_profile_scoped(self, api, member_headers):
# Use default (unrestricted) profile to add, kid profile should not see
plist = api.get(f"{BASE_URL}/api/profiles", headers=member_headers).json()
default = next((p for p in plist if p["max_rating"] == "NR"), plist[0])
kid = self._kid_profile(api, member_headers)
movies = api.get(f"{BASE_URL}/api/movies", headers=member_headers).json()
# find an R-rated or other distinct movie
target = next((m for m in movies if m["rating"] not in ("G", "PG", "NR")), movies[0])
mid = target["id"]
h_def = {**member_headers, "X-Profile-Id": default["id"]}
api.post(f"{BASE_URL}/api/watchlist/{mid}", headers=h_def)
wl_def = api.get(f"{BASE_URL}/api/watchlist", headers=h_def).json()
assert any(m["id"] == mid for m in wl_def)
h_kid = {**member_headers, "X-Profile-Id": kid["id"]}
wl_kid = api.get(f"{BASE_URL}/api/watchlist", headers=h_kid).json()
assert all(m["id"] != mid for m in wl_kid)
# ---------- Settings (admin) ----------
class TestSettings:
def test_get_settings(self, api, admin_headers):
r = api.get(f"{BASE_URL}/api/settings", headers=admin_headers)
assert r.status_code == 200
s = r.json()
assert "tmdb_configured" in s and "radarr_configured" in s
assert isinstance(s["tmdb_configured"], bool)
def test_settings_member_forbidden(self, api, member_headers):
r = api.get(f"{BASE_URL}/api/settings", headers=member_headers)
assert r.status_code == 403
def test_put_settings_persists(self, api, admin_headers):
# snapshot current state
before = api.get(f"{BASE_URL}/api/settings", headers=admin_headers).json()
# set empty -> tmdb_configured False
r = api.put(f"{BASE_URL}/api/settings",
json={"tmdb_api_key": "", "radarr_url": "", "radarr_api_key": ""},
headers=admin_headers)
assert r.status_code == 200
assert r.json()["tmdb_configured"] is False
assert r.json()["radarr_configured"] is False
# set a fake TMDB key (won't be hit) — verify configured flips true
r2 = api.put(f"{BASE_URL}/api/settings",
json={"tmdb_api_key": "TEST_FAKE_KEY", "radarr_url": "",
"radarr_api_key": ""}, headers=admin_headers)
assert r2.status_code == 200
assert r2.json()["tmdb_configured"] is True
# restore to empty (avoid hitting TMDB in other tests)
api.put(f"{BASE_URL}/api/settings",
json={"tmdb_api_key": before.get("tmdb_api_key", ""),
"radarr_url": before.get("radarr_url", ""),
"radarr_api_key": before.get("radarr_api_key", "")},
headers=admin_headers)
# ---------- TMDB / Radarr unconfigured ----------
class TestExternalUnconfigured:
def test_tmdb_search_no_key(self, api, admin_headers):
# ensure unconfigured
api.put(f"{BASE_URL}/api/settings",
json={"tmdb_api_key": "", "radarr_url": "", "radarr_api_key": ""},
headers=admin_headers)
r = api.get(f"{BASE_URL}/api/tmdb/search",
params={"q": "matrix"}, headers=admin_headers)
assert r.status_code == 400
def test_radarr_test_unconfigured(self, api, admin_headers):
r = api.post(f"{BASE_URL}/api/radarr/test", headers=admin_headers)
assert r.status_code == 400
def test_radarr_movies_unconfigured(self, api, admin_headers):
r = api.get(f"{BASE_URL}/api/radarr/movies", headers=admin_headers)
assert r.status_code == 400
# ---------- HLS / Transcode contract ----------
class TestHLS:
def test_transcode_external_400(self, api, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
ext = next(m for m in movies if m["storage_type"] == "external")
r = api.post(f"{BASE_URL}/api/movies/{ext['id']}/transcode",
headers=admin_headers)
assert r.status_code == 400
def test_hls_serve_404_no_files(self, api, admin_token, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/movies/{mid}/hls/playlist.m3u8",
params={"auth": admin_token})
assert r.status_code == 404
def test_hls_serve_unauth(self, api, admin_headers):
movies = api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()
mid = movies[0]["id"]
r = api.get(f"{BASE_URL}/api/movies/{mid}/hls/playlist.m3u8")
assert r.status_code == 401
# ---------- Subtitles ----------
class TestSubtitles:
@pytest.fixture(scope="class")
def movie_id(self, api, admin_headers):
return api.get(f"{BASE_URL}/api/movies", headers=admin_headers).json()[0]["id"]
def test_upload_vtt(self, api, admin_headers, admin_token, movie_id):
vtt = b"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello"
files = {"file": ("test.vtt", io.BytesIO(vtt), "text/vtt")}
data = {"language": "en", "label": "TEST_English", "is_default": "false"}
r = requests.post(f"{BASE_URL}/api/movies/{movie_id}/subtitles",
headers=admin_headers, files=files, data=data)
assert r.status_code == 200, r.text
sub = r.json()
sid = sub["id"]
assert sub["movie_id"] == movie_id
assert sub["language"] == "en"
# list
ls = api.get(f"{BASE_URL}/api/movies/{movie_id}/subtitles",
headers=admin_headers)
assert ls.status_code == 200
assert any(x["id"] == sid for x in ls.json())
# serve via header auth
f = requests.get(f"{BASE_URL}/api/subtitles/{sid}/file",
headers=admin_headers)
assert f.status_code == 200
assert "text/vtt" in f.headers.get("content-type", "")
assert b"WEBVTT" in f.content
# serve via query auth
f2 = requests.get(f"{BASE_URL}/api/subtitles/{sid}/file",
params={"auth": admin_token})
assert f2.status_code == 200
# serve without auth
f3 = requests.get(f"{BASE_URL}/api/subtitles/{sid}/file")
assert f3.status_code == 401
# delete
d = requests.delete(f"{BASE_URL}/api/subtitles/{sid}",
headers=admin_headers)
assert d.status_code == 200
# verify gone
ls2 = api.get(f"{BASE_URL}/api/movies/{movie_id}/subtitles",
headers=admin_headers).json()
assert all(x["id"] != sid for x in ls2)
def test_upload_srt_converts(self, api, admin_headers, movie_id):
srt = b"1\n00:00:01,000 --> 00:00:02,000\nHi from SRT\n"
files = {"file": ("test.srt", io.BytesIO(srt), "text/plain")}
data = {"language": "fr", "label": "TEST_French"}
r = requests.post(f"{BASE_URL}/api/movies/{movie_id}/subtitles",
headers=admin_headers, files=files, data=data)
assert r.status_code == 200
sid = r.json()["id"]
f = requests.get(f"{BASE_URL}/api/subtitles/{sid}/file",
headers=admin_headers)
assert f.status_code == 200
assert b"WEBVTT" in f.content # converted
# cleanup
requests.delete(f"{BASE_URL}/api/subtitles/{sid}", headers=admin_headers)
def test_upload_subtitle_member_forbidden(self, api, member_headers, movie_id):
files = {"file": ("x.vtt", b"WEBVTT\n\n", "text/vtt")}
r = requests.post(f"{BASE_URL}/api/movies/{movie_id}/subtitles",
headers=member_headers, files=files,
data={"language": "en"})
assert r.status_code == 403
+88
View File
@@ -0,0 +1,88 @@
"""TMDB API client. Uses v3 API with api_key query param."""
import requests
from typing import List, Dict, Optional
BASE = "https://api.themoviedb.org/3"
IMG = "https://image.tmdb.org/t/p"
def _img(path: Optional[str], size: str = "w500") -> str:
if not path:
return ""
return f"{IMG}/{size}{path}"
def search_movies(api_key: str, query: str) -> List[Dict]:
if not api_key or not query.strip():
return []
r = requests.get(f"{BASE}/search/movie", params={"api_key": api_key, "query": query}, timeout=15)
r.raise_for_status()
data = r.json().get("results", []) or []
out = []
for m in data[:20]:
year = None
if m.get("release_date"):
try: year = int(m["release_date"].split("-")[0])
except Exception: pass
out.append({
"tmdb_id": m["id"],
"title": m.get("title") or m.get("name") or "",
"year": year,
"overview": m.get("overview") or "",
"poster_url": _img(m.get("poster_path"), "w500"),
"backdrop_url": _img(m.get("backdrop_path"), "original"),
})
return out
def get_movie(api_key: str, tmdb_id: int) -> Dict:
"""Return rich movie details with cast/crew."""
r = requests.get(
f"{BASE}/movie/{tmdb_id}",
params={"api_key": api_key, "append_to_response": "credits"},
timeout=15,
)
r.raise_for_status()
m = r.json()
year = None
if m.get("release_date"):
try: year = int(m["release_date"].split("-")[0])
except Exception: pass
credits = m.get("credits", {}) or {}
cast = [c["name"] for c in (credits.get("cast") or [])[:8] if c.get("name")]
crew = credits.get("crew") or []
director = next((c["name"] for c in crew if c.get("job") == "Director"), "")
genres = [g["name"] for g in (m.get("genres") or []) if g.get("name")]
# Map TMDB certifications to our rating scale (best effort, US fallback)
rating = "NR"
try:
rel = requests.get(
f"{BASE}/movie/{tmdb_id}/release_dates",
params={"api_key": api_key}, timeout=10,
).json()
for r_country in rel.get("results", []):
if r_country.get("iso_3166_1") == "US":
for d in r_country.get("release_dates", []) or []:
cert = (d.get("certification") or "").strip()
if cert:
rating = cert
break
break
except Exception:
pass
return {
"tmdb_id": m["id"],
"title": m.get("title") or "",
"year": year,
"description": m.get("overview") or "",
"duration_minutes": m.get("runtime") or 0,
"rating": rating or "NR",
"genres": genres,
"cast": cast,
"director": director,
"poster_url": _img(m.get("poster_path"), "w500"),
"backdrop_url": _img(m.get("backdrop_path"), "original"),
}
+74
View File
@@ -0,0 +1,74 @@
"""HLS transcoding via ffmpeg. Background-runs and updates DB."""
import asyncio
import logging
import shutil
from pathlib import Path
from typing import Optional
logger = logging.getLogger("kino.transcode")
async def transcode_to_hls(
source: Path,
out_dir: Path,
on_status,
):
"""
Run ffmpeg to produce HLS playlist + segments.
`on_status(status, error=None)` is awaited at start/end with status one of
'running'|'done'|'failed'.
Uses stream-copy where possible (fast, no re-encode).
"""
if not source.is_file():
await on_status("failed", error=f"Source missing: {source}")
return
out_dir.mkdir(parents=True, exist_ok=True)
playlist = out_dir / "playlist.m3u8"
cmd = [
"ffmpeg", "-y",
"-i", str(source),
"-c:v", "copy",
"-c:a", "copy",
"-bsf:v", "h264_mp4toannexb",
"-f", "hls",
"-hls_time", "6",
"-hls_list_size", "0",
"-hls_playlist_type", "vod",
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(playlist),
]
await on_status("running")
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err = stderr.decode("utf-8", errors="ignore")[-500:]
logger.error(f"ffmpeg failed: {err}")
await on_status("failed", error=err[:200])
# cleanup partial files
shutil.rmtree(out_dir, ignore_errors=True)
return
await on_status("done")
except FileNotFoundError:
await on_status("failed", error="ffmpeg not installed")
except Exception as e:
logger.exception("transcode crashed")
await on_status("failed", error=str(e))
shutil.rmtree(out_dir, ignore_errors=True)
def srt_to_vtt(srt_text: str) -> str:
"""Convert SRT subtitles to WebVTT format (simple, no styling)."""
lines = srt_text.replace("\r\n", "\n").split("\n")
out = ["WEBVTT", ""]
for line in lines:
# Replace SRT timestamp commas with VTT dots
if "-->" in line:
out.append(line.replace(",", "."))
else:
out.append(line)
return "\n".join(out)
+1
View File
@@ -38,6 +38,7 @@
"cra-template": "1.2.0", "cra-template": "1.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"hls.js": "^1.6.16",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.507.0", "lucide-react": "^0.507.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+55 -22
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { AuthProvider, useAuth } from "./lib/auth"; import { AuthProvider, useAuth } from "./lib/auth";
import { ProfileProvider, useProfile } from "./lib/profile";
import ProtectedRoute from "./components/ProtectedRoute"; import ProtectedRoute from "./components/ProtectedRoute";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import GrainOverlay from "./components/GrainOverlay"; import GrainOverlay from "./components/GrainOverlay";
@@ -13,14 +14,38 @@ import Player from "./pages/Player";
import Requests from "./pages/Requests"; import Requests from "./pages/Requests";
import Admin from "./pages/Admin"; import Admin from "./pages/Admin";
import AdminUpload from "./pages/AdminUpload"; import AdminUpload from "./pages/AdminUpload";
import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport";
import ProfileSelect from "./pages/ProfileSelect";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
const { active, loading: profLoading } = useProfile();
const loc = useLocation();
if (authLoading || profLoading) {
return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
}
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
if (!active) return <Navigate to="/profile" replace />;
return children;
};
const AdminGate = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
if (!user.is_admin) return <Navigate to="/browse" replace />;
return children;
};
const Shell = ({ children }) => { const Shell = ({ children }) => {
const { user } = useAuth(); const { user } = useAuth();
const { active } = useProfile();
const loc = useLocation(); const loc = useLocation();
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register"; const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile";
return ( return (
<> <>
{!hideChrome && user && <Navbar />} {!hideChrome && user && active && <Navbar />}
{children} {children}
<GrainOverlay /> <GrainOverlay />
</> </>
@@ -29,8 +54,11 @@ const Shell = ({ children }) => {
const RootRedirect = () => { const RootRedirect = () => {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>; const { active, loading: pl } = useProfile();
return <Navigate to={user ? "/browse" : "/login"} replace />; if (loading || pl) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
if (!active) return <Navigate to="/profile" replace />;
return <Navigate to="/browse" replace />;
}; };
function App() { function App() {
@@ -38,24 +66,29 @@ function App() {
<div className="App min-h-screen bg-[#050505]"> <div className="App min-h-screen bg-[#050505]">
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Shell> <ProfileProvider>
<Routes> <Shell>
<Route path="/" element={<RootRedirect />} /> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/register" element={<Register />} /> <Route path="/login" element={<Login />} />
<Route path="/browse" element={<ProtectedRoute><Browse /></ProtectedRoute>} /> <Route path="/register" element={<Register />} />
<Route path="/my-list" element={<ProtectedRoute><MyList /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
<Route path="/search" element={<ProtectedRoute><Search /></ProtectedRoute>} /> <Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
<Route path="/watch/:id" element={<ProtectedRoute><Player /></ProtectedRoute>} /> <Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
<Route path="/requests" element={<ProtectedRoute><Requests /></ProtectedRoute>} /> <Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
<Route path="/admin" element={<ProtectedRoute adminOnly><Admin /></ProtectedRoute>} /> <Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
<Route path="/admin/upload" element={<ProtectedRoute adminOnly><AdminUpload /></ProtectedRoute>} /> <Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
</Routes> <Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
</Shell> <Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Toaster position="bottom-right" theme="dark" toastOptions={{ <Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" }, <Route path="*" element={<Navigate to="/" replace />} />
}} /> </Routes>
</Shell>
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
</ProfileProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</div> </div>
+37 -51
View File
@@ -1,10 +1,12 @@
import { Link, NavLink, useNavigate } from "react-router-dom"; import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import { Search, LogOut, Upload, ListVideo } from "lucide-react"; import { useProfile } from "../lib/profile";
import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export const Navbar = () => { export const Navbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { active, clearActive } = useProfile();
const nav = useNavigate(); const nav = useNavigate();
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
@@ -15,23 +17,19 @@ export const Navbar = () => {
}, []); }, []);
const linkClass = ({ isActive }) => const linkClass = ({ isActive }) =>
`text-sm tracking-wide transition-colors duration-300 ${ `text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"
}`; const switchProfile = () => {
clearActive();
nav("/profile");
};
return ( return (
<header <header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
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="px-6 md:px-12 py-4 flex items-center justify-between">
<div className="flex items-center gap-10"> <div className="flex items-center gap-10">
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo"> <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"> <span className="font-display text-2xl font-black tracking-tighter text-white">Kino</span>
Kino
</span>
<span className="text-[#D9381E] text-2xl leading-none">.</span> <span className="text-[#D9381E] text-2xl leading-none">.</span>
</Link> </Link>
{user && ( {user && (
@@ -39,60 +37,48 @@ export const Navbar = () => {
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink> <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="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</NavLink> <NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</NavLink>
{user.is_admin && ( {user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
<NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>
)}
</nav> </nav>
)} )}
</div> </div>
{user ? ( {user ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button onClick={() => nav("/search")} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-search-button" aria-label="Search">
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} /> <Search size={18} strokeWidth={1.5} />
</button> </button>
{user.is_admin && ( {user.is_admin && (
<button <>
onClick={() => nav("/admin/upload")} <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">
className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" <Upload size={14} strokeWidth={1.5} /> Upload
data-testid="nav-upload-button" </button>
> <button onClick={() => nav("/admin/settings")} 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-settings-button">
<Upload size={14} strokeWidth={1.5} /> Upload <SettingsIcon size={14} strokeWidth={1.5} /> Settings
</button> </button>
</>
)} )}
<div className="flex items-center gap-3 pl-4 border-l border-[#222]"> <div className="flex items-center gap-3 pl-4 border-l border-[#222]">
<div className="hidden sm:flex flex-col items-end leading-tight"> <button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
<span className="text-xs text-white" data-testid="nav-user-name">{user.name}</span> {active && (
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]"> <>
{user.is_admin ? "Admin" : "Member"} <div className="hidden sm:flex flex-col items-end leading-tight">
</span> <span className="text-xs text-white">{active.name}</span>
</div> <span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
<div className="w-8 h-8 bg-[#D9381E] flex items-center justify-center text-white text-xs font-medium"> </div>
{user.name?.[0]?.toUpperCase() || "U"} <div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
</div> {active.name?.[0]?.toUpperCase() || "U"}
<button </div>
onClick={() => { logout(); nav("/login"); }} </>
className="text-[#8A8A8A] hover:text-white transition-colors duration-300" )}
data-testid="nav-logout-button" {!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
aria-label="Logout" </button>
> <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} /> <LogOut size={16} strokeWidth={1.5} />
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<Link <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>
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> </div>
</header> </header>
+19 -13
View File
@@ -8,26 +8,32 @@ const instance = axios.create({ baseURL: API });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
const token = localStorage.getItem("kino_token"); const token = localStorage.getItem("kino_token");
if (token) config.headers.Authorization = `Bearer ${token}`; if (token) config.headers.Authorization = `Bearer ${token}`;
// Active profile id (set by ProfileProvider via localStorage)
const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null");
if (userId) {
const pid = localStorage.getItem(`kino_profile_${userId}`);
if (pid) config.headers["X-Profile-Id"] = pid;
}
return config; 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 default instance;
export const getStreamUrl = (movie) => { export const getStreamUrl = (movie) => {
if (!movie) return ""; if (!movie) return "";
if (movie.storage_type === "local") { const token = localStorage.getItem("kino_token") || "";
const token = localStorage.getItem("kino_token"); if (movie.hls_status === "done" && movie.hls_path) {
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token || "")}`; return `${API}/movies/${movie.id}/hls/playlist.m3u8?auth=${encodeURIComponent(token)}`;
}
if (movie.storage_type === "local" || movie.storage_type === "radarr") {
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
} }
return movie.video_url; return movie.video_url;
}; };
export const isHls = (url) => url.includes(".m3u8");
export const getSubtitleUrl = (sub) => {
const token = localStorage.getItem("kino_token") || "";
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(token)}`;
};
+6 -5
View File
@@ -9,16 +9,14 @@ export const AuthProvider = ({ children }) => {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const token = localStorage.getItem("kino_token"); const token = localStorage.getItem("kino_token");
if (!token) { if (!token) { setUser(null); setLoading(false); return; }
setUser(null);
setLoading(false);
return;
}
try { try {
const { data } = await api.get("/auth/me"); const { data } = await api.get("/auth/me");
setUser(data); setUser(data);
localStorage.setItem("kino_user_id", JSON.stringify(data.id));
} catch { } catch {
localStorage.removeItem("kino_token"); localStorage.removeItem("kino_token");
localStorage.removeItem("kino_user_id");
setUser(null); setUser(null);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -30,6 +28,7 @@ export const AuthProvider = ({ children }) => {
const login = async (email, password) => { const login = async (email, password) => {
const { data } = await api.post("/auth/login", { email, password }); const { data } = await api.post("/auth/login", { email, password });
localStorage.setItem("kino_token", data.access_token); localStorage.setItem("kino_token", data.access_token);
localStorage.setItem("kino_user_id", JSON.stringify(data.user.id));
setUser(data.user); setUser(data.user);
return data.user; return data.user;
}; };
@@ -37,12 +36,14 @@ export const AuthProvider = ({ children }) => {
const register = async (email, password, name) => { const register = async (email, password, name) => {
const { data } = await api.post("/auth/register", { email, password, name }); const { data } = await api.post("/auth/register", { email, password, name });
localStorage.setItem("kino_token", data.access_token); localStorage.setItem("kino_token", data.access_token);
localStorage.setItem("kino_user_id", JSON.stringify(data.user.id));
setUser(data.user); setUser(data.user);
return data.user; return data.user;
}; };
const logout = () => { const logout = () => {
localStorage.removeItem("kino_token"); localStorage.removeItem("kino_token");
localStorage.removeItem("kino_user_id");
setUser(null); setUser(null);
}; };
+48
View File
@@ -0,0 +1,48 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import api from "./api";
import { useAuth } from "./auth";
const ProfileCtx = createContext(null);
export const ProfileProvider = ({ children }) => {
const { user } = useAuth();
const [profiles, setProfiles] = useState([]);
const [active, setActive] = useState(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
if (!user) { setProfiles([]); setActive(null); return; }
setLoading(true);
try {
const { data } = await api.get("/profiles");
setProfiles(data);
const stored = localStorage.getItem(`kino_profile_${user.id}`);
const found = data.find((p) => p.id === stored);
setActive(found || null);
} finally {
setLoading(false);
}
}, [user]);
useEffect(() => { refresh(); }, [refresh]);
const switchTo = (p) => {
if (!user) return;
localStorage.setItem(`kino_profile_${user.id}`, p.id);
setActive(p);
};
const clearActive = () => {
if (!user) return;
localStorage.removeItem(`kino_profile_${user.id}`);
setActive(null);
};
return (
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
{children}
</ProfileCtx.Provider>
);
};
export const useProfile = () => useContext(ProfileCtx);
+66 -27
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import api from "../lib/api"; import api from "../lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import { Trash2, Star } from "lucide-react"; import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
export default function Admin() { export default function Admin() {
const nav = useNavigate();
const [movies, setMovies] = useState([]); const [movies, setMovies] = useState([]);
const [transcoding, setTranscoding] = useState({});
const load = async () => { const load = async () => {
const { data } = await api.get("/movies"); const { data } = await api.get("/movies");
@@ -12,6 +15,14 @@ export default function Admin() {
}; };
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
// Poll for transcoding status
useEffect(() => {
const inProgress = movies.some((m) => m.hls_status === "running" || m.hls_status === "pending");
if (!inProgress) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [movies]);
const remove = async (id) => { const remove = async (id) => {
if (!window.confirm("Remove this movie permanently?")) return; if (!window.confirm("Remove this movie permanently?")) return;
await api.delete(`/movies/${id}`); await api.delete(`/movies/${id}`);
@@ -21,52 +32,80 @@ export default function Admin() {
const toggleFeatured = async (m) => { const toggleFeatured = async (m) => {
await api.patch(`/movies/${m.id}`, { featured: !m.featured }); 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(); load();
}; };
const startTranscode = async (m) => {
setTranscoding({ ...transcoding, [m.id]: true });
try {
await api.post(`/movies/${m.id}/transcode`);
toast.success("Transcoding started — refresh in a few minutes");
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not start transcode");
} finally {
setTranscoding({ ...transcoding, [m.id]: false });
}
};
const hlsStatusBadge = (m) => {
if (!m.hls_status) return null;
const colors = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
};
return <span className={`text-[9px] uppercase tracking-[0.2em] ${colors[m.hls_status] || "text-[#8A8A8A]"}`}>HLS {m.hls_status}</span>;
};
return ( return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-page"> <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"> <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> <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"> <h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Admin</h1>
Admin <p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
</h1>
<p className="text-[#8A8A8A] mt-4">
Manage your library and review pending requests.
</p>
<div className="mt-10 flex gap-3"> <div className="mt-10 flex flex-wrap 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"> <Link to="/admin/upload" className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="admin-upload-link">
Upload New <Film size={14} strokeWidth={1.5} /> Upload Movie
</a> </Link>
<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"> <Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</Link>
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
Review Requests Review Requests
</a> </Link>
</div> </div>
<div className="mt-12 border border-[#222]"> <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]"> <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-4">Title</span>
<span className="col-span-2">Year</span> <span className="col-span-1">Year</span>
<span className="col-span-2">Rating</span> <span className="col-span-1">Rating</span>
<span className="col-span-2">Source</span> <span className="col-span-2">Source</span>
<span className="col-span-1 text-right">Actions</span> <span className="col-span-2">HLS</span>
<span className="col-span-2 text-right">Actions</span>
</div> </div>
{movies.map((m) => ( {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" <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}`}>
data-testid={`admin-movie-row-${m.id}`}> <div className="col-span-4 flex items-center gap-3 min-w-0">
<div className="col-span-5 flex items-center gap-3">
<img src={m.poster_url} alt="" className="w-10 h-14 object-cover" /> <img src={m.poster_url} alt="" className="w-10 h-14 object-cover" />
<span className="text-white truncate">{m.title}</span> <span className="text-white truncate">{m.title}</span>
</div> </div>
<span className="col-span-2 text-[#8A8A8A] text-sm">{m.year}</span> <span className="col-span-1 text-[#8A8A8A] text-sm">{m.year}</span>
<span className="col-span-2 text-[#8A8A8A] text-sm">{m.rating}</span> <span className="col-span-1 text-[#8A8A8A] text-sm">{m.rating}</span>
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</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"> <span className="col-span-2">{hlsStatusBadge(m)}</span>
<div className="col-span-2 flex justify-end gap-2">
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "done" && m.hls_status !== "running" && (
<button onClick={() => startTranscode(m)} disabled={transcoding[m.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 disabled:opacity-50"
data-testid={`transcode-${m.id}`}>HLS</button>
)}
<button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}> <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"} /> <Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
</button> </button>
+182 -38
View File
@@ -1,64 +1,175 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import api from "../lib/api"; import api from "../lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { UploadCloud } from "lucide-react"; import { UploadCloud, Search, Subtitles, Trash2 } from "lucide-react";
export default function AdminUpload() { export default function AdminUpload() {
const nav = useNavigate(); const nav = useNavigate();
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [tmdbConfigured, setTmdbConfigured] = useState(false);
const [tmdbQuery, setTmdbQuery] = useState("");
const [tmdbResults, setTmdbResults] = useState([]);
const [searchingTmdb, setSearchingTmdb] = useState(false);
const [createdMovieId, setCreatedMovieId] = useState(null);
const [subs, setSubs] = useState([]);
const [subFile, setSubFile] = useState(null);
const [subLang, setSubLang] = useState("en");
const [subLabel, setSubLabel] = useState("English");
const [form, setForm] = useState({ const [form, setForm] = useState({
title: "", description: "", year: 2024, duration_minutes: 0, rating: "NR", title: "", description: "", year: 2024, duration_minutes: 0, rating: "NR",
genres: "", cast: "", director: "", poster_url: "", backdrop_url: "", featured: false, genres: "", cast: "", director: "", poster_url: "", backdrop_url: "", featured: false, tmdb_id: "",
}); });
const upd = (k, v) => setForm((f) => ({ ...f, [k]: v })); const upd = (k, v) => setForm((f) => ({ ...f, [k]: v }));
useEffect(() => {
api.get("/settings").then(({ data }) => setTmdbConfigured(data.tmdb_configured)).catch(() => {});
}, []);
const searchTmdb = async () => {
if (!tmdbQuery.trim()) return;
setSearchingTmdb(true);
try {
const { data } = await api.get(`/tmdb/search?q=${encodeURIComponent(tmdbQuery)}`);
setTmdbResults(data);
} catch (err) {
toast.error(err.response?.data?.detail || "TMDB search failed");
} finally { setSearchingTmdb(false); }
};
const pickTmdb = async (r) => {
try {
const { data } = await api.get(`/tmdb/movie/${r.tmdb_id}`);
setForm({
title: data.title || "",
description: data.description || "",
year: data.year || 2024,
duration_minutes: data.duration_minutes || 0,
rating: data.rating || "NR",
genres: (data.genres || []).join(", "),
cast: (data.cast || []).join(", "),
director: data.director || "",
poster_url: data.poster_url || "",
backdrop_url: data.backdrop_url || "",
featured: false,
tmdb_id: data.tmdb_id || "",
});
setTmdbResults([]);
setTmdbQuery("");
toast.success("Metadata filled from TMDB");
} catch (err) {
toast.error("Could not fetch movie details");
}
};
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!file) { toast.error("Choose a video file"); return; } if (!file) { toast.error("Choose a video file"); return; }
if (!form.title.trim()) { toast.error("Title is required"); return; } if (!form.title.trim()) { toast.error("Title is required"); return; }
setSubmitting(true); setSubmitting(true);
const fd = new FormData(); const fd = new FormData();
Object.entries(form).forEach(([k, v]) => fd.append(k, v)); Object.entries(form).forEach(([k, v]) => {
if (k === "tmdb_id" && !v) return;
fd.append(k, v);
});
fd.append("file", file); fd.append("file", file);
try { try {
await api.post("/upload/video", fd, { const { data } = await api.post("/upload/video", fd, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (p) => { onUploadProgress: (p) => { if (p.total) setProgress(Math.round((p.loaded / p.total) * 100)); },
if (p.total) setProgress(Math.round((p.loaded / p.total) * 100));
},
}); });
toast.success("Movie uploaded"); toast.success("Movie uploaded");
nav("/admin"); setCreatedMovieId(data.id);
loadSubs(data.id);
} catch (err) { } catch (err) {
toast.error(err.response?.data?.detail || "Upload failed"); toast.error(err.response?.data?.detail || "Upload failed");
} finally { } finally { setSubmitting(false); }
setSubmitting(false); };
const loadSubs = async (movieId) => {
const { data } = await api.get(`/movies/${movieId}/subtitles`);
setSubs(data);
};
const uploadSub = async (e) => {
e.preventDefault();
if (!subFile || !createdMovieId) return;
const fd = new FormData();
fd.append("language", subLang);
fd.append("label", subLabel);
fd.append("is_default", subs.length === 0);
fd.append("file", subFile);
try {
await api.post(`/movies/${createdMovieId}/subtitles`, fd, { headers: { "Content-Type": "multipart/form-data" } });
toast.success("Subtitle added");
setSubFile(null);
loadSubs(createdMovieId);
} catch (err) {
toast.error(err.response?.data?.detail || "Subtitle upload failed");
} }
}; };
const deleteSub = async (sid) => {
await api.delete(`/subtitles/${sid}`);
loadSubs(createdMovieId);
};
return ( return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-upload-page"> <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"> <div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">Library</span> <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"> <h1 className="font-display text-5xl font-black tracking-tighter text-white mt-3">Upload Film</h1>
Upload Film
</h1>
<form onSubmit={submit} className="mt-12 space-y-6" data-testid="upload-form"> {/* TMDB search box */}
{tmdbConfigured && !createdMovieId && (
<div className="mt-10 border border-[#222] p-5" data-testid="tmdb-search-section">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-3">
<Search size={12} strokeWidth={1.5} /> Auto-fill from TMDB
</div>
<div className="flex gap-2">
<input value={tmdbQuery} onChange={(e) => setTmdbQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), searchTmdb())}
placeholder="Search by title…"
className="flex-1 bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="tmdb-query-input" />
<button type="button" onClick={searchTmdb} disabled={searchingTmdb}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-3 text-xs uppercase tracking-[0.2em]"
data-testid="tmdb-search-button">{searchingTmdb ? "…" : "Search"}</button>
</div>
{tmdbResults.length > 0 && (
<div className="mt-4 max-h-72 overflow-y-auto border border-[#222]">
{tmdbResults.map((r) => (
<button type="button" key={r.tmdb_id} onClick={() => pickTmdb(r)}
className="w-full flex items-center gap-3 p-3 hover:bg-[#0F0F0F] text-left border-b border-[#222] last:border-b-0 transition-colors"
data-testid={`tmdb-result-${r.tmdb_id}`}>
{r.poster_url ? <img src={r.poster_url} alt="" className="w-10 h-14 object-cover" /> : <div className="w-10 h-14 bg-[#1A1A1A]" />}
<div className="flex-1 min-w-0">
<p className="text-white truncate">{r.title} {r.year && <span className="text-[#8A8A8A]">({r.year})</span>}</p>
<p className="text-xs text-[#8A8A8A] line-clamp-1">{r.overview}</p>
</div>
</button>
))}
</div>
)}
</div>
)}
{!tmdbConfigured && (
<p className="mt-8 text-xs text-[#8A8A8A]">
<span className="text-[#fcd34d]"></span> Configure TMDB in <a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">Settings</a> for auto-fill.
</p>
)}
<form onSubmit={submit} className="mt-10 space-y-6" data-testid="upload-form">
<label className="block"> <label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Video file (MP4)</span> <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"> <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} /> <UploadCloud className="mx-auto text-[#8A8A8A]" size={28} strokeWidth={1.5} />
<input <input type="file" accept="video/*" onChange={(e) => setFile(e.target.files?.[0] || null)}
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" 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" data-testid="upload-file-input" />
/>
{file && <p className="mt-2 text-xs text-[#8A8A8A]">{file.name} · {(file.size / (1024*1024)).toFixed(1)} MB</p>} {file && <p className="mt-2 text-xs text-[#8A8A8A]">{file.name} · {(file.size / (1024*1024)).toFixed(1)} MB</p>}
</div> </div>
</label> </label>
@@ -77,17 +188,13 @@ export default function AdminUpload() {
<label className="block"> <label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Description</span> <span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Description</span>
<textarea <textarea value={form.description} onChange={(e) => upd("description", e.target.value)} rows={4}
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" 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" data-testid="upload-description" />
/>
</label> </label>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={form.featured} onChange={(e) => upd("featured", e.target.checked)} <input type="checkbox" checked={form.featured} onChange={(e) => upd("featured", e.target.checked)} className="accent-[#D9381E]" data-testid="upload-featured" />
className="accent-[#D9381E]" data-testid="upload-featured" />
<span className="text-sm text-[#C8C8C8]">Set as featured (hero banner)</span> <span className="text-sm text-[#C8C8C8]">Set as featured (hero banner)</span>
</label> </label>
@@ -100,15 +207,55 @@ export default function AdminUpload() {
</div> </div>
)} )}
<button <button type="submit" disabled={submitting || createdMovieId}
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]" 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" data-testid="upload-submit-button">
> {submitting ? "Uploading…" : createdMovieId ? "Uploaded" : "Upload"}
{submitting ? "Uploading…" : "Upload"}
</button> </button>
</form> </form>
{/* Subtitles - only after movie created */}
{createdMovieId && (
<div className="mt-12 border border-[#222] p-6" data-testid="subtitles-section">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-4">
<Subtitles size={12} strokeWidth={1.5} /> Subtitles
</div>
<p className="text-sm text-[#8A8A8A] mb-4">Upload .srt or .vtt files. SRT will be auto-converted.</p>
<form onSubmit={uploadSub} className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input value={subLang} onChange={(e) => setSubLang(e.target.value)} placeholder="Lang code (en)"
className="bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 text-sm"
data-testid="sub-lang-input" />
<input value={subLabel} onChange={(e) => setSubLabel(e.target.value)} placeholder="Label (English)"
className="bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 text-sm"
data-testid="sub-label-input" />
<input type="file" accept=".srt,.vtt,text/vtt" onChange={(e) => setSubFile(e.target.files?.[0] || null)}
className="text-sm text-[#C8C8C8] file:mr-2 file:py-2 file:px-4 file:border-0 file:bg-[#222] file:text-white"
data-testid="sub-file-input" />
<button type="submit" disabled={!subFile} className="sm:col-span-3 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white py-2 text-xs uppercase tracking-[0.2em]"
data-testid="sub-upload-button">Upload Subtitle</button>
</form>
{subs.length > 0 && (
<div className="mt-5 border border-[#222]">
{subs.map((s) => (
<div key={s.id} className="flex items-center justify-between p-3 border-b border-[#222] last:border-b-0" data-testid={`sub-row-${s.id}`}>
<div>
<p className="text-white text-sm">{s.label} <span className="text-[#8A8A8A] text-xs">({s.language})</span></p>
{s.is_default && <p className="text-[10px] uppercase tracking-[0.2em] text-[#86efac]">Default</p>}
</div>
<button onClick={() => deleteSub(s.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`sub-delete-${s.id}`}>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
))}
</div>
)}
<button onClick={() => nav("/admin")} className="mt-6 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors"
data-testid="upload-done-button">Done back to Admin</button>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -117,11 +264,8 @@ export default function AdminUpload() {
const Field = ({ label, value, onChange, type = "text", required, testid, full }) => ( const Field = ({ label, value, onChange, type = "text", required, testid, full }) => (
<label className={`block ${full ? "md:col-span-2" : ""}`}> <label className={`block ${full ? "md:col-span-2" : ""}`}>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span> <span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span>
<input <input type={type} value={value} required={required} onChange={(e) => onChange(e.target.value)}
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" 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} data-testid={testid} />
/>
</label> </label>
); );
+64 -25
View File
@@ -1,32 +1,64 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import api, { getStreamUrl } from "../lib/api"; import api, { getStreamUrl, getSubtitleUrl, isHls } from "../lib/api";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import Hls from "hls.js";
export default function Player() { export default function Player() {
const { id } = useParams(); const { id } = useParams();
const nav = useNavigate(); const nav = useNavigate();
const [movie, setMovie] = useState(null); const [movie, setMovie] = useState(null);
const [subs, setSubs] = useState([]);
const videoRef = useRef(null); const videoRef = useRef(null);
const hlsRef = useRef(null);
const lastSent = useRef(0); const lastSent = useRef(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const { data } = await api.get(`/movies/${id}`); const [{ data: m }, { data: subList }] = await Promise.all([
api.get(`/movies/${id}`),
api.get(`/movies/${id}/subtitles`).catch(() => ({ data: [] })),
]);
if (cancelled) return; if (cancelled) return;
setMovie(data); setMovie(m);
// Restore progress setSubs(subList);
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; }; return () => { cancelled = true; };
}, [id]); }, [id]);
// Wire up the source supports HLS via hls.js or native (Safari)
useEffect(() => {
if (!movie || !videoRef.current) return;
const v = videoRef.current;
const src = getStreamUrl(movie);
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (isHls(src) && Hls.isSupported() && !v.canPlayType("application/vnd.apple.mpegurl")) {
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(v);
hlsRef.current = hls;
} else {
v.src = src;
}
// Restore progress
api.get(`/progress/${id}`).then(({ data }) => {
if (data?.position_seconds && v) {
v.currentTime = data.position_seconds;
}
}).catch(() => {});
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [movie, id]);
const onTimeUpdate = () => { const onTimeUpdate = () => {
const v = videoRef.current; const v = videoRef.current;
if (!v || !v.duration) return; if (!v || !v.duration) return;
@@ -41,40 +73,47 @@ export default function Player() {
}; };
if (!movie) { if (!movie) {
return ( return <div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]" data-testid="player-loading">Loading</div>;
<div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]" data-testid="player-loading">
Loading
</div>
);
} }
return ( return (
<div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="player-page"> <div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="player-page">
<button <button onClick={() => nav(-1)}
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" 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" data-testid="player-back-button">
>
<ArrowLeft size={16} strokeWidth={1.5} /> <ArrowLeft size={16} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em]">Back</span> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
</button> </button>
<div className="absolute top-6 right-6 z-10 text-right"> <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"> <h2 className="font-display text-xl text-white tracking-tight" data-testid="player-title">{movie.title}</h2>
{movie.title} <p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
</h2> {movie.year} · {movie.rating}
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{movie.year} · {movie.rating}</p> {movie.hls_status === "done" && <span className="text-[#86efac]"> · HLS</span>}
</p>
</div> </div>
<video <video
ref={videoRef} ref={videoRef}
src={getStreamUrl(movie)}
controls controls
autoPlay autoPlay
crossOrigin="anonymous"
className="w-full h-full object-contain bg-black" className="w-full h-full object-contain bg-black"
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
data-testid="player-video" data-testid="player-video"
/> >
{subs.map((s) => (
<track
key={s.id}
kind="subtitles"
src={getSubtitleUrl(s)}
srcLang={s.language}
label={s.label}
default={s.is_default}
data-testid={`player-track-${s.id}`}
/>
))}
</video>
</div> </div>
); );
} }
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { useProfile } from "../lib/profile";
import { useAuth } from "../lib/auth";
import { Plus, Pencil, Check, X, Trash2 } from "lucide-react";
import { toast } from "sonner";
const COLORS = ["#D9381E", "#EAB308", "#22C55E", "#3B82F6", "#A855F7", "#EC4899"];
export default function ProfileSelect() {
const { user, logout } = useAuth();
const { profiles, refresh, switchTo } = useProfile();
const nav = useNavigate();
const [editing, setEditing] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
useEffect(() => { refresh(); }, [refresh]);
const choose = (p) => {
switchTo(p);
nav("/browse", { replace: true });
};
const submitNew = async (e) => {
e.preventDefault();
if (!draft.name.trim()) return;
try {
await api.post("/profiles", draft);
toast.success("Profile created");
setCreating(false);
setDraft({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
refresh();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not create");
}
};
const remove = async (id) => {
if (!window.confirm("Delete this profile? Watchlist & history will be removed.")) return;
try { await api.delete(`/profiles/${id}`); refresh(); } catch (err) {
toast.error(err.response?.data?.detail || "Could not delete");
}
};
return (
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
Who's watching?
</h1>
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
{profiles.map((p) => (
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
<button
onClick={() => editing ? null : choose(p)}
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
style={{ backgroundColor: p.avatar_color }}
data-testid={`select-profile-${p.id}`}
>
{p.name?.[0]?.toUpperCase() || "?"}
</button>
<span className="font-display text-lg text-white">{p.name}</span>
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{p.is_kids && <span>Kids</span>}
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
</div>
{editing && (
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
<Trash2 size={14} strokeWidth={1.5} />
</button>
)}
</div>
))}
{profiles.length < 5 && (
<button
onClick={() => setCreating(true)}
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="add-profile-button"
>
<Plus size={32} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
</button>
)}
</div>
<div className="mt-16 flex gap-4">
<button onClick={() => setEditing(!editing)}
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="manage-profiles-button">
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
{editing ? "Done" : "Manage Profiles"}
</button>
<button onClick={() => { logout(); nav("/login"); }}
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="profile-logout-button">
Sign out
</button>
</div>
{creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
<label className="block mt-6">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
required className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="new-profile-name" />
</label>
<div className="mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
<div className="mt-2 flex gap-2">
{COLORS.map((c) => (
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
data-testid={`color-${c.replace('#', '')}`} />
))}
</div>
</div>
<label className="flex items-center gap-3 mt-5 cursor-pointer">
<input type="checkbox" checked={draft.is_kids}
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
className="accent-[#D9381E]" data-testid="new-profile-kids" />
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
data-testid="new-profile-rating">
<option value="G">G</option>
<option value="PG">PG</option>
<option value="PG-13">PG-13</option>
<option value="R">R</option>
<option value="NR">No restriction</option>
</select>
</label>
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
data-testid="new-profile-submit">Create profile</button>
</form>
</div>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Download, RefreshCcw, Check } from "lucide-react";
export default function RadarrImport() {
const [movies, setMovies] = useState([]);
const [selected, setSelected] = useState(new Set());
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const load = async () => {
setLoading(true);
try {
const { data } = await api.get("/radarr/movies");
setMovies(data);
} catch (err) {
toast.error(err.response?.data?.detail || "Could not load Radarr movies");
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const toggle = (id) => {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
setSelected(s);
};
const importSelected = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const { data } = await api.post("/radarr/import", { radarr_ids: Array.from(selected) });
toast.success(`Imported ${data.imported} movie(s)`);
setSelected(new Set());
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Import failed");
} finally { setImporting(false); }
};
const withFile = movies.filter((m) => m.has_file);
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="radarr-import-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Radarr</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Import Library</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
{movies.length === 0 && !loading ? "No movies found, or Radarr not configured." :
`${withFile.length} movie(s) with files available to import.`}
</p>
<div className="mt-8 flex gap-3">
<button onClick={load} disabled={loading}
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300 disabled:opacity-50"
data-testid="radarr-refresh-button">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} />
Refresh
</button>
<button onClick={importSelected} disabled={selected.size === 0 || importing}
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors"
data-testid="radarr-import-button">
<Download size={14} strokeWidth={1.5} />
Import {selected.size > 0 ? `(${selected.size})` : ""}
</button>
</div>
<div className="mt-10 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{withFile.map((m) => (
<button key={m.radarr_id} onClick={() => toggle(m.radarr_id)}
className={`relative aspect-[2/3] overflow-hidden bg-[#0F0F0F] border-2 transition-colors ${selected.has(m.radarr_id) ? "border-[#D9381E]" : "border-transparent hover:border-white/20"}`}
data-testid={`radarr-card-${m.radarr_id}`}>
{m.poster_url
? <img src={m.poster_url} alt={m.title} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center text-[#8A8A8A] text-xs px-3 text-center">{m.title}</div>}
<div className="absolute inset-x-0 bottom-0 bg-black/70 p-2">
<p className="text-xs text-white truncate">{m.title}</p>
<p className="text-[10px] text-[#8A8A8A]">{m.year}</p>
</div>
{selected.has(m.radarr_id) && (
<div className="absolute top-2 right-2 w-7 h-7 bg-[#D9381E] flex items-center justify-center">
<Check size={14} strokeWidth={2} color="white" />
</div>
)}
</button>
))}
</div>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Eye, EyeOff } from "lucide-react";
export default function Settings() {
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "" });
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
const [showTmdb, setShowTmdb] = useState(false);
const [showRadarr, setShowRadarr] = useState(false);
const [saving, setSaving] = useState(false);
const load = async () => {
const { data } = await api.get("/settings");
setS({ tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "" });
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
};
useEffect(() => { load(); }, []);
const save = async (e) => {
e.preventDefault();
setSaving(true);
try {
const { data } = await api.put("/settings", s);
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
toast.success("Settings saved");
} catch {
toast.error("Could not save");
} finally { setSaving(false); }
};
const testRadarr = async () => {
try {
const { data } = await api.post("/radarr/test");
data.ok ? toast.success("Radarr connected") : toast.error("Radarr unreachable");
} catch (err) {
toast.error(err.response?.data?.detail || "Test failed");
}
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
<p className="text-[#8A8A8A] mt-4 max-w-xl">Connect TMDB for metadata auto-fill and Radarr for library import.</p>
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2>
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.tmdb_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{info.tmdb_configured ? "● Connected" : "○ Not configured"}
</span>
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Get a free API key at <a className="text-[#D9381E] hover:text-[#ED4B32]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org/settings/api</a>
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key (v3)</span>
<div className="relative mt-2">
<input type={showTmdb ? "text" : "password"}
value={s.tmdb_api_key} onChange={(e) => setS({ ...s, tmdb_api_key: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="tmdb-key-input" />
<button type="button" onClick={() => setShowTmdb(!showTmdb)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showTmdb ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
</section>
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2>
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.radarr_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{info.radarr_configured ? "● Configured" : "○ Not configured"}
</span>
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Import your existing Radarr-managed library. Kino must be able to read Radarr's media paths on disk.
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Base URL</span>
<input value={s.radarr_url} placeholder="http://192.168.1.10:7878"
onChange={(e) => setS({ ...s, radarr_url: 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="radarr-url-input" />
</label>
<label className="block mt-4">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key</span>
<div className="relative mt-2">
<input type={showRadarr ? "text" : "password"}
value={s.radarr_api_key} onChange={(e) => setS({ ...s, radarr_api_key: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="radarr-key-input" />
<button type="button" onClick={() => setShowRadarr(!showRadarr)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showRadarr ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
<button type="button" onClick={testRadarr}
className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors"
data-testid="radarr-test-button">
Test Connection
</button>
</section>
<button type="submit" disabled={saving}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="settings-save-button">
{saving ? "Saving…" : "Save Settings"}
</button>
</form>
</div>
</div>
);
}