Video file serving app #17
@@ -22,6 +22,20 @@ This repo also includes a small Python server that serves local video files with
|
|||||||
- Served as: `http://localhost:8001/media/<filename>`
|
- Served as: `http://localhost:8001/media/<filename>`
|
||||||
- List endpoint: `http://localhost:8001/api/media`
|
- List endpoint: `http://localhost:8001/api/media`
|
||||||
|
|
||||||
|
### Authentication (required)
|
||||||
|
By default, the Python server requires a valid Oscar-Pool JWT (same `JWT_SECRET` as your Node app).
|
||||||
|
|
||||||
|
- The server loads `./.env` automatically (so it can reuse `JWT_SECRET`)
|
||||||
|
- The web player sends auth automatically:
|
||||||
|
- direct `/media/*` and `/hls/*` requests use `?token=<jwt>` (needed for the `<video>` tag)
|
||||||
|
- `/api/hls` uses `Authorization: Bearer <jwt>`
|
||||||
|
|
||||||
|
To disable auth (not recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VIDEO_AUTH_REQUIRED=0
|
||||||
|
```
|
||||||
|
|
||||||
### HLS (for better browser compatibility, especially MKV)
|
### HLS (for better browser compatibility, especially MKV)
|
||||||
The Python server can also **generate and serve HLS** (`.m3u8` + `.ts`) using `ffmpeg`, and the web player will automatically try this when a `/media/*.mkv` file is used (or when direct playback fails).
|
The Python server can also **generate and serve HLS** (`.m3u8` + `.ts`) using `ffmpeg`, and the web player will automatically try this when a `/media/*.mkv` file is used (or when direct playback fails).
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+31
-6
@@ -90,6 +90,25 @@ function looksLikeVideoFile(url) {
|
|||||||
return /\.(mp4|mkv|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
|
return /\.(mp4|mkv|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAuthToken() {
|
||||||
|
return localStorage.getItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAuthTokenQuery(url) {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return url;
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
const p = u.pathname || '';
|
||||||
|
// Only attach tokens to our python video endpoints.
|
||||||
|
if (!(p.startsWith('/media/') || p.startsWith('/hls/'))) return url;
|
||||||
|
if (!u.searchParams.has('token')) u.searchParams.set('token', token);
|
||||||
|
return u.toString();
|
||||||
|
} catch (_) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isMkv(url) {
|
function isMkv(url) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url, window.location.origin);
|
const u = new URL(url, window.location.origin);
|
||||||
@@ -109,12 +128,17 @@ async function tryGetGeneratedHlsPlaylist(rawUrl) {
|
|||||||
|
|
||||||
const api = new URL('/api/hls', u.origin);
|
const api = new URL('/api/hls', u.origin);
|
||||||
api.searchParams.set('source', rel);
|
api.searchParams.set('source', rel);
|
||||||
const res = await fetch(api.toString(), { method: 'GET' });
|
const token = getAuthToken();
|
||||||
|
const res = await fetch(api.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
const playlist = data?.playlist_abs_url || data?.playlist_url;
|
const playlist = data?.playlist_abs_url || data?.playlist_url;
|
||||||
if (!playlist) return null;
|
if (!playlist) return null;
|
||||||
return playlist.startsWith('http') ? playlist : new URL(playlist, u.origin).toString();
|
const abs = playlist.startsWith('http') ? playlist : new URL(playlist, u.origin).toString();
|
||||||
|
return withAuthTokenQuery(abs);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -221,7 +245,7 @@ async function playVodLink(vodLink) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "Open original" button
|
// "Open original" button
|
||||||
setOpenOriginal(raw);
|
setOpenOriginal(withAuthTokenQuery(raw));
|
||||||
|
|
||||||
// Prefer known embed providers
|
// Prefer known embed providers
|
||||||
const yt = toYoutubeEmbed(raw);
|
const yt = toYoutubeEmbed(raw);
|
||||||
@@ -252,7 +276,7 @@ async function playVodLink(vodLink) {
|
|||||||
setSourceLabel('Direct video file');
|
setSourceLabel('Direct video file');
|
||||||
const videoEl = showVideoPlayer();
|
const videoEl = showVideoPlayer();
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
videoEl.src = raw;
|
videoEl.src = withAuthTokenQuery(raw);
|
||||||
videoEl.addEventListener('error', () => {
|
videoEl.addEventListener('error', () => {
|
||||||
// If the browser can't play it (or CORS blocks), try HLS from the python server, then fall back to iframe.
|
// If the browser can't play it (or CORS blocks), try HLS from the python server, then fall back to iframe.
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -275,10 +299,11 @@ async function playVodLink(vodLink) {
|
|||||||
setSourceLabel('HLS stream (.m3u8)');
|
setSourceLabel('HLS stream (.m3u8)');
|
||||||
const videoEl = showVideoPlayer();
|
const videoEl = showVideoPlayer();
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
|
const hlsUrl = withAuthTokenQuery(raw);
|
||||||
|
|
||||||
// Native HLS (Safari) first
|
// Native HLS (Safari) first
|
||||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
videoEl.src = raw;
|
videoEl.src = hlsUrl;
|
||||||
try { videoEl.load(); } catch (_) {}
|
try { videoEl.load(); } catch (_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -288,7 +313,7 @@ async function playVodLink(vodLink) {
|
|||||||
const hls = new window.Hls({
|
const hls = new window.Hls({
|
||||||
enableWorker: true,
|
enableWorker: true,
|
||||||
});
|
});
|
||||||
hls.loadSource(raw);
|
hls.loadSource(hlsUrl);
|
||||||
hls.attachMedia(videoEl);
|
hls.attachMedia(videoEl);
|
||||||
hls.on(window.Hls.Events.ERROR, function (_evt, data) {
|
hls.on(window.Hls.Events.ERROR, function (_evt, data) {
|
||||||
if (data?.fatal) {
|
if (data?.fatal) {
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
|
PyJWT==2.10.1
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
|||||||
+97
-5
@@ -5,9 +5,11 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Iterable, Optional, Tuple
|
from typing import Generator, Iterable, Optional, Tuple
|
||||||
|
|
||||||
|
import jwt
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
@@ -17,11 +19,22 @@ BASE_DIR = Path(__file__).resolve().parent
|
|||||||
MEDIA_DIR = BASE_DIR / "media"
|
MEDIA_DIR = BASE_DIR / "media"
|
||||||
HLS_CACHE_DIR = Path(os.getenv("HLS_CACHE_DIR", str(BASE_DIR / ".hls_cache"))).resolve()
|
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).
|
# Allow common video types (what browsers can play is separate from what we serve).
|
||||||
ALLOWED_EXTS = {".mp4", ".mkv"}
|
ALLOWED_EXTS = {".mp4", ".mkv"}
|
||||||
|
|
||||||
RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
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:
|
def _safe_resolve_media_path(rel_path: str) -> Path:
|
||||||
"""
|
"""
|
||||||
@@ -48,6 +61,37 @@ def _safe_resolve_media_path(rel_path: str) -> Path:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(request: Request) -> Optional[str]:
|
||||||
|
# Query-param token (works for <video> and Safari HLS)
|
||||||
|
q = request.query_params.get("token")
|
||||||
|
if q:
|
||||||
|
return q
|
||||||
|
|
||||||
|
# Authorization: Bearer <token> (works for fetch/hls.js)
|
||||||
|
auth = request.headers.get("authorization") or request.headers.get("Authorization")
|
||||||
|
if auth and auth.lower().startswith("bearer "):
|
||||||
|
return auth.split(" ", 1)[1].strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auth(request: Request) -> dict:
|
||||||
|
if not AUTH_REQUIRED:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
token = _extract_token(request)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
if not JWT_SECRET:
|
||||||
|
raise HTTPException(status_code=500, detail="Server misconfigured: JWT_SECRET is not set")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
|
||||||
|
|
||||||
|
|
||||||
def _guess_content_type(path: Path) -> str:
|
def _guess_content_type(path: Path) -> str:
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
if ext == ".mkv":
|
if ext == ".mkv":
|
||||||
@@ -262,6 +306,33 @@ def _safe_resolve_hls_asset(hls_id: str, asset_path: str) -> Path:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _with_token_query(uri: str, token: str) -> str:
|
||||||
|
parts = urlsplit(uri)
|
||||||
|
q = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||||
|
if "token" not in q:
|
||||||
|
q["token"] = token
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_m3u8_with_token(content: str, token: str) -> str:
|
||||||
|
"""
|
||||||
|
Adds ?token=... to each URI line (segments, key URIs, etc.) in a playlist.
|
||||||
|
Only touches non-comment, non-empty lines.
|
||||||
|
"""
|
||||||
|
out_lines: list[str] = []
|
||||||
|
for raw_line in content.splitlines(True):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
out_lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
# Preserve original newline by rewriting only the URI portion.
|
||||||
|
uri = line
|
||||||
|
# If the line already includes leading/trailing whitespace, normalize to original.
|
||||||
|
rewritten = _with_token_query(uri, token)
|
||||||
|
out_lines.append(raw_line.replace(uri, rewritten, 1))
|
||||||
|
return "".join(out_lines)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=APP_NAME)
|
app = FastAPI(title=APP_NAME)
|
||||||
|
|
||||||
# Default to permissive CORS because the player may be on another port/host.
|
# Default to permissive CORS because the player may be on another port/host.
|
||||||
@@ -287,7 +358,8 @@ def health() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/media")
|
@app.get("/api/media")
|
||||||
def list_media() -> JSONResponse:
|
def list_media(request: Request) -> JSONResponse:
|
||||||
|
_require_auth(request)
|
||||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
items = []
|
items = []
|
||||||
for p in sorted(MEDIA_DIR.rglob("*")):
|
for p in sorted(MEDIA_DIR.rglob("*")):
|
||||||
@@ -307,12 +379,16 @@ def list_media() -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/hls")
|
@app.get("/api/hls")
|
||||||
def prepare_hls(request: Request, source: str = Query(..., description="Path relative to ./media (e.g. MyMovie.mkv)")) -> JSONResponse:
|
def prepare_hls(
|
||||||
|
request: Request,
|
||||||
|
source: str = Query(..., description="Path relative to ./media (e.g. MyMovie.mkv)"),
|
||||||
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Generates (and caches) an HLS VOD playlist for a local media file.
|
Generates (and caches) an HLS VOD playlist for a local media file.
|
||||||
|
|
||||||
Returns an id and a playlist URL like: /hls/<id>/index.m3u8
|
Returns an id and a playlist URL like: /hls/<id>/index.m3u8
|
||||||
"""
|
"""
|
||||||
|
_require_auth(request)
|
||||||
media_path = _safe_resolve_media_path(source)
|
media_path = _safe_resolve_media_path(source)
|
||||||
hls_id = _ensure_hls_for_media(media_path)
|
hls_id = _ensure_hls_for_media(media_path)
|
||||||
base = str(request.base_url).rstrip("/")
|
base = str(request.base_url).rstrip("/")
|
||||||
@@ -328,19 +404,34 @@ def prepare_hls(request: Request, source: str = Query(..., description="Path rel
|
|||||||
|
|
||||||
@app.api_route("/hls/{hls_id}/{asset_path:path}", methods=["GET", "HEAD"])
|
@app.api_route("/hls/{hls_id}/{asset_path:path}", methods=["GET", "HEAD"])
|
||||||
async def serve_hls_asset(hls_id: str, asset_path: str, request: Request) -> Response:
|
async def serve_hls_asset(hls_id: str, asset_path: str, request: Request) -> Response:
|
||||||
|
_require_auth(request)
|
||||||
path = _safe_resolve_hls_asset(hls_id, asset_path)
|
path = _safe_resolve_hls_asset(hls_id, asset_path)
|
||||||
size = path.stat().st_size
|
|
||||||
ctype = _hls_content_type(path)
|
ctype = _hls_content_type(path)
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
size = path.stat().st_size
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": ctype,
|
"Content-Type": ctype,
|
||||||
"Content-Length": str(size),
|
"Content-Length": str(size),
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.method == "HEAD":
|
|
||||||
return Response(status_code=200, headers=headers)
|
return Response(status_code=200, headers=headers)
|
||||||
|
|
||||||
|
# For playlists, inject token into segment URIs so the browser can fetch them without headers.
|
||||||
|
if path.suffix.lower() == ".m3u8":
|
||||||
|
token = _extract_token(request) or ""
|
||||||
|
text = path.read_text("utf-8", errors="replace")
|
||||||
|
if token:
|
||||||
|
text = _rewrite_m3u8_with_token(text, token)
|
||||||
|
data = text.encode("utf-8")
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
status_code=200,
|
||||||
|
headers={"Content-Type": ctype, "Content-Length": str(len(data)), "Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
size = path.stat().st_size
|
||||||
|
headers = {"Content-Type": ctype, "Content-Length": str(size), "Cache-Control": "no-store"}
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_iter_file(path, 0, size - 1),
|
_iter_file(path, 0, size - 1),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
@@ -351,6 +442,7 @@ async def serve_hls_asset(hls_id: str, asset_path: str, request: Request) -> Res
|
|||||||
|
|
||||||
@app.api_route("/media/{file_path:path}", methods=["GET", "HEAD"])
|
@app.api_route("/media/{file_path:path}", methods=["GET", "HEAD"])
|
||||||
async def serve_media(file_path: str, request: Request) -> Response:
|
async def serve_media(file_path: str, request: Request) -> Response:
|
||||||
|
_require_auth(request)
|
||||||
path = _safe_resolve_media_path(file_path)
|
path = _safe_resolve_media_path(file_path)
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
ctype = _guess_content_type(path)
|
ctype = _guess_content_type(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user