130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
|
|
|
|
Petites fonctions pures extraites de :mod:`backend.main` sans changement
|
|
de comportement. Regroupées ici car utilisées par plusieurs routers
|
|
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
|
|
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
|
|
(resté dans ``main`` jusqu'à la tranche media).
|
|
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException, Request
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
|
|
|
|
def content_disposition(disposition: str, filename: str) -> str:
|
|
"""Build a header-safe Content-Disposition value.
|
|
|
|
HTTP header values must be ASCII. Unicode filenames are sent per
|
|
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
|
|
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
|
|
the filename contains accented characters (e.g. 'Bière blonde…pdf').
|
|
"""
|
|
from urllib.parse import quote
|
|
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
|
|
ext = Path(filename).suffix
|
|
if ext and not Path(ascii_name).suffix:
|
|
ascii_name = ascii_name + ext
|
|
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
|
|
|
|
|
def media_max_inline_bytes() -> int:
|
|
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
|
|
|
|
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
|
|
above the limit are not streamed in the viewer (the UI falls back to the
|
|
download button), which keeps a single uvicorn worker from being pinned by
|
|
multi-gigabyte media. Invalid or non-positive values fall back to default.
|
|
"""
|
|
default_mb = 500
|
|
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
|
|
if not raw:
|
|
return default_mb * 1024 * 1024
|
|
try:
|
|
mb = float(raw)
|
|
except ValueError:
|
|
return default_mb * 1024 * 1024
|
|
if mb <= 0:
|
|
return default_mb * 1024 * 1024
|
|
return int(mb * 1024 * 1024)
|
|
|
|
|
|
def stream_file_with_range(file_path: Path, request: Request, media_type: str):
|
|
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
|
|
|
|
Extrait de :mod:`backend.main` (``_stream_file_with_range``) sans
|
|
changement de comportement. Shared by ``pdf/stream`` and ``/api/media``:
|
|
a plain :class:`FileResponse` with ``Accept-Ranges: bytes`` when no range
|
|
is requested, or a :class:`StreamingResponse` (206 Partial Content,
|
|
64 KiB chunks) for a valid single range. An unsatisfiable range yields
|
|
``416`` with a ``Content-Range: bytes */size`` header.
|
|
|
|
Reads are offloaded to threads so the event loop is never blocked
|
|
(ASYNC230), matching the previous inline implementation.
|
|
"""
|
|
file_size = file_path.stat().st_size
|
|
range_header = request.headers.get("range")
|
|
disposition = content_disposition("inline", file_path.name)
|
|
|
|
if range_header:
|
|
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
|
|
m = re.match(r"bytes=(\d*)-(\d*)", range_header)
|
|
if not m:
|
|
raise HTTPException(status_code=416,
|
|
headers={"Content-Range": f"bytes */{file_size}"})
|
|
start_s, end_s = m.group(1), m.group(2)
|
|
if start_s == "" and end_s == "":
|
|
raise HTTPException(status_code=416,
|
|
headers={"Content-Range": f"bytes */{file_size}"})
|
|
if start_s == "":
|
|
# suffix range: last N bytes
|
|
length = min(int(end_s), file_size)
|
|
start = file_size - length
|
|
end = file_size - 1
|
|
else:
|
|
start = int(start_s)
|
|
end = int(end_s) if end_s else file_size - 1
|
|
end = min(end, file_size - 1)
|
|
if start > end or start >= file_size:
|
|
raise HTTPException(status_code=416,
|
|
headers={"Content-Range": f"bytes */{file_size}"})
|
|
|
|
chunk_size = end - start + 1
|
|
|
|
async def _partial():
|
|
f = await asyncio.to_thread(open, str(file_path), "rb")
|
|
try:
|
|
await asyncio.to_thread(f.seek, start)
|
|
remaining = chunk_size
|
|
while remaining > 0:
|
|
data = await asyncio.to_thread(f.read, min(64 * 1024, remaining))
|
|
if not data:
|
|
break
|
|
remaining -= len(data)
|
|
yield data
|
|
finally:
|
|
await asyncio.to_thread(f.close)
|
|
|
|
return StreamingResponse(
|
|
_partial(),
|
|
status_code=206,
|
|
media_type=media_type,
|
|
headers={
|
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(chunk_size),
|
|
"Content-Disposition": disposition,
|
|
},
|
|
)
|
|
|
|
return FileResponse(str(file_path), media_type=media_type, headers={
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Disposition": disposition})
|