feat: enhance media file delivery with range support and inline display
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s

- Added support for HTTP Range requests to enable partial downloads for media files, improving streaming capabilities.
- Implemented a new response function to handle binary media responses, including content disposition for inline display.
- Updated the media file download endpoint to utilize the new response handling, ensuring secure and efficient file delivery.
- Enhanced type hints and imports for better code clarity and maintainability.
This commit is contained in:
Lars 2026-05-07 11:02:43 +02:00
parent b752883392
commit 9365125969

View File

@ -8,11 +8,13 @@ import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterator, List, Optional, Tuple
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
@ -1818,6 +1820,120 @@ def delete_exercise_variant(
return {"ok": True}
def _content_disposition_inline(filename: Optional[str]) -> str:
"""Inline-Darstellung im Browser (<video>/<img>), keine attachment-Download-Leiste."""
if not filename:
return "inline"
fn = str(filename).strip()
quoted = quote(fn, safe="")
escaped = fn.replace('"', "\\")
if quoted != fn:
return f"inline; filename*=utf-8''{quoted}"
return f'inline; filename="{escaped}"'
def _parse_bytes_range_single(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
"""
Aus Range-Header einen inklusiven (start, end)-Bereich; None = gesamte Datei.
Nur ein bytes=-Segment (erstes Segment bei mehreren, durch Komma getrennt).
"""
if not range_header:
return None
hdr = range_header.strip()
if "," in hdr:
hdr = hdr.split(",", 1)[0].strip()
m = re.match(r"^bytes=(\d*)-(\d*)$", hdr)
if not m:
return None
start_s, end_s = m.group(1), m.group(2)
if not start_s and not end_s:
return None
if file_size <= 0:
return None
last_idx = file_size - 1
if start_s == "" and end_s != "":
suffix = int(end_s)
if suffix <= 0:
return None
start = max(0, file_size - suffix)
end = last_idx
elif start_s != "" and end_s == "":
start = int(start_s)
end = last_idx
else:
start = int(start_s)
end = int(end_s)
end = min(end, last_idx)
if start > last_idx or start > end:
return None
return start, end
def _iter_file_chunks(path_str: str, start: int, length: int) -> Iterator[bytes]:
chunk_size = 64 * 1024
remaining = length
with open(path_str, "rb") as f:
f.seek(start)
while remaining > 0:
blob = f.read(min(chunk_size, remaining))
if not blob:
break
remaining -= len(blob)
yield blob
def _binary_media_response(
path_arg: Path, mime_type: str, download_name: Optional[str], request: Request
):
"""Vollständige oder Range-Download-Antwort; MP4/streaming brauchen oft 206 + Content-Range."""
path_str = str(path_arg.resolve())
stat_r = os.stat(path_str)
file_size = int(stat_r.st_size)
cd_val = _content_disposition_inline(download_name)
base_h = {"accept-ranges": "bytes", "content-disposition": cd_val}
if request.method.upper() == "HEAD":
return Response(
status_code=200,
headers={**base_h, "content-type": mime_type or "application/octet-stream", "content-length": str(file_size)},
)
rng = request.headers.get("range")
if rng and request.method.upper() != "HEAD":
rstrip = rng.strip()
if re.match(r"^bytes=(\d*)-(\d*)$", rstrip):
parsed = _parse_bytes_range_single(rng, file_size)
if parsed is None:
return Response(
status_code=416,
headers={
"accept-ranges": "bytes",
"content-range": f"bytes */{file_size}",
},
)
start, end_incl = parsed
content_length = end_incl - start + 1
hdrs = {
**base_h,
"content-type": mime_type or "application/octet-stream",
"content-range": f"bytes {start}-{end_incl}/{file_size}",
"content-length": str(content_length),
}
return StreamingResponse(
_iter_file_chunks(path_str, start, content_length),
status_code=206,
headers=hdrs,
media_type=mime_type or "application/octet-stream",
)
return FileResponse(
path_str,
media_type=mime_type or "application/octet-stream",
stat_result=stat_r,
headers=base_h,
)
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
@ -1832,15 +1948,16 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
return r2d(row) if row else None
@router.get("/exercises/{exercise_id}/media/{media_id}/file")
@router.api_route("/exercises/{exercise_id}/media/{media_id}/file", methods=["GET", "HEAD"])
def download_exercise_media_file(
request: Request,
exercise_id: int,
media_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""
Dateiauslieferung mit Governance wie GET Übung Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
Direktes /media/... ohne Token ist nicht vorgesehen (kein anonymes Hosting).
Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment).
"""
with get_db() as conn:
cur = get_cursor(conn)
@ -1858,11 +1975,7 @@ def download_exercise_media_file(
mime = media.get("mime_type") or "application/octet-stream"
fname = media.get("original_filename") or abs_p.name
return FileResponse(
path=str(abs_p.resolve()),
media_type=mime,
filename=str(fname),
)
return _binary_media_response(abs_p, mime, str(fname) if fname else None, request)
@router.post("/exercises/{exercise_id}/media", status_code=201)