"""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})