auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc

This commit is contained in:
emergent-agent-e1
2026-04-29 16:01:20 +00:00
parent 1d4bd4f513
commit cdc8c8955f
19 changed files with 1933 additions and 339 deletions
+444 -137
View File
@@ -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,