import mimetypes import os import re import shutil import subprocess import time import hashlib from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from pathlib import Path from typing import Generator, Iterable, Optional, Tuple import jwt from fastapi import FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse APP_NAME = "video-file-server" BASE_DIR = Path(__file__).resolve().parent MEDIA_DIR = BASE_DIR / "media" HLS_CACHE_DIR = Path(os.getenv("HLS_CACHE_DIR", str(BASE_DIR / ".hls_cache"))).resolve() # Load repo .env (shared with the Node app) if present. try: from dotenv import load_dotenv # type: ignore load_dotenv(BASE_DIR / ".env") except Exception: pass # Allow common video types (what browsers can play is separate from what we serve). ALLOWED_EXTS = {".mp4", ".mkv"} RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$") AUTH_REQUIRED = os.getenv("VIDEO_AUTH_REQUIRED", "1").strip() not in {"0", "false", "False", "no", "NO"} JWT_SECRET = os.getenv("JWT_SECRET") def _safe_resolve_media_path(rel_path: str) -> Path: """ Resolve a user-provided path against MEDIA_DIR, preventing path traversal. """ # Normalize separators and strip leading slashes. rel_path = rel_path.replace("\\", "/").lstrip("/") if not rel_path: raise HTTPException(status_code=404, detail="Missing file path") candidate = (MEDIA_DIR / rel_path).resolve() media_root = MEDIA_DIR.resolve() try: candidate.relative_to(media_root) except ValueError as exc: raise HTTPException(status_code=400, detail="Invalid path") from exc if not candidate.is_file(): raise HTTPException(status_code=404, detail="File not found") if candidate.suffix.lower() not in ALLOWED_EXTS: raise HTTPException(status_code=415, detail="Unsupported file type") return candidate def _extract_token(request: Request) -> Optional[str]: # Query-param token (works for