mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -05:00
auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc
This commit is contained in:
+444
-137
@@ -2,11 +2,12 @@ import os
|
||||
import uuid
|
||||
import logging
|
||||
import mimetypes
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request
|
||||
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse, FileResponse, Response
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from dotenv import load_dotenv
|
||||
@@ -15,17 +16,21 @@ from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
from models import (
|
||||
UserCreate, UserLogin, UserPublic, TokenResponse,
|
||||
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
|
||||
Movie, MovieCreate, MovieUpdate,
|
||||
WatchlistItem, ProgressUpsert, Progress,
|
||||
Subtitle,
|
||||
WatchlistItem, ProgressUpsert,
|
||||
RequestCreate, RequestUpdate, MovieRequest,
|
||||
AppSettings, AppSettingsPublic,
|
||||
TMDBSearchResult, RadarrMovieDTO,
|
||||
)
|
||||
from auth import (
|
||||
hash_password, verify_password, create_token, decode_token,
|
||||
)
|
||||
from auth import hash_password, verify_password, create_token, decode_token
|
||||
from seed import SAMPLE_MOVIES
|
||||
import tmdb as tmdb_client
|
||||
import radarr as radarr_client
|
||||
from transcode import transcode_to_hls, srt_to_vtt
|
||||
|
||||
|
||||
# ---------- Setup ----------
|
||||
ROOT_DIR = Path(__file__).parent
|
||||
load_dotenv(ROOT_DIR / ".env")
|
||||
|
||||
@@ -39,12 +44,14 @@ db = client[os.environ["DB_NAME"]]
|
||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", str(ROOT_DIR / "media")))
|
||||
VIDEOS_DIR = MEDIA_ROOT / "videos"
|
||||
POSTERS_DIR = MEDIA_ROOT / "posters"
|
||||
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
POSTERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SUBS_DIR = MEDIA_ROOT / "subtitles"
|
||||
HLS_DIR = MEDIA_ROOT / "hls"
|
||||
for d in (VIDEOS_DIR, POSTERS_DIR, SUBS_DIR, HLS_DIR):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
app = FastAPI(title="Kino — Personal Media Server")
|
||||
app = FastAPI(title="Kino")
|
||||
api = APIRouter(prefix="/api")
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -60,30 +67,46 @@ async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depen
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_optional(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> Optional[dict]:
|
||||
if not creds:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(creds.credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
return await db.users.find_one({"id": payload["sub"]}, {"_id": 0, "password_hash": 0})
|
||||
|
||||
|
||||
async def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
return user
|
||||
|
||||
|
||||
# ---------- Startup: seed admin + sample movies ----------
|
||||
async def get_active_profile(
|
||||
x_profile_id: Optional[str] = Header(None),
|
||||
user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Resolve current profile via X-Profile-Id header, else default profile."""
|
||||
if x_profile_id:
|
||||
p = await db.profiles.find_one({"id": x_profile_id, "user_id": user["id"]}, {"_id": 0})
|
||||
if p:
|
||||
return p
|
||||
p = await db.profiles.find_one({"user_id": user["id"]}, {"_id": 0})
|
||||
if not p:
|
||||
# auto-create default
|
||||
prof = Profile(user_id=user["id"], name=user.get("name", "Main")).model_dump()
|
||||
await db.profiles.insert_one(prof)
|
||||
prof.pop("_id", None)
|
||||
return prof
|
||||
return p
|
||||
|
||||
|
||||
# ---------- Settings helper ----------
|
||||
async def get_settings() -> dict:
|
||||
doc = await db.settings.find_one({"id": "app"}, {"_id": 0})
|
||||
if not doc:
|
||||
return AppSettings().model_dump() | {"id": "app"}
|
||||
return doc
|
||||
|
||||
|
||||
# ---------- Startup ----------
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
# Index for unique email
|
||||
await db.users.create_index("email", unique=True)
|
||||
await db.movies.create_index("title")
|
||||
await db.watchlist.create_index([("user_id", 1), ("movie_id", 1)], unique=True)
|
||||
await db.progress.create_index([("user_id", 1), ("movie_id", 1)], unique=True)
|
||||
await db.profiles.create_index("user_id")
|
||||
await db.subtitles.create_index("movie_id")
|
||||
|
||||
admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local")
|
||||
if not await db.users.find_one({"email": admin_email}):
|
||||
@@ -96,15 +119,44 @@ async def on_startup():
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await db.users.insert_one(admin_user)
|
||||
logger.info(f"Seeded admin user: {admin_email}")
|
||||
logger.info(f"Seeded admin: {admin_email}")
|
||||
|
||||
# Seed movies if empty
|
||||
count = await db.movies.count_documents({})
|
||||
if count == 0:
|
||||
if await db.movies.count_documents({}) == 0:
|
||||
for m in SAMPLE_MOVIES:
|
||||
doc = Movie(**m).model_dump()
|
||||
await db.movies.insert_one(doc)
|
||||
logger.info(f"Seeded {len(SAMPLE_MOVIES)} sample movies")
|
||||
await db.movies.insert_one(Movie(**m).model_dump())
|
||||
logger.info(f"Seeded {len(SAMPLE_MOVIES)} movies")
|
||||
|
||||
# ---- Migration: ensure every user has a default profile, backfill watchlist/progress ----
|
||||
async for u in db.users.find({}, {"_id": 0, "id": 1, "name": 1}):
|
||||
if not await db.profiles.find_one({"user_id": u["id"]}):
|
||||
prof = Profile(user_id=u["id"], name=u.get("name") or "Main").model_dump()
|
||||
await db.profiles.insert_one(prof)
|
||||
await db.watchlist.update_many(
|
||||
{"user_id": u["id"], "$or": [{"profile_id": {"$exists": False}}, {"profile_id": None}]},
|
||||
{"$set": {"profile_id": prof["id"]}},
|
||||
)
|
||||
await db.progress.update_many(
|
||||
{"user_id": u["id"], "$or": [{"profile_id": {"$exists": False}}, {"profile_id": None}]},
|
||||
{"$set": {"profile_id": prof["id"]}},
|
||||
)
|
||||
|
||||
# Now build unique indexes (migration above ensures no null collisions)
|
||||
try:
|
||||
await db.watchlist.drop_index("user_id_1_movie_id_1")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await db.watchlist.create_index([("profile_id", 1), ("movie_id", 1)], unique=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"watchlist unique index skipped: {e}")
|
||||
try:
|
||||
await db.progress.drop_index("user_id_1_movie_id_1")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await db.progress.create_index([("profile_id", 1), ("movie_id", 1)], unique=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"progress unique index skipped: {e}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
@@ -112,7 +164,6 @@ async def on_shutdown():
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------- Helpers ----------
|
||||
def _user_public(u: dict) -> UserPublic:
|
||||
return UserPublic(
|
||||
id=u["id"], email=u["email"], name=u["name"],
|
||||
@@ -120,12 +171,23 @@ def _user_public(u: dict) -> UserPublic:
|
||||
)
|
||||
|
||||
|
||||
def _strip(doc: dict) -> dict:
|
||||
doc.pop("_id", None)
|
||||
return doc
|
||||
def _strip(d: dict) -> dict:
|
||||
d.pop("_id", None)
|
||||
return d
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
def _rating_allowed(profile: dict, rating: str) -> bool:
|
||||
"""Profile.max_rating is the strictest a profile can see."""
|
||||
cap = profile.get("max_rating", "NR")
|
||||
if cap == "NR":
|
||||
return True
|
||||
try:
|
||||
return RATING_ORDER.index(rating) <= RATING_ORDER.index(cap)
|
||||
except ValueError:
|
||||
return True # unknown rating → allow
|
||||
|
||||
|
||||
# ============ AUTH ============
|
||||
@api.post("/auth/register", response_model=TokenResponse)
|
||||
async def register(payload: UserCreate):
|
||||
if await db.users.find_one({"email": payload.email.lower()}):
|
||||
@@ -139,8 +201,9 @@ async def register(payload: UserCreate):
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await db.users.insert_one(user)
|
||||
token = create_token(user["id"], False)
|
||||
return TokenResponse(access_token=token, user=_user_public(user))
|
||||
# auto-create default profile
|
||||
await db.profiles.insert_one(Profile(user_id=user["id"], name=user["name"]).model_dump())
|
||||
return TokenResponse(access_token=create_token(user["id"], False), user=_user_public(user))
|
||||
|
||||
|
||||
@api.post("/auth/login", response_model=TokenResponse)
|
||||
@@ -148,8 +211,7 @@ async def login(payload: UserLogin):
|
||||
user = await db.users.find_one({"email": payload.email.lower()})
|
||||
if not user or not verify_password(payload.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
token = create_token(user["id"], user.get("is_admin", False))
|
||||
return TokenResponse(access_token=token, user=_user_public(user))
|
||||
return TokenResponse(access_token=create_token(user["id"], user.get("is_admin", False)), user=_user_public(user))
|
||||
|
||||
|
||||
@api.get("/auth/me", response_model=UserPublic)
|
||||
@@ -157,10 +219,65 @@ async def me(user: dict = Depends(get_current_user)):
|
||||
return _user_public(user)
|
||||
|
||||
|
||||
# ---------- Movies ----------
|
||||
# ============ PROFILES ============
|
||||
@api.get("/profiles", response_model=List[Profile])
|
||||
async def list_profiles(user: dict = Depends(get_current_user)):
|
||||
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(20)
|
||||
return docs
|
||||
|
||||
|
||||
@api.post("/profiles", response_model=Profile)
|
||||
async def create_profile(payload: ProfileCreate, user: dict = Depends(get_current_user)):
|
||||
count = await db.profiles.count_documents({"user_id": user["id"]})
|
||||
if count >= 5:
|
||||
raise HTTPException(status_code=400, detail="Max 5 profiles per account")
|
||||
prof = Profile(user_id=user["id"], **payload.model_dump()).model_dump()
|
||||
await db.profiles.insert_one(prof)
|
||||
return _strip(prof)
|
||||
|
||||
|
||||
@api.patch("/profiles/{pid}", response_model=Profile)
|
||||
async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)):
|
||||
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
res = await db.profiles.find_one_and_update(
|
||||
{"id": pid, "user_id": user["id"]},
|
||||
{"$set": updates},
|
||||
projection={"_id": 0}, return_document=True,
|
||||
)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
return res
|
||||
|
||||
|
||||
@api.delete("/profiles/{pid}")
|
||||
async def delete_profile(pid: str, user: dict = Depends(get_current_user)):
|
||||
if await db.profiles.count_documents({"user_id": user["id"]}) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the only profile")
|
||||
res = await db.profiles.delete_one({"id": pid, "user_id": user["id"]})
|
||||
if res.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
await db.watchlist.delete_many({"profile_id": pid})
|
||||
await db.progress.delete_many({"profile_id": pid})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ MOVIES ============
|
||||
def _movie_filter_for_profile(profile: dict) -> dict:
|
||||
cap = profile.get("max_rating", "NR")
|
||||
if cap == "NR":
|
||||
return {}
|
||||
allowed = RATING_ORDER[:RATING_ORDER.index(cap) + 1]
|
||||
return {"rating": {"$in": allowed + ["NR"]}}
|
||||
|
||||
|
||||
@api.get("/movies", response_model=List[Movie])
|
||||
async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
|
||||
query: dict = {}
|
||||
async def list_movies(
|
||||
genre: Optional[str] = None, q: Optional[str] = None, limit: int = 200,
|
||||
profile: dict = Depends(get_active_profile),
|
||||
):
|
||||
query: dict = _movie_filter_for_profile(profile)
|
||||
if genre:
|
||||
query["genres"] = {"$regex": f"^{genre}$", "$options": "i"}
|
||||
if q:
|
||||
@@ -175,10 +292,11 @@ async def list_movies(genre: Optional[str] = None, q: Optional[str] = None, limi
|
||||
|
||||
|
||||
@api.get("/movies/featured", response_model=Movie)
|
||||
async def get_featured():
|
||||
doc = await db.movies.find_one({"featured": True}, {"_id": 0})
|
||||
async def get_featured(profile: dict = Depends(get_active_profile)):
|
||||
base = _movie_filter_for_profile(profile)
|
||||
doc = await db.movies.find_one({**base, "featured": True}, {"_id": 0})
|
||||
if not doc:
|
||||
doc = await db.movies.find_one({}, {"_id": 0})
|
||||
doc = await db.movies.find_one(base, {"_id": 0})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="No movies")
|
||||
return doc
|
||||
@@ -224,19 +342,20 @@ async def delete_movie(movie_id: str, user: dict = Depends(require_admin)):
|
||||
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not movie:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
# delete local files if any
|
||||
if movie.get("storage_type") == "local" and movie.get("storage_path"):
|
||||
try:
|
||||
(VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
try: (VIDEOS_DIR / movie["storage_path"]).unlink(missing_ok=True)
|
||||
except Exception: pass
|
||||
if movie.get("hls_path"):
|
||||
import shutil
|
||||
shutil.rmtree(HLS_DIR / movie_id, ignore_errors=True)
|
||||
await db.movies.delete_one({"id": movie_id})
|
||||
await db.watchlist.delete_many({"movie_id": movie_id})
|
||||
await db.progress.delete_many({"movie_id": movie_id})
|
||||
await db.subtitles.delete_many({"movie_id": movie_id})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------- Upload (admin) ----------
|
||||
# ============ UPLOAD VIDEO ============
|
||||
@api.post("/upload/video", response_model=Movie)
|
||||
async def upload_video(
|
||||
title: str = Form(...),
|
||||
@@ -244,12 +363,13 @@ async def upload_video(
|
||||
year: int = Form(2024),
|
||||
duration_minutes: int = Form(0),
|
||||
rating: str = Form("NR"),
|
||||
genres: str = Form(""), # comma-separated
|
||||
genres: str = Form(""),
|
||||
cast: str = Form(""),
|
||||
director: str = Form(""),
|
||||
poster_url: str = Form(""),
|
||||
backdrop_url: str = Form(""),
|
||||
featured: bool = Form(False),
|
||||
tmdb_id: Optional[int] = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(require_admin),
|
||||
):
|
||||
@@ -257,62 +377,50 @@ async def upload_video(
|
||||
safe_id = str(uuid.uuid4())
|
||||
fname = f"{safe_id}.{ext}"
|
||||
target = VIDEOS_DIR / fname
|
||||
|
||||
# stream save
|
||||
with target.open("wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
if not chunk: break
|
||||
f.write(chunk)
|
||||
|
||||
movie = Movie(
|
||||
title=title,
|
||||
description=description,
|
||||
year=year,
|
||||
duration_minutes=duration_minutes,
|
||||
rating=rating,
|
||||
title=title, description=description, year=year,
|
||||
duration_minutes=duration_minutes, rating=rating,
|
||||
genres=[g.strip() for g in genres.split(",") if g.strip()],
|
||||
cast=[c.strip() for c in cast.split(",") if c.strip()],
|
||||
director=director,
|
||||
poster_url=poster_url,
|
||||
backdrop_url=backdrop_url,
|
||||
video_url="", # filled with stream endpoint client-side
|
||||
storage_type="local",
|
||||
storage_path=fname,
|
||||
featured=featured,
|
||||
director=director, poster_url=poster_url, backdrop_url=backdrop_url,
|
||||
video_url="", storage_type="local", storage_path=fname,
|
||||
featured=featured, tmdb_id=tmdb_id,
|
||||
)
|
||||
doc = movie.model_dump()
|
||||
await db.movies.insert_one(doc)
|
||||
return _strip(doc)
|
||||
|
||||
|
||||
# ---------- Streaming with Range support ----------
|
||||
# ============ STREAM (Range-aware) ============
|
||||
@api.get("/stream/{movie_id}")
|
||||
async def stream_movie(
|
||||
movie_id: str,
|
||||
request: Request,
|
||||
movie_id: str, request: Request,
|
||||
auth: Optional[str] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
# token check (header OR ?auth= for <video> tag)
|
||||
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) # raises 401 if invalid
|
||||
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") != "local" or not movie.get("storage_path"):
|
||||
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")
|
||||
|
||||
file_path = VIDEOS_DIR / movie["storage_path"]
|
||||
if movie.get("storage_type") == "radarr":
|
||||
file_path = Path(movie["storage_path"]) # absolute path on Radarr-mounted storage
|
||||
else:
|
||||
file_path = VIDEOS_DIR / movie["storage_path"]
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File missing on disk")
|
||||
|
||||
@@ -339,106 +447,205 @@ async def stream_movie(
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = f.read(min(CHUNK_SIZE, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
if not chunk: break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
headers = {
|
||||
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 StreamingResponse(iter_file(), status_code=206, headers=headers, media_type=content_type)
|
||||
|
||||
# full file
|
||||
})
|
||||
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
|
||||
|
||||
|
||||
# ---------- Watchlist ----------
|
||||
# ============ HLS ============
|
||||
async def _set_hls_status(movie_id: str, status: str, path: Optional[str] = None, error: Optional[str] = None):
|
||||
update = {"hls_status": status}
|
||||
if path is not None: update["hls_path"] = path
|
||||
await db.movies.update_one({"id": movie_id}, {"$set": update})
|
||||
if error:
|
||||
logger.warning(f"HLS {movie_id}: {error}")
|
||||
|
||||
|
||||
async def _run_transcode(movie_id: str, source: Path):
|
||||
out_dir = HLS_DIR / movie_id
|
||||
async def cb(status, error=None):
|
||||
if status == "done":
|
||||
await _set_hls_status(movie_id, "done", path=f"{movie_id}/playlist.m3u8")
|
||||
else:
|
||||
await _set_hls_status(movie_id, status, error=error)
|
||||
await transcode_to_hls(source, out_dir, cb)
|
||||
|
||||
|
||||
@api.post("/movies/{movie_id}/transcode")
|
||||
async def trigger_transcode(movie_id: str, user: dict = Depends(require_admin)):
|
||||
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
|
||||
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
|
||||
if movie.get("hls_status") == "running":
|
||||
raise HTTPException(status_code=409, detail="Already transcoding")
|
||||
source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"]
|
||||
await _set_hls_status(movie_id, "pending")
|
||||
asyncio.create_task(_run_transcode(movie_id, source))
|
||||
return {"ok": True, "status": "pending"}
|
||||
|
||||
|
||||
@api.get("/movies/{movie_id}/hls/{filename:path}")
|
||||
async def serve_hls(
|
||||
movie_id: str, filename: str,
|
||||
auth: Optional[str] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
token = None
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
elif auth:
|
||||
token = auth
|
||||
if not token: raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
decode_token(token)
|
||||
|
||||
target = (HLS_DIR / movie_id / filename).resolve()
|
||||
base = (HLS_DIR / movie_id).resolve()
|
||||
if not str(target).startswith(str(base)):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="HLS file not found")
|
||||
media_type = "application/vnd.apple.mpegurl" if filename.endswith(".m3u8") else "video/mp2t"
|
||||
return FileResponse(str(target), media_type=media_type)
|
||||
|
||||
|
||||
# ============ SUBTITLES ============
|
||||
@api.get("/movies/{movie_id}/subtitles", response_model=List[Subtitle])
|
||||
async def list_subs(movie_id: str, user: dict = Depends(get_current_user)):
|
||||
docs = await db.subtitles.find({"movie_id": movie_id}, {"_id": 0}).sort("created_at", 1).to_list(20)
|
||||
return docs
|
||||
|
||||
|
||||
@api.post("/movies/{movie_id}/subtitles", response_model=Subtitle)
|
||||
async def upload_sub(
|
||||
movie_id: str,
|
||||
language: str = Form("en"),
|
||||
label: str = Form("English"),
|
||||
is_default: bool = Form(False),
|
||||
file: UploadFile = File(...),
|
||||
user: dict = Depends(require_admin),
|
||||
):
|
||||
if not await db.movies.find_one({"id": movie_id}, {"_id": 1}):
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
raw = await file.read()
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
is_srt = (file.filename or "").lower().endswith(".srt") or "WEBVTT" not in text[:20]
|
||||
if is_srt:
|
||||
text = srt_to_vtt(text)
|
||||
sid = str(uuid.uuid4())
|
||||
fname = f"{sid}.vtt"
|
||||
(SUBS_DIR / fname).write_text(text, encoding="utf-8")
|
||||
sub = Subtitle(id=sid, movie_id=movie_id, language=language, label=label,
|
||||
storage_path=fname, is_default=is_default).model_dump()
|
||||
if is_default:
|
||||
await db.subtitles.update_many({"movie_id": movie_id}, {"$set": {"is_default": False}})
|
||||
await db.subtitles.insert_one(sub)
|
||||
return _strip(sub)
|
||||
|
||||
|
||||
@api.delete("/subtitles/{sub_id}")
|
||||
async def delete_sub(sub_id: str, user: dict = Depends(require_admin)):
|
||||
sub = await db.subtitles.find_one({"id": sub_id}, {"_id": 0})
|
||||
if not sub: raise HTTPException(status_code=404, detail="Subtitle not found")
|
||||
try: (SUBS_DIR / sub["storage_path"]).unlink(missing_ok=True)
|
||||
except Exception: pass
|
||||
await db.subtitles.delete_one({"id": sub_id})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/subtitles/{sub_id}/file")
|
||||
async def serve_sub(
|
||||
sub_id: str,
|
||||
auth: Optional[str] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
token = None
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
elif auth:
|
||||
token = auth
|
||||
if not token: raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
decode_token(token)
|
||||
sub = await db.subtitles.find_one({"id": sub_id}, {"_id": 0})
|
||||
if not sub: raise HTTPException(status_code=404, detail="Subtitle not found")
|
||||
return FileResponse(str(SUBS_DIR / sub["storage_path"]), media_type="text/vtt")
|
||||
|
||||
|
||||
# ============ WATCHLIST ============
|
||||
@api.get("/watchlist", response_model=List[Movie])
|
||||
async def get_watchlist(user: dict = Depends(get_current_user)):
|
||||
items = await db.watchlist.find({"user_id": user["id"]}, {"_id": 0}).sort("added_at", -1).to_list(500)
|
||||
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 []
|
||||
if not movie_ids: return []
|
||||
movies = await db.movies.find({"id": {"$in": movie_ids}}, {"_id": 0}).to_list(500)
|
||||
# preserve order
|
||||
by_id = {m["id"]: m for m in movies}
|
||||
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, user: dict = Depends(get_current_user)):
|
||||
async def add_watchlist(movie_id: str, profile: dict = Depends(get_active_profile)):
|
||||
if not await db.movies.find_one({"id": movie_id}, {"_id": 1}):
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
item = WatchlistItem(user_id=user["id"], movie_id=movie_id).model_dump()
|
||||
try:
|
||||
await db.watchlist.insert_one(item)
|
||||
except Exception:
|
||||
pass # duplicate ok
|
||||
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, user: dict = Depends(get_current_user)):
|
||||
await db.watchlist.delete_one({"user_id": user["id"], "movie_id": 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 (continue watching) ----------
|
||||
# ============ PROGRESS ============
|
||||
@api.post("/progress")
|
||||
async def upsert_progress(payload: ProgressUpsert, user: dict = Depends(get_current_user)):
|
||||
async def upsert_progress(payload: ProgressUpsert, profile: dict = Depends(get_active_profile)):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await db.progress.update_one(
|
||||
{"user_id": user["id"], "movie_id": payload.movie_id},
|
||||
{"profile_id": profile["id"], "movie_id": payload.movie_id},
|
||||
{"$set": {
|
||||
"user_id": user["id"],
|
||||
"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,
|
||||
}}, upsert=True,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/progress/continue", response_model=List[dict])
|
||||
async def continue_watching(user: dict = Depends(get_current_user)):
|
||||
rows = await db.progress.find(
|
||||
{"user_id": user["id"]}, {"_id": 0}
|
||||
).sort("updated_at", -1).to_list(50)
|
||||
# filter out completed (>95%)
|
||||
@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 []
|
||||
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}
|
||||
out = []
|
||||
for r in rows:
|
||||
m = by_id.get(r["movie_id"])
|
||||
if m:
|
||||
out.append({**m, "progress": r})
|
||||
return out
|
||||
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, user: dict = Depends(get_current_user)):
|
||||
p = await db.progress.find_one({"user_id": user["id"], "movie_id": movie_id}, {"_id": 0})
|
||||
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}
|
||||
|
||||
|
||||
# ---------- Movie Requests ----------
|
||||
# ============ 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,
|
||||
)
|
||||
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)
|
||||
@@ -446,14 +653,12 @@ async def submit_request(payload: RequestCreate, user: dict = Depends(get_curren
|
||||
|
||||
@api.get("/requests/mine", response_model=List[MovieRequest])
|
||||
async def my_requests(user: dict = Depends(get_current_user)):
|
||||
docs = await db.requests.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", -1).to_list(200)
|
||||
return docs
|
||||
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)):
|
||||
docs = await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500)
|
||||
return docs
|
||||
return await db.requests.find({}, {"_id": 0}).sort("created_at", -1).to_list(500)
|
||||
|
||||
|
||||
@api.patch("/requests/{request_id}", response_model=MovieRequest)
|
||||
@@ -462,18 +667,120 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
|
||||
{"id": request_id}, {"$set": {"status": payload.status}},
|
||||
projection={"_id": 0}, return_document=True,
|
||||
)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
if not res: raise HTTPException(status_code=404, detail="Request not found")
|
||||
return res
|
||||
|
||||
|
||||
# ---------- Health ----------
|
||||
# ============ SETTINGS (admin) ============
|
||||
@api.get("/settings", response_model=AppSettingsPublic)
|
||||
async def read_settings(user: dict = Depends(require_admin)):
|
||||
s = await get_settings()
|
||||
return AppSettingsPublic(
|
||||
tmdb_configured=bool(s.get("tmdb_api_key")),
|
||||
radarr_configured=bool(s.get("radarr_api_key") and s.get("radarr_url")),
|
||||
tmdb_api_key=s.get("tmdb_api_key", ""),
|
||||
radarr_url=s.get("radarr_url", ""),
|
||||
radarr_api_key=s.get("radarr_api_key", ""),
|
||||
)
|
||||
|
||||
|
||||
@api.put("/settings", response_model=AppSettingsPublic)
|
||||
async def update_settings(payload: AppSettings, user: dict = Depends(require_admin)):
|
||||
doc = payload.model_dump()
|
||||
doc["id"] = "app"
|
||||
await db.settings.update_one({"id": "app"}, {"$set": doc}, upsert=True)
|
||||
return AppSettingsPublic(
|
||||
tmdb_configured=bool(doc.get("tmdb_api_key")),
|
||||
radarr_configured=bool(doc.get("radarr_api_key") and doc.get("radarr_url")),
|
||||
tmdb_api_key=doc.get("tmdb_api_key", ""),
|
||||
radarr_url=doc.get("radarr_url", ""),
|
||||
radarr_api_key=doc.get("radarr_api_key", ""),
|
||||
)
|
||||
|
||||
|
||||
# ============ TMDB (admin) ============
|
||||
@api.get("/tmdb/search", response_model=List[TMDBSearchResult])
|
||||
async def tmdb_search(q: str, user: dict = Depends(require_admin)):
|
||||
s = await get_settings()
|
||||
key = s.get("tmdb_api_key", "")
|
||||
if not key: raise HTTPException(status_code=400, detail="TMDB API key not configured")
|
||||
try:
|
||||
results = await asyncio.to_thread(tmdb_client.search_movies, key, q)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"TMDB error: {e}")
|
||||
return results
|
||||
|
||||
|
||||
@api.get("/tmdb/movie/{tmdb_id}")
|
||||
async def tmdb_movie(tmdb_id: int, user: dict = Depends(require_admin)):
|
||||
s = await get_settings()
|
||||
key = s.get("tmdb_api_key", "")
|
||||
if not key: raise HTTPException(status_code=400, detail="TMDB API key not configured")
|
||||
try:
|
||||
return await asyncio.to_thread(tmdb_client.get_movie, key, tmdb_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"TMDB error: {e}")
|
||||
|
||||
|
||||
# ============ RADARR (admin) ============
|
||||
@api.post("/radarr/test")
|
||||
async def radarr_test(user: dict = Depends(require_admin)):
|
||||
s = await get_settings()
|
||||
if not s.get("radarr_url") or not s.get("radarr_api_key"):
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
ok = await asyncio.to_thread(radarr_client.test_connection, s["radarr_url"], s["radarr_api_key"])
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@api.get("/radarr/movies", response_model=List[RadarrMovieDTO])
|
||||
async def radarr_list(user: dict = Depends(require_admin)):
|
||||
s = await get_settings()
|
||||
if not s.get("radarr_url") or not s.get("radarr_api_key"):
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
try:
|
||||
return await asyncio.to_thread(radarr_client.list_movies, s["radarr_url"], s["radarr_api_key"])
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Radarr error: {e}")
|
||||
|
||||
|
||||
@api.post("/radarr/import")
|
||||
async def radarr_import(payload: dict, user: dict = Depends(require_admin)):
|
||||
"""Body: {radarr_ids: [int]}"""
|
||||
radarr_ids = payload.get("radarr_ids") or []
|
||||
if not radarr_ids:
|
||||
raise HTTPException(status_code=400, detail="No radarr_ids provided")
|
||||
s = await get_settings()
|
||||
if not s.get("radarr_url") or not s.get("radarr_api_key"):
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
movies = await asyncio.to_thread(radarr_client.list_movies, s["radarr_url"], s["radarr_api_key"])
|
||||
by_id = {m["radarr_id"]: m for m in movies}
|
||||
created = 0
|
||||
for rid in radarr_ids:
|
||||
m = by_id.get(int(rid))
|
||||
if not m or not m.get("has_file") or not m.get("file_path"): continue
|
||||
# Skip if already imported
|
||||
if await db.movies.find_one({"radarr_id": m["radarr_id"]}, {"_id": 1}): continue
|
||||
movie = Movie(
|
||||
title=m["title"], description=m.get("overview", ""),
|
||||
year=m.get("year") or 2024, rating="NR",
|
||||
genres=[], cast=[], director="",
|
||||
poster_url=m.get("poster_url", ""),
|
||||
backdrop_url=m.get("poster_url", ""),
|
||||
video_url="", storage_type="radarr",
|
||||
storage_path=m["file_path"], # absolute path on Radarr-mounted storage
|
||||
radarr_id=m["radarr_id"], tmdb_id=m.get("tmdb_id"),
|
||||
).model_dump()
|
||||
await db.movies.insert_one(movie)
|
||||
created += 1
|
||||
return {"ok": True, "imported": created}
|
||||
|
||||
|
||||
# ============ HEALTH ============
|
||||
@api.get("/")
|
||||
async def root():
|
||||
return {"app": "Kino", "status": "ok"}
|
||||
|
||||
|
||||
# Mount router and CORS
|
||||
app.include_router(api)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
Reference in New Issue
Block a user