mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 21:44:14 -05:00
auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc
This commit is contained in:
+90
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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)}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user