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, BackgroundTasks from fastapi.responses import StreamingResponse, FileResponse, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from dotenv import load_dotenv from starlette.middleware.cors import CORSMiddleware from motor.motor_asyncio import AsyncIOMotorClient from models import ( UserCreate, UserLogin, UserPublic, TokenResponse, Profile, ProfileCreate, ProfileUpdate, RATING_ORDER, Movie, MovieCreate, MovieUpdate, Subtitle, WatchlistItem, ProgressUpsert, RequestCreate, RequestUpdate, MovieRequest, AppSettings, AppSettingsPublic, TMDBSearchResult, RadarrMovieDTO, ) 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_quick, transcode_abr, srt_to_vtt ROOT_DIR = Path(__file__).parent load_dotenv(ROOT_DIR / ".env") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger("kino") mongo_url = os.environ["MONGO_URL"] client = AsyncIOMotorClient(mongo_url) db = client[os.environ["DB_NAME"]] MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", str(ROOT_DIR / "media"))) VIDEOS_DIR = MEDIA_ROOT / "videos" POSTERS_DIR = MEDIA_ROOT / "posters" 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 app = FastAPI(title="Kino") api = APIRouter(prefix="/api") bearer = HTTPBearer(auto_error=False) # ---------- Auth deps ---------- async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> dict: if not creds: raise HTTPException(status_code=401, detail="Not authenticated") payload = decode_token(creds.credentials) user = await db.users.find_one({"id": payload["sub"]}, {"_id": 0, "password_hash": 0}) if not user: raise HTTPException(status_code=401, detail="User not found") return user 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 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(): await db.users.create_index("email", unique=True) await db.movies.create_index("title") 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}): admin_user = { "id": str(uuid.uuid4()), "email": admin_email, "name": os.environ.get("ADMIN_NAME", "Admin"), "password_hash": hash_password(os.environ.get("ADMIN_PASSWORD", "kino-admin-2026")), "is_admin": True, "created_at": datetime.now(timezone.utc).isoformat(), } await db.users.insert_one(admin_user) logger.info(f"Seeded admin: {admin_email}") if await db.movies.count_documents({}) == 0: for m in 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") async def on_shutdown(): client.close() def _user_public(u: dict) -> UserPublic: return UserPublic( id=u["id"], email=u["email"], name=u["name"], is_admin=u.get("is_admin", False), created_at=u["created_at"], ) def _strip(d: dict) -> dict: d.pop("_id", None) return d 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()}): raise HTTPException(status_code=400, detail="Email already registered") user = { "id": str(uuid.uuid4()), "email": payload.email.lower(), "name": payload.name, "password_hash": hash_password(payload.password), "is_admin": False, "created_at": datetime.now(timezone.utc).isoformat(), } await db.users.insert_one(user) # 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) 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") return TokenResponse(access_token=create_token(user["id"], user.get("is_admin", False)), user=_user_public(user)) @api.get("/auth/me", response_model=UserPublic) async def me(user: dict = Depends(get_current_user)): return _user_public(user) # ============ 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, profile: dict = Depends(get_active_profile), ): query: dict = _movie_filter_for_profile(profile) if genre: query["genres"] = {"$regex": f"^{genre}$", "$options": "i"} if q: query["$or"] = [ {"title": {"$regex": q, "$options": "i"}}, {"description": {"$regex": q, "$options": "i"}}, {"director": {"$regex": q, "$options": "i"}}, {"cast": {"$regex": q, "$options": "i"}}, ] docs = await db.movies.find(query, {"_id": 0}).sort("created_at", -1).to_list(limit) return docs @api.get("/movies/featured", response_model=Movie) async def get_featured(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(base, {"_id": 0}) if not doc: raise HTTPException(status_code=404, detail="No movies") return doc @api.get("/movies/genres", response_model=List[str]) async def list_genres(): genres = await db.movies.distinct("genres") return sorted([g for g in genres if g]) @api.get("/movies/{movie_id}", response_model=Movie) async def get_movie(movie_id: str): doc = await db.movies.find_one({"id": movie_id}, {"_id": 0}) if not doc: raise HTTPException(status_code=404, detail="Movie not found") return doc @api.post("/movies", response_model=Movie) async def create_movie(payload: MovieCreate, user: dict = Depends(require_admin)): movie = Movie(**payload.model_dump()) doc = movie.model_dump() await db.movies.insert_one(doc) return _strip(doc) @api.patch("/movies/{movie_id}", response_model=Movie) async def update_movie(movie_id: str, payload: MovieUpdate, user: dict = Depends(require_admin)): updates = {k: v for k, v in payload.model_dump().items() if v is not None} if not updates: raise HTTPException(status_code=400, detail="No fields to update") res = await db.movies.find_one_and_update( {"id": movie_id}, {"$set": updates}, projection={"_id": 0}, return_document=True, ) if not res: raise HTTPException(status_code=404, detail="Movie not found") return res @api.delete("/movies/{movie_id}") async def delete_movie(movie_id: str, user: dict = Depends(require_admin)): movie = await db.movies.find_one({"id": movie_id}, {"_id": 0}) if not movie: raise HTTPException(status_code=404, detail="Movie not found") if movie.get("storage_type") == "local" and movie.get("storage_path"): 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 VIDEO ============ @api.post("/upload/video", response_model=Movie) async def upload_video( title: str = Form(...), description: str = Form(""), year: int = Form(2024), duration_minutes: int = Form(0), rating: str = Form("NR"), genres: str = Form(""), 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), ): ext = (file.filename.rsplit(".", 1)[-1] if "." in (file.filename or "") else "mp4").lower() safe_id = str(uuid.uuid4()) fname = f"{safe_id}.{ext}" target = VIDEOS_DIR / fname with target.open("wb") as f: while True: chunk = await file.read(CHUNK_SIZE) if not chunk: break f.write(chunk) movie = Movie( title=title, description=description, year=year, duration_minutes=duration_minutes, rating=rating, genres=[g.strip() for g in genres.split(",") if g.strip()], cast=[c.strip() for c in cast.split(",") if c.strip()], director=director, poster_url=poster_url, backdrop_url=backdrop_url, video_url="", 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) # ============ STREAM (Range-aware) ============ @api.get("/stream/{movie_id}") async def stream_movie( movie_id: str, request: Request, auth: Optional[str] = Query(None), authorization: Optional[str] = Header(None), ): token = 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) 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="Movie has no local file; use video_url directly") 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(): raise HTTPException(status_code=404, detail="File missing on disk") file_size = file_path.stat().st_size content_type, _ = mimetypes.guess_type(str(file_path)) content_type = content_type or "video/mp4" range_header = request.headers.get("range") or request.headers.get("Range") if range_header and range_header.startswith("bytes="): try: start_str, end_str = range_header.replace("bytes=", "").split("-") start = int(start_str) if start_str else 0 end = int(end_str) if end_str else file_size - 1 except ValueError: raise HTTPException(status_code=416, detail="Invalid range") if start >= file_size: raise HTTPException(status_code=416, detail="Range out of bounds") end = min(end, file_size - 1) length = end - start + 1 def iter_file(): with file_path.open("rb") as f: f.seek(start) remaining = length while remaining > 0: chunk = f.read(min(CHUNK_SIZE, remaining)) if not chunk: break remaining -= len(chunk) yield chunk return StreamingResponse(iter_file(), status_code=206, media_type=content_type, headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(length), "Content-Type": content_type, }) return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"}) # ============ 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, quality: str): out_dir = HLS_DIR / movie_id # Clear any prior output before re-encoding import shutil as _sh _sh.rmtree(out_dir, ignore_errors=True) async def cb(status, entry: Optional[str] = None, error: Optional[str] = None): if status == "done" and entry: await _set_hls_status(movie_id, "done", path=entry) else: await _set_hls_status(movie_id, status, error=error) if quality == "abr": await transcode_abr(source, out_dir, cb) else: await transcode_quick(source, out_dir, cb) @api.post("/movies/{movie_id}/transcode") async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)): """Body: {"quality": "quick"|"abr"} — default "quick".""" quality = (payload or {}).get("quality", "quick") if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'") 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") in ("running", "pending"): 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, quality)) return {"ok": True, "status": "pending", "quality": quality} @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") # Rewrite playlists to propagate ?auth= to relative URLs (variants + segments) if filename.endswith(".m3u8"): text = target.read_text(encoding="utf-8", errors="ignore") out_lines = [] for line in text.splitlines(): stripped = line.strip() if stripped and not stripped.startswith("#") and "://" not in stripped: sep = "&" if "?" in stripped else "?" line = f"{stripped}{sep}auth={token}" out_lines.append(line) return Response("\n".join(out_lines) + "\n", media_type="application/vnd.apple.mpegurl") media_type = "video/mp2t" if filename.endswith(".ts") else "application/octet-stream" 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]) async def get_watchlist(profile: dict = Depends(get_active_profile)): 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] if not movie_ids: return [] movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(500) by_id = {m["id"]: m for m in movies} return [by_id[mid] for mid in movie_ids if mid in by_id] @api.post("/watchlist/{movie_id}") async def add_watchlist(movie_id: str, profile: dict = Depends(get_active_profile)): if not await db.movies.find_one({"id": movie_id}, {"_id": 1}): raise HTTPException(status_code=404, detail="Movie not found") item = WatchlistItem(profile_id=profile["id"], user_id=profile["user_id"], movie_id=movie_id).model_dump() try: await db.watchlist.insert_one(item) except Exception: pass return {"ok": True} @api.delete("/watchlist/{movie_id}") async def remove_watchlist(movie_id: str, profile: dict = Depends(get_active_profile)): await db.watchlist.delete_one({"profile_id": profile["id"], "movie_id": movie_id}) return {"ok": True} # ============ PROGRESS ============ @api.post("/progress") async def upsert_progress(payload: ProgressUpsert, profile: dict = Depends(get_active_profile)): now = datetime.now(timezone.utc).isoformat() await db.progress.update_one( {"profile_id": profile["id"], "movie_id": payload.movie_id}, {"$set": { "profile_id": profile["id"], "user_id": profile["user_id"], "movie_id": payload.movie_id, "position_seconds": payload.position_seconds, "duration_seconds": payload.duration_seconds, "updated_at": now, }}, upsert=True, ) return {"ok": True} @api.get("/progress/continue") async def continue_watching(profile: dict = Depends(get_active_profile)): rows = await db.progress.find({"profile_id": profile["id"]}, {"_id": 0}).sort("updated_at", -1).to_list(50) rows = [r for r in rows if r.get("duration_seconds", 0) == 0 or r["position_seconds"] / max(r["duration_seconds"], 1) < 0.95] if not rows: return [] movie_ids = [r["movie_id"] for r in rows] movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(50) by_id = {m["id"]: m for m in movies} return [{**by_id[r["movie_id"]], "progress": r} for r in rows if r["movie_id"] in by_id] @api.get("/progress/{movie_id}") async def get_progress(movie_id: str, profile: dict = Depends(get_active_profile)): 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} # ============ REQUESTS ============ @api.post("/requests", response_model=MovieRequest) async def submit_request(payload: RequestCreate, user: dict = Depends(get_current_user)): req = MovieRequest(user_id=user["id"], user_name=user.get("name", ""), title=payload.title, year=payload.year, notes=payload.notes) doc = req.model_dump() await db.requests.insert_one(doc) return _strip(doc) @api.get("/requests/mine", response_model=List[MovieRequest]) async def my_requests(user: dict = Depends(get_current_user)): return await db.requests.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", -1).to_list(200) @api.get("/requests", response_model=List[MovieRequest]) async def all_requests(user: dict = Depends(require_admin)): return await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500) @api.patch("/requests/{request_id}", response_model=MovieRequest) async def update_request(request_id: str, payload: RequestUpdate, user: dict = Depends(require_admin)): res = await db.requests.find_one_and_update( {"id": request_id}, {"$set": {"status": payload.status}}, projection={"_id": 0}, return_document=True, ) if not res: raise HTTPException(status_code=404, detail="Request not found") return res # ============ 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("/") async def root(): return {"app": "Kino", "status": "ok"} app.include_router(api) app.add_middleware( CORSMiddleware, allow_credentials=True, allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","), allow_methods=["*"], allow_headers=["*"], expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"], )