diff --git a/py/routes/handlers/preview_handlers.py b/py/routes/handlers/preview_handlers.py index 7916e716..4b88cb4e 100644 --- a/py/routes/handlers/preview_handlers.py +++ b/py/routes/handlers/preview_handlers.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import mimetypes import urllib.parse from pathlib import Path @@ -12,6 +13,12 @@ from ...config import config as global_config logger = logging.getLogger(__name__) +_CHUNK_SIZE = 256 * 1024 # 256 KB + +# Video file extensions that bypass native sendfile on Windows +# to avoid IOCP/ProactorEventLoop crashes during client disconnect. +_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"}) + class PreviewHandler: """Serve preview assets for the active library at request time.""" @@ -48,8 +55,51 @@ class PreviewHandler: logger.debug("Preview file not found at %s", str(resolved)) raise web.HTTPNotFound(text="Preview file not found") + # Video files: stream manually to avoid Windows native sendfile crash. + # aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based), + # which breaks when the client disconnects mid-transfer — this happens + # constantly when users scroll through a gallery of animated previews. + suffix = resolved.suffix.lower() + if suffix in _VIDEO_EXTENSIONS: + return await self._stream_file(request, resolved) + # aiohttp's FileResponse handles range requests and content headers for us. - return web.FileResponse(path=resolved, chunk_size=256 * 1024) + return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE) + + async def _stream_file( + self, request: web.Request, path: Path + ) -> web.StreamResponse: + """Stream a file chunk-by-chunk, bypassing native sendfile. + + This avoids the Windows IOCP ``_sendfile_native`` crash that occurs + when the client disconnects during a large file transfer. + """ + content_type, _ = mimetypes.guess_type(str(path)) + if content_type is None: + content_type = "application/octet-stream" + + file_size = path.stat().st_size + resp = web.StreamResponse() + resp.content_type = content_type + resp.content_length = file_size + + await resp.prepare(request) + + try: + with open(path, "rb") as f: + while True: + chunk = f.read(_CHUNK_SIZE) + if not chunk: + break + await resp.write(chunk) + except (ConnectionResetError, ConnectionAbortedError): + # Client disconnected during streaming — expected when scrolling + # rapidly through a library with animated previews. + pass + except OSError as exc: + logger.debug("I/O error streaming preview %s: %s", path, exc) + + return resp __all__ = ["PreviewHandler"]