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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
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 import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
|
|
@ -1818,6 +1820,120 @@ def delete_exercise_variant(
|
||||||
return {"ok": True}
|
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) ---
|
# --- 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
|
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(
|
def download_exercise_media_file(
|
||||||
|
request: Request,
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
media_id: int,
|
media_id: int,
|
||||||
tenant: TenantContext = Depends(get_tenant_context_flexible),
|
tenant: TenantContext = Depends(get_tenant_context_flexible),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Dateiauslieferung mit Governance wie GET Übung — Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -1858,11 +1975,7 @@ def download_exercise_media_file(
|
||||||
|
|
||||||
mime = media.get("mime_type") or "application/octet-stream"
|
mime = media.get("mime_type") or "application/octet-stream"
|
||||||
fname = media.get("original_filename") or abs_p.name
|
fname = media.get("original_filename") or abs_p.name
|
||||||
return FileResponse(
|
return _binary_media_response(abs_p, mime, str(fname) if fname else None, request)
|
||||||
path=str(abs_p.resolve()),
|
|
||||||
media_type=mime,
|
|
||||||
filename=str(fname),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/exercises/{exercise_id}/media", status_code=201)
|
@router.post("/exercises/{exercise_id}/media", status_code=201)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user