import os import uuid import logging import mimetypes import asyncio from pathlib import Path from datetime import datetime, timezone from typing import List, Optional, Dict 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, PasswordChange, AdminUserCreate, AdminUserUpdate, Profile, ProfileUpdate, RATING_ORDER, Movie, MovieCreate, MovieUpdate, Subtitle, WatchlistItem, ProgressUpsert, RequestCreate, RequestUpdate, MovieRequest, AppSettings, AppSettingsPublic, TMDBSearchResult, RadarrMovieDTO, TranscodeJob, QueuePauseToggle, Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert, SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult, Track, TrackUpdate, ) 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 import sonarr as sonarr_client import services as services_client import library_scan import musicbrainz as musicbrainz_client import trakt as trakt_client import opensubtitles as opensubs_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="StreamHoard") 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@streamhoard.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}") # Reset any stuck "running" jobs from a previous backend crash await db.transcode_queue.update_many( {"status": "running"}, {"$set": {"status": "failed", "error": "Backend restarted while running"}}, ) await db.transcode_queue.create_index([("status", 1), ("created_at", 1)]) # Start the background transcode worker asyncio.create_task(_transcode_worker()) @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) @api.post("/auth/change-password") async def change_password(payload: PasswordChange, user: dict = Depends(get_current_user)): full_user = await db.users.find_one({"id": user["id"]}) if not full_user or not verify_password(payload.current_password, full_user["password_hash"]): raise HTTPException(status_code=401, detail="Current password is incorrect") if len(payload.new_password) < 8: raise HTTPException(status_code=400, detail="New password must be at least 8 characters") await db.users.update_one( {"id": user["id"]}, {"$set": {"password_hash": hash_password(payload.new_password)}} ) return {"ok": True} # ============ USERS (admin) ============ @api.get("/admin/users", response_model=List[UserPublic]) async def admin_list_users(user: dict = Depends(require_admin)): docs = await db.users.find({}, {"_id": 0, "password_hash": 0}).sort("created_at", 1).to_list(500) return docs @api.post("/admin/users", response_model=UserPublic) async def admin_create_user(payload: AdminUserCreate, user: dict = Depends(require_admin)): if await db.users.find_one({"email": payload.email.lower()}): raise HTTPException(status_code=400, detail="Email already registered") new_user = { "id": str(uuid.uuid4()), "email": payload.email.lower(), "name": payload.name, "password_hash": hash_password(payload.password), "is_admin": payload.is_admin, "created_at": datetime.now(timezone.utc).isoformat(), } await db.users.insert_one(new_user) await db.profiles.insert_one(Profile(user_id=new_user["id"], name=new_user["name"]).model_dump()) return _user_public(new_user) @api.patch("/admin/users/{user_id}", response_model=UserPublic) async def admin_update_user(user_id: str, payload: AdminUserUpdate, user: dict = Depends(require_admin)): target = await db.users.find_one({"id": user_id}) if not target: raise HTTPException(status_code=404, detail="User not found") updates = {} if payload.name is not None: updates["name"] = payload.name if payload.is_admin is not None: if user_id == user["id"] and not payload.is_admin: raise HTTPException(status_code=400, detail="Cannot remove your own admin access") updates["is_admin"] = payload.is_admin if payload.password: if len(payload.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") updates["password_hash"] = hash_password(payload.password) if updates: await db.users.update_one({"id": user_id}, {"$set": updates}) target = await db.users.find_one({"id": user_id}) return _user_public(target) @api.delete("/admin/users/{user_id}") async def admin_delete_user(user_id: str, user: dict = Depends(require_admin)): if user_id == user["id"]: raise HTTPException(status_code=400, detail="Cannot delete your own account") target = await db.users.find_one({"id": user_id}) if not target: raise HTTPException(status_code=404, detail="User not found") await db.users.delete_one({"id": user_id}) await db.profiles.delete_many({"user_id": user_id}) return {"ok": True} # ============ PROFILE (1:1 with account — no separate sub-profile management) ============ @api.get("/profiles", response_model=List[Profile]) async def list_profiles(user: dict = Depends(get_current_user)): """Always exactly one profile per account; get_active_profile auto-creates it if missing.""" docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(1) return docs @api.patch("/profiles/{pid}", response_model=Profile) async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)): """Edit your own single profile (avatar color, kids mode, rating cap) — not a multi-profile switcher.""" 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 # ============ 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) # Auto-enqueue transcode if enabled s = await get_settings() auto = s.get("auto_transcode", "off") if auto in ("quick", "abr"): await _enqueue_transcode(doc["id"], auto, triggered_by="auto") 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", "scan") 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") in ("radarr", "scan"): file_path = Path(movie["storage_path"]) # absolute path on NAS-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 _process_job(job: dict): """Run a transcode job. Handles both movies and episodes.""" content_type = job.get("content_type", "movie") coll = db.movies if content_type == "movie" else db.episodes doc = await coll.find_one({"id": job["movie_id"]}, {"_id": 0}) if not doc: await db.transcode_queue.update_one( {"id": job["id"]}, {"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}}, ) return if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"): await db.transcode_queue.update_one( {"id": job["id"]}, {"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}}, ) return source = ( Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr", "scan") else VIDEOS_DIR / doc["storage_path"] ) out_dir = HLS_DIR / job["movie_id"] import shutil as _sh _sh.rmtree(out_dir, ignore_errors=True) last_error = {"msg": ""} async def cb(status, entry: Optional[str] = None, error: Optional[str] = None): update = {"hls_status": status} if status == "done" and entry: update["hls_path"] = entry await coll.update_one({"id": job["movie_id"]}, {"$set": update}) if error: last_error["msg"] = error try: if job["quality"] == "abr": await transcode_abr(source, out_dir, cb) else: await transcode_quick(source, out_dir, cb) except Exception as e: logger.exception("transcode crashed") last_error["msg"] = str(e) await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": "failed"}}) after = await coll.find_one({"id": job["movie_id"]}, {"_id": 0, "hls_status": 1}) final_status = "done" if after and after.get("hls_status") == "done" else "failed" await db.transcode_queue.update_one( {"id": job["id"]}, {"$set": {"status": final_status, "error": last_error["msg"], "finished_at": datetime.now(timezone.utc).isoformat()}}, ) async def _transcode_worker(): """Single FIFO worker. Polls for pending jobs and runs them serially.""" logger.info("Transcode worker started") while True: try: settings = await get_settings() if settings.get("queue_paused", False): await asyncio.sleep(15) continue job = await db.transcode_queue.find_one_and_update( {"status": "pending"}, {"$set": {"status": "running", "started_at": datetime.now(timezone.utc).isoformat()}}, sort=[("created_at", 1)], projection={"_id": 0}, return_document=True, ) if not job: await asyncio.sleep(8) continue await _set_hls_status(job["movie_id"], "running") await _process_job(job) except Exception: logger.exception("Transcode worker error") await asyncio.sleep(10) async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str = "manual", content_type: str = "movie") -> dict: """Add a job to the queue. Returns the job document. Works for movies + episodes.""" coll = db.movies if content_type == "movie" else db.episodes doc = await coll.find_one({"id": content_id}, {"_id": 0, "title": 1}) title = doc.get("title", "") if doc else "" existing = await db.transcode_queue.find_one( {"movie_id": content_id, "status": {"$in": ["pending", "running"]}}, {"_id": 0}, ) if existing: return existing job = TranscodeJob( movie_id=content_id, movie_title=title, quality=quality, triggered_by=triggered_by, content_type=content_type, ).model_dump() await db.transcode_queue.insert_one(job) await coll.update_one({"id": content_id}, {"$set": {"hls_status": "pending"}}) return job @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". Adds to background queue.""" 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", "scan") or not movie.get("storage_path"): raise HTTPException(status_code=400, detail="Only local/radarr/scanned movies can be transcoded") if movie.get("hls_status") in ("running", "pending"): raise HTTPException(status_code=409, detail="Already transcoding or queued") job = await _enqueue_transcode(movie_id, quality, triggered_by="manual") return {"ok": True, "status": "pending", "quality": quality, "job_id": job["id"]} # ============ Transcode Queue endpoints ============ @api.get("/transcode/queue", response_model=List[TranscodeJob]) async def list_queue(status: Optional[str] = None, user: dict = Depends(require_admin)): q: dict = {} if status: q["status"] = {"$in": status.split(",")} rows = await db.transcode_queue.find(q, {"_id": 0}).sort("created_at", -1).to_list(500) return rows @api.get("/transcode/queue/stats") async def queue_stats(user: dict = Depends(require_admin)): pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}] out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0} async for row in db.transcode_queue.aggregate(pipeline): out[row["_id"]] = row["n"] s = await get_settings() out["paused"] = bool(s.get("queue_paused", False)) out["auto_transcode"] = s.get("auto_transcode", "off") return out @api.delete("/transcode/queue/{job_id}") async def cancel_job(job_id: str, user: dict = Depends(require_admin)): job = await db.transcode_queue.find_one({"id": job_id}, {"_id": 0}) if not job: raise HTTPException(status_code=404, detail="Job not found") if job["status"] == "running": raise HTTPException(status_code=400, detail="Cannot cancel a running job") await db.transcode_queue.update_one({"id": job_id}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}}) if job["status"] == "pending": # Reset movie hls_status AND clear stale hls_path so the row no longer shows as transcoded await db.movies.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}}) return {"ok": True} @api.post("/transcode/queue/{job_id}/retry") async def retry_job(job_id: str, user: dict = Depends(require_admin)): job = await db.transcode_queue.find_one({"id": job_id}, {"_id": 0}) if not job: raise HTTPException(status_code=404, detail="Job not found") if job["status"] not in ("failed", "cancelled"): raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried") new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual")) return {"ok": True, "job_id": new_job["id"]} @api.post("/transcode/queue/clear") async def clear_finished(user: dict = Depends(require_admin)): res = await db.transcode_queue.delete_many({"status": {"$in": ["done", "failed", "cancelled"]}}) return {"ok": True, "deleted": res.deleted_count} @api.post("/transcode/queue/pause") async def pause_queue(payload: QueuePauseToggle, user: dict = Depends(require_admin)): await db.settings.update_one({"id": "app"}, {"$set": {"queue_paused": payload.paused}}, upsert=True) return {"ok": True, "paused": payload.paused} @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.get("/episodes/{episode_id}/subtitles", response_model=List[Subtitle]) async def list_episode_subs(episode_id: str, user: dict = Depends(get_current_user)): docs = await db.subtitles.find({"episode_id": episode_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, ) # Trakt scrobble hook (fire and forget) movie = await db.movies.find_one({"id": payload.movie_id}, {"_id": 0, "tmdb_id": 1}) if movie: asyncio.create_task(_trakt_scrobble_movie(profile["user_id"], movie, payload.position_seconds, payload.duration_seconds)) 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")), sonarr_configured=bool(s.get("sonarr_api_key") and s.get("sonarr_url")), opensubs_configured=bool(s.get("opensubs_api_key")), trakt_configured=bool(s.get("trakt_client_id") and s.get("trakt_client_secret")), tmdb_api_key=s.get("tmdb_api_key", ""), radarr_url=s.get("radarr_url", ""), radarr_api_key=s.get("radarr_api_key", ""), sonarr_url=s.get("sonarr_url", ""), sonarr_api_key=s.get("sonarr_api_key", ""), opensubs_api_key=s.get("opensubs_api_key", ""), trakt_client_id=s.get("trakt_client_id", ""), trakt_client_secret=s.get("trakt_client_secret", ""), auto_transcode=s.get("auto_transcode", "off"), queue_paused=bool(s.get("queue_paused", False)), ) @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")), sonarr_configured=bool(doc.get("sonarr_api_key") and doc.get("sonarr_url")), opensubs_configured=bool(doc.get("opensubs_api_key")), trakt_configured=bool(doc.get("trakt_client_id") and doc.get("trakt_client_secret")), tmdb_api_key=doc.get("tmdb_api_key", ""), radarr_url=doc.get("radarr_url", ""), radarr_api_key=doc.get("radarr_api_key", ""), sonarr_url=doc.get("sonarr_url", ""), sonarr_api_key=doc.get("sonarr_api_key", ""), opensubs_api_key=doc.get("opensubs_api_key", ""), trakt_client_id=doc.get("trakt_client_id", ""), trakt_client_secret=doc.get("trakt_client_secret", ""), auto_transcode=doc.get("auto_transcode", "off"), queue_paused=bool(doc.get("queue_paused", False)), ) # ============ 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 auto = s.get("auto_transcode", "off") 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 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"], radarr_id=m["radarr_id"], tmdb_id=m.get("tmdb_id"), ).model_dump() await db.movies.insert_one(movie) if auto in ("quick", "abr"): await _enqueue_transcode(movie["id"], auto, triggered_by="auto") # Auto-fetch English subtitle (fire and forget) asyncio.create_task(_auto_fetch_subtitle("movie", movie["id"], movie.get("tmdb_id"))) created += 1 return {"ok": True, "imported": created} # ============ SHOWS (TV) ============ @api.get("/shows", response_model=List[Show]) async def list_shows(profile: dict = Depends(get_active_profile)): query = _movie_filter_for_profile(profile) docs = await db.shows.find(query, {"_id": 0}).sort("created_at", -1).to_list(500) return docs @api.get("/shows/featured", response_model=Show) async def show_featured(profile: dict = Depends(get_active_profile)): base = _movie_filter_for_profile(profile) doc = await db.shows.find_one({**base, "featured": True}, {"_id": 0}) if not doc: doc = await db.shows.find_one(base, {"_id": 0}) if not doc: raise HTTPException(status_code=404, detail="No shows") return doc @api.get("/shows/{show_id}", response_model=Show) async def get_show(show_id: str, user: dict = Depends(get_current_user)): doc = await db.shows.find_one({"id": show_id}, {"_id": 0}) if not doc: raise HTTPException(status_code=404, detail="Show not found") return doc @api.post("/shows", response_model=Show) async def create_show(payload: ShowCreate, user: dict = Depends(require_admin)): doc = Show(**payload.model_dump()).model_dump() await db.shows.insert_one(doc) return _strip(doc) @api.patch("/shows/{show_id}", response_model=Show) async def update_show(show_id: str, payload: ShowUpdate, 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") res = await db.shows.find_one_and_update({"id": show_id}, {"$set": updates}, projection={"_id": 0}, return_document=True) if not res: raise HTTPException(status_code=404, detail="Not found") return res @api.delete("/shows/{show_id}") async def delete_show(show_id: str, user: dict = Depends(require_admin)): await db.shows.delete_one({"id": show_id}) await db.episodes.delete_many({"show_id": show_id}) await db.episode_progress.delete_many({"show_id": show_id}) return {"ok": True} @api.get("/shows/{show_id}/episodes", response_model=List[Episode]) async def list_episodes(show_id: str, include_hidden: bool = False, user: dict = Depends(get_current_user)): q: dict = {"show_id": show_id} if not include_hidden or not user.get("is_admin"): q["hidden"] = {"$ne": True} docs = await db.episodes.find(q, {"_id": 0}).sort([("season_number", 1), ("episode_number", 1)]).to_list(2000) return docs @api.get("/episodes/{episode_id}", response_model=Episode) async def get_episode(episode_id: str, user: dict = Depends(get_current_user)): doc = await db.episodes.find_one({"id": episode_id}, {"_id": 0}) if not doc: raise HTTPException(status_code=404, detail="Episode not found") return doc @api.post("/episodes", response_model=Episode) async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_admin)): if not await db.shows.find_one({"id": payload.show_id}, {"_id": 1}): raise HTTPException(status_code=404, detail="Show not found") doc = Episode(**payload.model_dump()).model_dump() await db.episodes.insert_one(doc) # Refresh show counts seasons = await db.episodes.distinct("season_number", {"show_id": payload.show_id}) ecount = await db.episodes.count_documents({"show_id": payload.show_id}) await db.shows.update_one({"id": payload.show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}}) return _strip(doc) # ============ Episode bulk actions (admin) ============ @api.post("/episodes/bulk-transcode") async def bulk_transcode_eps(payload: dict, user: dict = Depends(require_admin)): ids = payload.get("episode_ids") or [] quality = payload.get("quality", "abr") if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality") queued = 0 for eid in ids: ep = await db.episodes.find_one({"id": eid}, {"_id": 0}) if not ep or ep.get("hls_status") in ("running", "pending"): continue if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): continue await _enqueue_transcode(eid, quality, triggered_by="manual", content_type="episode") queued += 1 return {"ok": True, "queued": queued} @api.post("/episodes/bulk-hide") async def bulk_hide_eps(payload: dict, user: dict = Depends(require_admin)): ids = payload.get("episode_ids") or [] hidden = bool(payload.get("hidden", True)) res = await db.episodes.update_many({"id": {"$in": ids}}, {"$set": {"hidden": hidden}}) return {"ok": True, "modified": res.modified_count} @api.post("/episodes/bulk-delete") async def bulk_delete_eps(payload: dict, user: dict = Depends(require_admin)): ids = payload.get("episode_ids") or [] import shutil as _sh for eid in ids: _sh.rmtree(HLS_DIR / eid, ignore_errors=True) res = await db.episodes.delete_many({"id": {"$in": ids}}) await db.episode_progress.delete_many({"episode_id": {"$in": ids}}) await db.subtitles.delete_many({"episode_id": {"$in": ids}}) return {"ok": True, "deleted": res.deleted_count} @api.post("/episodes/{episode_id}/transcode") async def transcode_episode(episode_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)): quality = (payload or {}).get("quality", "abr") if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality") ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0}) if not ep: raise HTTPException(status_code=404, detail="Episode not found") if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): raise HTTPException(status_code=400, detail="Only local/sonarr episodes can be transcoded") if ep.get("hls_status") in ("running", "pending"): raise HTTPException(status_code=409, detail="Already transcoding or queued") job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode") return {"ok": True, "job_id": job["id"]} @api.get("/stream/episode/{episode_id}") async def stream_episode( episode_id: str, request: Request, auth: Optional[str] = Query(None), authorization: Optional[str] = Header(None), ): token = authorization.split(" ", 1)[1].strip() if authorization and authorization.lower().startswith("bearer ") else auth if not token: raise HTTPException(status_code=401, detail="Not authenticated") decode_token(token) ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0}) if not ep: raise HTTPException(status_code=404, detail="Episode not found") if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"): raise HTTPException(status_code=400, detail="Episode has no local file") file_path = Path(ep["storage_path"]) if ep["storage_type"] in ("sonarr", "scan") else VIDEOS_DIR / ep["storage_path"] if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing") file_size = file_path.stat().st_size content_type = mimetypes.guess_type(str(file_path))[0] 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") 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"}) # Episode progress @api.post("/progress/episode") async def upsert_episode_progress(payload: EpisodeProgressUpsert, profile: dict = Depends(get_active_profile)): ep = await db.episodes.find_one({"id": payload.episode_id}, {"_id": 0}) if not ep: raise HTTPException(status_code=404, detail="Episode not found") now = datetime.now(timezone.utc).isoformat() await db.episode_progress.update_one( {"profile_id": profile["id"], "episode_id": payload.episode_id}, {"$set": { "profile_id": profile["id"], "user_id": profile["user_id"], "episode_id": payload.episode_id, "show_id": ep["show_id"], "position_seconds": payload.position_seconds, "duration_seconds": payload.duration_seconds, "updated_at": now, }}, upsert=True, ) # Trakt scrobble hook (fire and forget) asyncio.create_task(_trakt_scrobble_episode(profile["user_id"], ep, payload.position_seconds, payload.duration_seconds)) return {"ok": True} @api.get("/progress/episode/{episode_id}") async def get_episode_progress(episode_id: str, profile: dict = Depends(get_active_profile)): p = await db.episode_progress.find_one({"profile_id": profile["id"], "episode_id": episode_id}, {"_id": 0}) return p or {"position_seconds": 0, "duration_seconds": 0} @api.get("/progress/episodes/continue") async def continue_watching_episodes(profile: dict = Depends(get_active_profile)): rows = await db.episode_progress.find({"profile_id": profile["id"]}, {"_id": 0}).sort("updated_at", -1).to_list(30) 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 [] ep_ids = [r["episode_id"] for r in rows] eps = await db.episodes.find({"id": {"$in": ep_ids}}, {"_id": 0}).to_list(30) ep_by_id = {e["id"]: e for e in eps} show_ids = list({e["show_id"] for e in eps}) shows = await db.shows.find({"id": {"$in": show_ids}}, {"_id": 0}).to_list(30) show_by_id = {s["id"]: s for s in shows} out = [] for r in rows: e = ep_by_id.get(r["episode_id"]); s = show_by_id.get(e["show_id"]) if e else None if e and s: out.append({"episode": e, "show": s, "progress": r}) return out # ============ SONARR ============ @api.post("/sonarr/test") async def sonarr_test(user: dict = Depends(require_admin)): s = await get_settings() if not s.get("sonarr_url") or not s.get("sonarr_api_key"): raise HTTPException(status_code=400, detail="Sonarr not configured") ok = await asyncio.to_thread(sonarr_client.test_connection, s["sonarr_url"], s["sonarr_api_key"]) return {"ok": ok} @api.get("/sonarr/series", response_model=List[SonarrSeriesDTO]) async def sonarr_series(user: dict = Depends(require_admin)): s = await get_settings() if not s.get("sonarr_url") or not s.get("sonarr_api_key"): raise HTTPException(status_code=400, detail="Sonarr not configured") try: return await asyncio.to_thread(sonarr_client.list_series, s["sonarr_url"], s["sonarr_api_key"]) except Exception as e: raise HTTPException(status_code=502, detail=f"Sonarr error: {e}") @api.post("/sonarr/import") async def sonarr_import(payload: dict, user: dict = Depends(require_admin)): """Body: {sonarr_ids: [int]} — imports full series + all episodes with files.""" sonarr_ids = payload.get("sonarr_ids") or [] if not sonarr_ids: raise HTTPException(status_code=400, detail="No sonarr_ids") s = await get_settings() if not s.get("sonarr_url") or not s.get("sonarr_api_key"): raise HTTPException(status_code=400, detail="Sonarr not configured") all_series = await asyncio.to_thread(sonarr_client.list_series, s["sonarr_url"], s["sonarr_api_key"]) by_id = {x["sonarr_id"]: x for x in all_series} created_shows = 0; created_eps = 0 auto = s.get("auto_transcode", "off") for sid in sonarr_ids: m = by_id.get(int(sid)) if not m: continue existing = await db.shows.find_one({"sonarr_id": m["sonarr_id"]}, {"_id": 0}) if existing: show_id = existing["id"] else: show_doc = Show( title=m["title"], description=m.get("overview", ""), year=m.get("year") or 2024, rating="NR", poster_url=m.get("poster_url", ""), backdrop_url=m.get("poster_url", ""), sonarr_id=m["sonarr_id"], tvdb_id=m.get("tvdb_id"), tmdb_id=m.get("tmdb_id"), ).model_dump() await db.shows.insert_one(show_doc) show_id = show_doc["id"]; created_shows += 1 # Fetch episodes try: eps = await asyncio.to_thread(sonarr_client.list_episodes, s["sonarr_url"], s["sonarr_api_key"], m["sonarr_id"]) except Exception: continue for ep in eps: if not ep.get("has_file") or not ep.get("file_path"): continue if await db.episodes.find_one({"sonarr_episode_id": ep["sonarr_episode_id"]}, {"_id": 1}): continue edoc = Episode( show_id=show_id, season_number=ep["season_number"], episode_number=ep["episode_number"], title=ep.get("title", ""), description=ep.get("description", ""), air_date=ep.get("air_date", ""), duration_minutes=ep.get("duration_minutes", 0), storage_type="sonarr", storage_path=ep["file_path"], sonarr_episode_id=ep["sonarr_episode_id"], ).model_dump() await db.episodes.insert_one(edoc); created_eps += 1 if auto in ("quick", "abr"): await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode") # Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers) show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1}) if show_doc and show_doc.get("tmdb_id"): asyncio.create_task(_auto_fetch_subtitle( "episode", edoc["id"], show_doc["tmdb_id"], season=ep["season_number"], episode=ep["episode_number"], )) # Update counts seasons = await db.episodes.distinct("season_number", {"show_id": show_id}) ecount = await db.episodes.count_documents({"show_id": show_id}) await db.shows.update_one({"id": show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}}) return {"ok": True, "shows_imported": created_shows, "episodes_imported": created_eps} # ============ LIBRARY SCAN (filesystem — separate from Radarr/Sonarr) ============ @api.get("/scan/movies") async def scan_movies(user: dict = Depends(require_admin)): found = await asyncio.to_thread(library_scan.scan_movies) imported_paths = set(await db.movies.distinct("storage_path", {"storage_type": {"$in": ["radarr", "scan", "local"]}})) for item in found: item["already_imported"] = item["storage_path"] in imported_paths return found @api.get("/scan/tv") async def scan_tv(user: dict = Depends(require_admin)): found = await asyncio.to_thread(library_scan.scan_tv) imported_paths = set(await db.episodes.distinct("storage_path", {"storage_type": {"$in": ["sonarr", "scan", "local"]}})) for item in found: item["already_imported"] = item["storage_path"] in imported_paths return found @api.get("/scan/music") async def scan_music(user: dict = Depends(require_admin)): found = await asyncio.to_thread(library_scan.scan_music) imported_paths = set(await db.tracks.distinct("storage_path")) for item in found: item["already_imported"] = item["storage_path"] in imported_paths return found @api.post("/scan/import") async def scan_import(payload: dict, user: dict = Depends(require_admin)): """Body: {kind: "movies"|"tv"|"music", items: []}""" kind = payload.get("kind") items = payload.get("items") or [] if kind not in ("movies", "tv", "music"): raise HTTPException(status_code=400, detail="kind must be movies, tv, or music") if not items: raise HTTPException(status_code=400, detail="No items provided") s = await get_settings() auto = s.get("auto_transcode", "off") created = 0 if kind == "movies": for it in items: if await db.movies.find_one({"storage_path": it["storage_path"]}, {"_id": 1}): continue movie = Movie( title=it.get("title") or "Untitled", year=it.get("year") or 2024, storage_type="scan", storage_path=it["storage_path"], ).model_dump() await db.movies.insert_one(movie) if auto in ("quick", "abr"): await _enqueue_transcode(movie["id"], auto, triggered_by="auto") created += 1 elif kind == "tv": shows_by_title: Dict[str, str] = {} for it in items: if await db.episodes.find_one({"storage_path": it["storage_path"]}, {"_id": 1}): continue show_title = it.get("show_title") or "Untitled" show_id = shows_by_title.get(show_title) if not show_id: existing = await db.shows.find_one({"title": show_title}, {"_id": 0}) if existing: show_id = existing["id"] else: show_doc = Show(title=show_title, year=2024, rating="NR").model_dump() await db.shows.insert_one(show_doc) show_id = show_doc["id"] shows_by_title[show_title] = show_id edoc = Episode( show_id=show_id, season_number=it.get("season_number") or 1, episode_number=it.get("episode_number") or 0, title=it.get("title") or "", storage_type="scan", storage_path=it["storage_path"], ).model_dump() await db.episodes.insert_one(edoc) if auto in ("quick", "abr"): await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode") created += 1 for show_id in set(shows_by_title.values()): seasons = await db.episodes.distinct("season_number", {"show_id": show_id}) ecount = await db.episodes.count_documents({"show_id": show_id}) await db.shows.update_one({"id": show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}}) elif kind == "music": for it in items: if await db.tracks.find_one({"storage_path": it["storage_path"]}, {"_id": 1}): continue track = Track( title=it.get("title") or "Untitled", artist=it.get("artist") or "Unknown Artist", album=it.get("album") or "Unknown Album", track_number=it.get("track_number"), duration_seconds=it.get("duration_seconds") or 0, storage_path=it["storage_path"], ).model_dump() await db.tracks.insert_one(track) created += 1 return {"ok": True, "imported": created} # ============ MUSIC ============ @api.get("/tracks", response_model=List[Track]) async def list_tracks(user: dict = Depends(get_current_user)): docs = await db.tracks.find({}, {"_id": 0}).sort([("artist", 1), ("album", 1), ("track_number", 1)]).to_list(10000) return docs @api.patch("/tracks/{track_id}", response_model=Track) async def update_track(track_id: str, payload: TrackUpdate, 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.tracks.find_one_and_update( {"id": track_id}, {"$set": updates}, projection={"_id": 0}, return_document=True, ) if not res: raise HTTPException(status_code=404, detail="Track not found") return res @api.delete("/tracks/{track_id}") async def delete_track(track_id: str, user: dict = Depends(require_admin)): res = await db.tracks.delete_one({"id": track_id}) if res.deleted_count == 0: raise HTTPException(status_code=404, detail="Track not found") return {"ok": True} _music_enrich_status = {"running": False, "total": 0, "done": 0, "updated": 0, "error": ""} async def _run_music_enrich(): _music_enrich_status.update(running=True, total=0, done=0, updated=0, error="") try: pairs = await db.tracks.aggregate([ {"$match": {"album_art_url": {"$in": [None, ""]}}}, {"$group": {"_id": {"artist": "$artist", "album": "$album"}}}, ]).to_list(5000) _music_enrich_status["total"] = len(pairs) for p in pairs: artist = p["_id"]["artist"]; album = p["_id"]["album"] try: release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album) if release and release.get("mbid"): art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"]) if art_url: res = await db.tracks.update_many( {"artist": artist, "album": album}, {"$set": {"album_art_url": art_url}}, ) _music_enrich_status["updated"] += res.modified_count except Exception as e: logger.warning(f"Music enrich failed for {artist} - {album}: {e}") _music_enrich_status["done"] += 1 await asyncio.sleep(1.1) # MusicBrainz rate limit: 1 req/sec, unauthenticated except Exception as e: _music_enrich_status["error"] = str(e) finally: _music_enrich_status["running"] = False @api.post("/music/enrich") async def start_music_enrich(user: dict = Depends(require_admin)): if _music_enrich_status["running"]: raise HTTPException(status_code=409, detail="Enrichment already running") asyncio.create_task(_run_music_enrich()) return {"ok": True} @api.get("/music/enrich/status") async def music_enrich_status(user: dict = Depends(require_admin)): return _music_enrich_status @api.get("/stream/track/{track_id}") async def stream_track( track_id: str, request: Request, auth: Optional[str] = Query(None), authorization: Optional[str] = Header(None), ): token = authorization.split(" ", 1)[1].strip() if authorization and authorization.lower().startswith("bearer ") else auth if not token: raise HTTPException(status_code=401, detail="Not authenticated") decode_token(token) track = await db.tracks.find_one({"id": track_id}, {"_id": 0}) if not track: raise HTTPException(status_code=404, detail="Track not found") file_path = Path(track["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))[0] or "audio/mpeg" 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"}) # ============ TRAKT ============ async def _trakt_get_token(user_id: str) -> Optional[dict]: return await db.trakt_tokens.find_one({"user_id": user_id}, {"_id": 0}) async def _trakt_scrobble_movie(user_id: str, movie: dict, position: float, duration: float): if not movie.get("tmdb_id") or duration <= 0: return tok = await _trakt_get_token(user_id) if not tok: return s = await get_settings() if not s.get("trakt_client_id"): return progress = (position / duration) * 100.0 action = "stop" if progress >= 80 else ("pause" if progress > 0 else "start") try: await asyncio.to_thread(trakt_client.scrobble_movie, s["trakt_client_id"], tok["access_token"], movie["tmdb_id"], progress, action) except Exception as e: logger.warning(f"Trakt scrobble movie failed: {e}") async def _trakt_scrobble_episode(user_id: str, ep: dict, position: float, duration: float): if duration <= 0: return tok = await _trakt_get_token(user_id) if not tok: return s = await get_settings() if not s.get("trakt_client_id"): return show = await db.shows.find_one({"id": ep["show_id"]}, {"_id": 0}) if not show or (not show.get("tvdb_id") and not show.get("tmdb_id")): return progress = (position / duration) * 100.0 action = "stop" if progress >= 80 else ("pause" if progress > 0 else "start") try: await asyncio.to_thread(trakt_client.scrobble_episode, s["trakt_client_id"], tok["access_token"], show.get("tvdb_id"), show.get("tmdb_id"), ep["season_number"], ep["episode_number"], progress, action) except Exception as e: logger.warning(f"Trakt scrobble episode failed: {e}") @api.post("/trakt/device-code", response_model=TraktDeviceCodeResponse) async def trakt_device_code(user: dict = Depends(get_current_user)): s = await get_settings() if not s.get("trakt_client_id"): raise HTTPException(status_code=400, detail="Trakt not configured (admin sets client_id/secret)") try: return await asyncio.to_thread(trakt_client.device_code, s["trakt_client_id"]) except Exception as e: raise HTTPException(status_code=502, detail=f"Trakt error: {e}") @api.post("/trakt/poll") async def trakt_poll(payload: dict, user: dict = Depends(get_current_user)): device_code_val = payload.get("device_code") if not device_code_val: raise HTTPException(status_code=400, detail="device_code required") s = await get_settings() if not s.get("trakt_client_id") or not s.get("trakt_client_secret"): raise HTTPException(status_code=400, detail="Trakt not configured") try: tok = await asyncio.to_thread(trakt_client.poll_token, s["trakt_client_id"], s["trakt_client_secret"], device_code_val) except Exception as e: raise HTTPException(status_code=502, detail=f"Trakt error: {e}") if not tok: return {"ok": False, "pending": True} try: us = await asyncio.to_thread(trakt_client.user_settings, s["trakt_client_id"], tok["access_token"]) username = ((us or {}).get("user") or {}).get("username", "") except Exception: username = "" await db.trakt_tokens.update_one( {"user_id": user["id"]}, {"$set": {"user_id": user["id"], "access_token": tok["access_token"], "refresh_token": tok.get("refresh_token", ""), "expires_in": tok.get("expires_in", 0), "username": username, "created_at": datetime.now(timezone.utc).isoformat()}}, upsert=True, ) return {"ok": True, "username": username} @api.get("/trakt/status", response_model=TraktStatus) async def trakt_status(user: dict = Depends(get_current_user)): tok = await _trakt_get_token(user["id"]) if not tok: return TraktStatus(connected=False) return TraktStatus(connected=True, username=tok.get("username", "")) @api.delete("/trakt/disconnect") async def trakt_disconnect(user: dict = Depends(get_current_user)): await db.trakt_tokens.delete_one({"user_id": user["id"]}) return {"ok": True} # ============ OPENSUBTITLES ============ @api.get("/opensubs/search", response_model=List[OpenSubsResult]) async def opensubs_search(tmdb_id: Optional[int] = None, q: Optional[str] = None, languages: str = "en", season: Optional[int] = None, episode: Optional[int] = None, user: dict = Depends(require_admin)): s = await get_settings() if not s.get("opensubs_api_key"): raise HTTPException(status_code=400, detail="OpenSubs not configured") try: return await asyncio.to_thread(opensubs_client.search, s["opensubs_api_key"], tmdb_id, q, languages, episode, season) except Exception as e: raise HTTPException(status_code=502, detail=f"OpenSubs error: {e}") @api.post("/opensubs/download") async def opensubs_download(payload: dict, user: dict = Depends(require_admin)): """Body: {file_id, movie_id?, episode_id?, language, label}""" file_id = payload.get("file_id") if not file_id: raise HTTPException(status_code=400, detail="file_id required") movie_id = payload.get("movie_id"); episode_id = payload.get("episode_id") if not movie_id and not episode_id: raise HTTPException(status_code=400, detail="movie_id or episode_id required") lang = payload.get("language", "en"); label = payload.get("label", "English") s = await get_settings() if not s.get("opensubs_api_key"): raise HTTPException(status_code=400, detail="OpenSubs not configured") try: link = await asyncio.to_thread(opensubs_client.get_download_link, s["opensubs_api_key"], file_id) srt = await asyncio.to_thread(opensubs_client.download_content, link) vtt = srt_to_vtt(srt) if "WEBVTT" not in srt[:20] else srt except Exception as e: raise HTTPException(status_code=502, detail=f"OpenSubs download error: {e}") sid = str(uuid.uuid4()); fname = f"{sid}.vtt" (SUBS_DIR / fname).write_text(vtt, encoding="utf-8") sub_doc = { "id": sid, "movie_id": movie_id or "", "episode_id": episode_id or "", "language": lang, "label": label, "storage_path": fname, "is_default": False, "created_at": datetime.now(timezone.utc).isoformat(), } await db.subtitles.insert_one(sub_doc) _strip(sub_doc) return {"ok": True, "subtitle": sub_doc} # ============ BULK TMDB ENRICHMENT ============ @api.post("/tmdb/enrich-all") async def tmdb_enrich_all(user: dict = Depends(require_admin)): """Sweep movies with missing metadata and fill from TMDB (by title match).""" s = await get_settings() key = s.get("tmdb_api_key", "") if not key: raise HTTPException(status_code=400, detail="TMDB not configured") enriched = 0; skipped = 0; failed = 0 async for m in db.movies.find({}, {"_id": 0}): needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url") if not needs: skipped += 1; continue try: if m.get("tmdb_id"): data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"]) else: results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"]) if not results: failed += 1; continue data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"]) updates = {} for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"): if data.get(field) and not m.get(field): updates[field] = data[field] if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"] if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"] if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"] if updates: await db.movies.update_one({"id": m["id"]}, {"$set": updates}) enriched += 1 except Exception as e: logger.warning(f"Enrich failed for {m.get('title')}: {e}") failed += 1 return {"ok": True, "enriched": enriched, "skipped": skipped, "failed": failed} async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int], season: Optional[int] = None, episode: Optional[int] = None, language: str = "en", label: str = "English"): """Best-effort background: pull first OpenSubs result and attach to movie/episode.""" if not tmdb_id: return s = await get_settings() key = s.get("opensubs_api_key", "") if not key: return try: results = await asyncio.to_thread(opensubs_client.search, key, tmdb_id, None, language, episode, season) if not results: return r = results[0] link = await asyncio.to_thread(opensubs_client.get_download_link, key, r["file_id"]) srt = await asyncio.to_thread(opensubs_client.download_content, link) vtt = srt_to_vtt(srt) if "WEBVTT" not in srt[:20] else srt except Exception as e: logger.warning(f"Auto-subtitle failed for {content_type} {content_id}: {e}") return sid = str(uuid.uuid4()); fname = f"{sid}.vtt" (SUBS_DIR / fname).write_text(vtt, encoding="utf-8") doc = { "id": sid, "movie_id": content_id if content_type == "movie" else "", "episode_id": content_id if content_type == "episode" else "", "language": language, "label": label, "storage_path": fname, "is_default": True, "created_at": datetime.now(timezone.utc).isoformat(), } await db.subtitles.insert_one(doc) logger.info(f"Auto-attached subtitle for {content_type} {content_id[:8]}") # ============ SERVICES (admin) ============ @api.get("/services") async def services_status(user: dict = Depends(require_admin)): return await asyncio.to_thread(services_client.list_service_status) @api.post("/services/{key}/restart") async def services_restart(key: str, user: dict = Depends(require_admin)): ok = await asyncio.to_thread(services_client.restart_service, key) if not ok: raise HTTPException(status_code=404, detail="Unknown service") return {"ok": True} # ============ HEALTH ============ @api.get("/") async def root(): return {"app": "StreamHoard", "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"], )