refactor: #85 T6c extrait media-pdf-export vers backend/routers (comportement inchange)

This commit is contained in:
2026-09-26 14:06:52 -04:00
parent 0abc17e9f2
commit 6cccdc1f34
14 changed files with 699 additions and 634 deletions
+78
View File
@@ -10,9 +10,14 @@ de comportement. Regroupées ici car utilisées par plusieurs routers
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.
@@ -49,3 +54,76 @@ def media_max_inline_bytes() -> int:
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})