@@ -538,17 +538,20 @@ def virtual_file(
538538 detail = "Invalid virtual file request" ,
539539 )
540540
541- byte_length , filename = filename_and_length .split ("-" , 1 )
542- if not byte_length .isdigit ():
541+ byte_length_str , filename = filename_and_length .split ("-" , 1 )
542+ if not byte_length_str .isdigit ():
543543 raise HTTPException (
544544 status_code = 404 ,
545545 detail = "Invalid byte length in virtual file request" ,
546546 )
547+ total_size = int (byte_length_str )
547548
548- chunks = read_virtual_file_chunked (filename , int (byte_length ))
549549 mimetype , _ = mimetypes .guess_type (filename )
550550 headers = {
551551 "Cache-Control" : "max-age=86400" ,
552+ # Advertise range support so Safari (which requires it for media
553+ # playback) will load <audio>/<video> sources. See #9460.
554+ "Accept-Ranges" : "bytes" ,
552555 }
553556 # When ?download=1 is set, force a save dialog. This bypasses cases
554557 # where <a download> is ignored (e.g., sandboxed iframes without
@@ -558,17 +561,76 @@ def virtual_file(
558561
559562 download_filename = request .query_params .get ("filename" ) or filename
560563 headers .update (make_download_headers (download_filename ))
561- # Do NOT set Content-Length here. StreamingResponse with an explicit
562- # Content-Length causes h11 LocalProtocolError ("Too little data for
563- # declared Content-Length") for large files. Omitting it lets h11 use
564+
565+ range_header = request .headers .get ("range" )
566+ if range_header is not None :
567+ parsed = _parse_range_header (range_header , total_size )
568+ if parsed is None :
569+ return Response (
570+ status_code = 416 ,
571+ headers = {** headers , "Content-Range" : f"bytes */{ total_size } " },
572+ )
573+ start , end = parsed
574+ length = end - start + 1
575+ chunks = read_virtual_file_chunked (filename , length , start = start )
576+ partial_headers = {
577+ ** headers ,
578+ "Content-Range" : f"bytes { start } -{ end } /{ total_size } " ,
579+ "Content-Length" : str (length ),
580+ }
581+ return StreamingResponse (
582+ content = chunks ,
583+ status_code = 206 ,
584+ media_type = mimetype ,
585+ headers = partial_headers ,
586+ )
587+
588+ # Do NOT set Content-Length on full responses. StreamingResponse with an
589+ # explicit Content-Length causes h11 LocalProtocolError ("Too little data
590+ # for declared Content-Length") for large files. Omitting it lets h11 use
564591 # chunked transfer encoding instead. See #8917.
592+ chunks = read_virtual_file_chunked (filename , total_size )
565593 return StreamingResponse (
566594 content = chunks ,
567595 media_type = mimetype ,
568596 headers = headers ,
569597 )
570598
571599
600+ _RANGE_RE = re .compile (r"^bytes=(\d*)-(\d*)$" , re .IGNORECASE )
601+
602+
603+ def _parse_range_header (
604+ range_header : str , total_size : int
605+ ) -> tuple [int , int ] | None :
606+ """Parse a single-range HTTP ``Range`` header.
607+
608+ Returns ``(start, end)`` byte offsets (inclusive) on success, or
609+ ``None`` if the range is unsatisfiable. Multi-range requests are
610+ treated as unsatisfiable since marimo only supports single ranges.
611+ """
612+ match = _RANGE_RE .match (range_header .strip ())
613+ if match is None or total_size == 0 :
614+ return None
615+ start_str , end_str = match .group (1 ), match .group (2 )
616+ if start_str == "" and end_str == "" :
617+ return None
618+ if start_str == "" :
619+ # Suffix range: last N bytes.
620+ suffix = int (end_str )
621+ if suffix == 0 :
622+ return None
623+ start = max (total_size - suffix , 0 )
624+ end = total_size - 1
625+ else :
626+ start = int (start_str )
627+ end = int (end_str ) if end_str else total_size - 1
628+ if start >= total_size or end < start :
629+ return None
630+ end = min (end , total_size - 1 )
631+ return start , end
632+
633+
572634@router .get ("/public-files-sw.js" )
573635async def public_files_service_worker (request : Request ) -> Response :
574636 """
0 commit comments