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
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:
parent
b752883392
commit
9365125969
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user