From cdc8c8955f44b94117286cdba8014ed9eb52fe0e Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Wed, 29 Apr 2026 16:01:20 +0000 Subject: [PATCH] auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc --- backend/models.py | 95 ++++- backend/radarr.py | 42 ++ backend/server.py | 581 ++++++++++++++++++++------- backend/tests/backend_test.py | 32 +- backend/tests/test_phase2.py | 334 +++++++++++++++ backend/tmdb.py | 88 ++++ backend/transcode.py | 74 ++++ frontend/package.json | 1 + frontend/src/App.js | 77 +++- frontend/src/components/Navbar.jsx | 88 ++-- frontend/src/lib/api.js | 32 +- frontend/src/lib/auth.jsx | 11 +- frontend/src/lib/profile.jsx | 48 +++ frontend/src/pages/Admin.jsx | 93 +++-- frontend/src/pages/AdminUpload.jsx | 220 ++++++++-- frontend/src/pages/Player.jsx | 89 ++-- frontend/src/pages/ProfileSelect.jsx | 157 ++++++++ frontend/src/pages/RadarrImport.jsx | 92 +++++ frontend/src/pages/Settings.jsx | 118 ++++++ 19 files changed, 1933 insertions(+), 339 deletions(-) create mode 100644 backend/radarr.py create mode 100644 backend/tests/test_phase2.py create mode 100644 backend/tmdb.py create mode 100644 backend/transcode.py create mode 100644 frontend/src/lib/profile.jsx create mode 100644 frontend/src/pages/ProfileSelect.jsx create mode 100644 frontend/src/pages/RadarrImport.jsx create mode 100644 frontend/src/pages/Settings.jsx diff --git a/backend/models.py b/backend/models.py index 8e49fda..826224c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -29,22 +29,55 @@ class UserPublic(BaseModel): 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 ---------- class MovieBase(BaseModel): title: str description: str = "" year: int = 2024 duration_minutes: int = 0 - rating: str = "NR" # G / PG / PG-13 / R / NR + rating: str = "NR" genres: List[str] = [] cast: List[str] = [] director: str = "" poster_url: str = "" backdrop_url: str = "" video_url: str = "" - storage_type: str = "external" # "external" or "local" + storage_type: str = "external" # external | local | radarr storage_path: Optional[str] = None 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): @@ -66,6 +99,7 @@ class MovieUpdate(BaseModel): storage_type: Optional[str] = None storage_path: Optional[str] = None featured: Optional[bool] = None + tmdb_id: Optional[int] = None class Movie(MovieBase): @@ -74,16 +108,28 @@ class Movie(MovieBase): 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): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) + profile_id: str user_id: str movie_id: str added_at: str = Field(default_factory=_now_iso) -# ---------- Progress (Continue Watching) ---------- class ProgressUpsert(BaseModel): movie_id: str position_seconds: float @@ -92,6 +138,7 @@ class ProgressUpsert(BaseModel): class Progress(BaseModel): model_config = ConfigDict(extra="ignore") + profile_id: str user_id: str movie_id: str position_seconds: float @@ -107,7 +154,7 @@ class RequestCreate(BaseModel): class RequestUpdate(BaseModel): - status: str # pending | fulfilled | rejected + status: str class MovieRequest(BaseModel): @@ -122,8 +169,46 @@ class MovieRequest(BaseModel): 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 ---------- class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" 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 diff --git a/backend/radarr.py b/backend/radarr.py new file mode 100644 index 0000000..dcc13e8 --- /dev/null +++ b/backend/radarr.py @@ -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 diff --git a/backend/server.py b/backend/server.py index 397d4d4..b252819 100644 --- a/backend/server.py +++ b/backend/server.py @@ -2,11 +2,12 @@ import os import uuid import logging import mimetypes +import asyncio from pathlib import Path from datetime import datetime, timezone from typing import List, Optional -from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request +from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request, BackgroundTasks from fastapi.responses import StreamingResponse, FileResponse, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from dotenv import load_dotenv @@ -15,17 +16,21 @@ from motor.motor_asyncio import AsyncIOMotorClient from models import ( UserCreate, UserLogin, UserPublic, TokenResponse, + Profile, ProfileCreate, ProfileUpdate, RATING_ORDER, Movie, MovieCreate, MovieUpdate, - WatchlistItem, ProgressUpsert, Progress, + Subtitle, + WatchlistItem, ProgressUpsert, RequestCreate, RequestUpdate, MovieRequest, + AppSettings, AppSettingsPublic, + TMDBSearchResult, RadarrMovieDTO, ) -from auth import ( - hash_password, verify_password, create_token, decode_token, -) +from auth import hash_password, verify_password, create_token, decode_token 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 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"))) VIDEOS_DIR = MEDIA_ROOT / "videos" POSTERS_DIR = MEDIA_ROOT / "posters" -VIDEOS_DIR.mkdir(parents=True, exist_ok=True) -POSTERS_DIR.mkdir(parents=True, exist_ok=True) +SUBS_DIR = MEDIA_ROOT / "subtitles" +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") bearer = HTTPBearer(auto_error=False) @@ -60,30 +67,46 @@ async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depen 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: if not user.get("is_admin"): raise HTTPException(status_code=403, detail="Admin only") 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") async def on_startup(): - # Index for unique email await db.users.create_index("email", unique=True) await db.movies.create_index("title") - await db.watchlist.create_index([("user_id", 1), ("movie_id", 1)], unique=True) - await db.progress.create_index([("user_id", 1), ("movie_id", 1)], unique=True) + await db.profiles.create_index("user_id") + await db.subtitles.create_index("movie_id") admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local") 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(), } 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 - count = await db.movies.count_documents({}) - if count == 0: + if await db.movies.count_documents({}) == 0: for m in SAMPLE_MOVIES: - doc = Movie(**m).model_dump() - await db.movies.insert_one(doc) - logger.info(f"Seeded {len(SAMPLE_MOVIES)} sample movies") + await db.movies.insert_one(Movie(**m).model_dump()) + logger.info(f"Seeded {len(SAMPLE_MOVIES)} 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") @@ -112,7 +164,6 @@ async def on_shutdown(): client.close() -# ---------- Helpers ---------- def _user_public(u: dict) -> UserPublic: return UserPublic( id=u["id"], email=u["email"], name=u["name"], @@ -120,12 +171,23 @@ def _user_public(u: dict) -> UserPublic: ) -def _strip(doc: dict) -> dict: - doc.pop("_id", None) - return doc +def _strip(d: dict) -> dict: + d.pop("_id", None) + 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) async def register(payload: UserCreate): 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(), } await db.users.insert_one(user) - token = create_token(user["id"], False) - return TokenResponse(access_token=token, user=_user_public(user)) + # auto-create default profile + 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) @@ -148,8 +211,7 @@ async def login(payload: UserLogin): user = await db.users.find_one({"email": payload.email.lower()}) if not user or not verify_password(payload.password, user["password_hash"]): raise HTTPException(status_code=401, detail="Invalid email or password") - token = create_token(user["id"], user.get("is_admin", False)) - return TokenResponse(access_token=token, user=_user_public(user)) + return TokenResponse(access_token=create_token(user["id"], user.get("is_admin", False)), user=_user_public(user)) @api.get("/auth/me", response_model=UserPublic) @@ -157,10 +219,65 @@ async def me(user: dict = Depends(get_current_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]) -async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200): - query: dict = {} +async def list_movies( + 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: query["genres"] = {"$regex": f"^{genre}$", "$options": "i"} 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) -async def get_featured(): - doc = await db.movies.find_one({"featured": True}, {"_id": 0}) +async def get_featured(profile: dict = Depends(get_active_profile)): + base = _movie_filter_for_profile(profile) + doc = await db.movies.find_one({**base, "featured": True}, {"_id": 0}) if not doc: - doc = await db.movies.find_one({}, {"_id": 0}) + doc = await db.movies.find_one(base, {"_id": 0}) if not doc: raise HTTPException(status_code=404, detail="No movies") 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}) if not movie: raise HTTPException(status_code=404, detail="Movie not found") - # delete local files if any if movie.get("storage_type") == "local" and movie.get("storage_path"): - try: - (VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True) - except Exception: - pass + try: (VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True) + except Exception: pass + if movie.get("hls_path"): + import shutil + shutil.rmtree(HLS_DIR / movie_id, ignore_errors=True) await db.movies.delete_one({"id": movie_id}) await db.watchlist.delete_many({"movie_id": movie_id}) await db.progress.delete_many({"movie_id": movie_id}) + await db.subtitles.delete_many({"movie_id": movie_id}) return {"ok": True} -# ---------- Upload (admin) ---------- +# ============ UPLOAD VIDEO ============ @api.post("/upload/video", response_model=Movie) async def upload_video( title: str = Form(...), @@ -244,12 +363,13 @@ async def upload_video( year: int = Form(2024), duration_minutes: int = Form(0), rating: str = Form("NR"), - genres: str = Form(""), # comma-separated + genres: str = Form(""), cast: str = Form(""), director: str = Form(""), poster_url: str = Form(""), backdrop_url: str = Form(""), featured: bool = Form(False), + tmdb_id: Optional[int] = Form(None), file: UploadFile = File(...), user: dict = Depends(require_admin), ): @@ -257,62 +377,50 @@ async def upload_video( safe_id = str(uuid.uuid4()) fname = f"{safe_id}.{ext}" target = VIDEOS_DIR / fname - - # stream save with target.open("wb") as f: while True: chunk = await file.read(CHUNK_SIZE) - if not chunk: - break + if not chunk: break f.write(chunk) movie = Movie( - title=title, - description=description, - year=year, - duration_minutes=duration_minutes, - rating=rating, + title=title, description=description, year=year, + duration_minutes=duration_minutes, rating=rating, genres=[g.strip() for g in genres.split(",") if g.strip()], cast=[c.strip() for c in cast.split(",") if c.strip()], - director=director, - poster_url=poster_url, - backdrop_url=backdrop_url, - video_url="", # filled with stream endpoint client-side - storage_type="local", - storage_path=fname, - featured=featured, + director=director, poster_url=poster_url, backdrop_url=backdrop_url, + video_url="", storage_type="local", storage_path=fname, + featured=featured, tmdb_id=tmdb_id, ) doc = movie.model_dump() await db.movies.insert_one(doc) return _strip(doc) -# ---------- Streaming with Range support ---------- +# ============ STREAM (Range-aware) ============ @api.get("/stream/{movie_id}") async def stream_movie( - movie_id: str, - request: Request, + movie_id: str, request: Request, auth: Optional[str] = Query(None), authorization: Optional[str] = Header(None), ): - # token check (header OR ?auth= for