Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using StreamingResponse to stream videos with seeking capabilities #1240

Closed
0xAcid opened this issue Apr 11, 2020 · 13 comments
Closed

Using StreamingResponse to stream videos with seeking capabilities #1240

0xAcid opened this issue Apr 11, 2020 · 13 comments
Labels
question Question or problem question-migrate

Comments

@0xAcid
Copy link

0xAcid commented Apr 11, 2020

First check

  • I checked StreamingResponse (along with the related issues) and can stream a video properly.

Description

I was wondering if it was possible using fastapi to use "StreamingResponse" not only to stream a video, but to be able to seek (with byte-range i guess ?)

Additional context

I am trying to reimplement a similar software to peerflix (https://github.com/mafintosh/peerflix), which basically downloads a content, and stream it then. It uses nodejs, which I would like to avoid, and i was wondering if it was possible to do such thing with fastapi. (some people achieved it with other technology : https://github.com/romanvm/kodi.yatp )

@0xAcid 0xAcid added the question Question or problem label Apr 11, 2020
@0xAcid
Copy link
Author

0xAcid commented Apr 11, 2020

My bad I think i managed to solve it.
I will leave that here for people that may be looking for an answer :

  • You need to return a status_code=206 in your StreamingResponse, and also set the approriate headers to enable range downloading:
headers = {
    "Accept-Ranges": "bytes",
    "Content-Length": str(sz),
    "Content-Range": F"bytes {asked}-{sz-1}/{sz}",
}
response =  StreamingResponse(streaming_file(file_path, CS, asked), headers=headers,

Not sure if there is anything else to add, but it seems like it's working here.

@BeatWolf
Copy link

Could you provide a full code example on how you achieved this? I need to implement the exact same thing (serving a folder of videos that can be streamed).
The StaticFiles way almost works (it works with firefox), but fails in chrome because it returns the 200 response code instead of 206, which then makes chrome think that it can't do range requests.

@0xAcid
Copy link
Author

0xAcid commented May 19, 2020

I currently have the same issue with Chrome, did not really dug in that sense to be honest. (I'm serving files to be opened with VLC, so browser do not really matter for me)

@tiangolo
Copy link
Owner

tiangolo commented Jun 6, 2020

Thanks for reporting back @0xAcid .

You should probably be able to create a subclass of StaticFiles returning the status code you need, I guess...

@github-actions
Copy link
Contributor

Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues.

@FlorisHoogenboom
Copy link

FlorisHoogenboom commented Mar 12, 2021

I thought since I would probably not be the only one who ended up here after some Gooling, that it might be nice to provide a bit more elaborate example of how I made this work for me based on the great tips above. The snippet below shows how to implement this for any bytes-like stream.

BYTES_PER_RESPONSE = 100000


def chunk_generator_from_stream(stream, chunk_size, start, size):
    bytes_read = 0

    stream.seek(start)

    while bytes_read < size:
        bytes_to_read = min(chunk_size,
                            size - bytes_read)
        yield stream.read(bytes_to_read)
        bytes_read = bytes_read + bytes_to_read

    stream.close()


@router.get("...")
def stream(req: Request):
    asked = req.headers.get("Range")

    stream, total_size = get_file_and_total_size(...)

    start_byte_requested = int(asked.split("=")[-1][:-1])
    end_byte_planned = min(start_byte_requested + BYTES_PER_RESPONSE, total_size)

    chunk_generator = chunk_generator_from_stream(
        stream,
        chunk_size=10000,
        start=start_byte_requested,
        size=BYTES_PER_RESPONSE
    )
    return StreamingResponse(
        chunk_generator,
        headers={
            "Accept-Ranges": "bytes",
            "Content-Range": f"bytes {start_byte_requested}-{end_byte_planned}/{total_size}",
            "Content-Type": "..."
        },
        status_code=206
    )

@kikohs
Copy link

kikohs commented Mar 29, 2021

Just to build on @FlorisHoogenboom' s answer. The "Content-Range" specifies a start byte and end byte included. The video was not streaming on Chrome first.

Modifying this line:

end_byte_planned = min(start_byte_requested + BYTES_PER_RESPONSE, total_size)

by

end_byte_planned = min(start_byte_requested + BYTES_PER_RESPONSE, total_size) - 1

works as expected.

@TVMD
Copy link

TVMD commented Oct 7, 2021

I adjust the code from FlorisHoogenboom a bit. This version work fine for me, both browser and VLC stream work.

`

CONTENT_CHUNK_SIZE=100*1024
@app.get("/stream/{name}")
async def stream(name:str,range: Optional[str] = Header(None)):
def get_file(name:str):
    f = open('./streamFiles/'+name,'rb')
    return f, os.path.getsize('./streamFiles/'+name)    
   
def chunk_generator_from_stream(stream, chunk_size, start, size):
    bytes_read = 0
    stream.seek(start)
    while bytes_read < size:
        bytes_to_read = min(chunk_size,size - bytes_read)
        yield stream.read(bytes_to_read)
        bytes_read = bytes_read + bytes_to_read
    stream.close()

asked = range or "bytes=0-"
print(asked)
stream,total_size=get_file(name)
start_byte = int(asked.split("=")[-1].split('-')[0])

return StreamingResponse(
    chunk_generator_from_stream(
        stream,
        start=start_byte,
        chunk_size=CONTENT_CHUNK_SIZE,
        size=total_size
    )
    ,headers={
        "Accept-Ranges": "bytes",
        "Content-Range": f"bytes {start_byte}-{start_byte+CONTENT_CHUNK_SIZE}/{total_size}",
        "Content-Type": "video/mp4"
    },
    status_code=206)

`

@DMT130
Copy link

DMT130 commented Dec 5, 2021

I'm stuck trying to modify the above code to read the bytes to image using opencv, the implementation in https://stackoverflow.com/questions/65971081/stream-video-to-web-browser-with-fastapi simple does not work for me. Can someone help me out.

@FlorisHoogenboom
Copy link

That's a question that has also kept wandering my mind for a while but I don't have an answer yet. Big part of the solution above working of course depends on the encoding of the bytestream. If you might have opencv output in the right encoding you could just use a BytesIO object (or any seek able bytestream) to write to and read from simultaneously. That ofcourse does require some locking and synchronization so probably is not a trivial thing to do. Curious to hear when you figure it out!

@angel-langdon
Copy link

Reusable implementation (Video/PDF/etc...)

import os
from typing import BinaryIO

from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import StreamingResponse


def send_bytes_range_requests(
    file_obj: BinaryIO, start: int, end: int, chunk_size: int = 10_000
):
    """Send a file in chunks using Range Requests specification RFC7233

    `start` and `end` parameters are inclusive due to specification
    """
    with file_obj as f:
        f.seek(start)
        while (pos := f.tell()) <= end:
            read_size = min(chunk_size, end + 1 - pos)
            yield f.read(read_size)


def _get_range_header(range_header: str, file_size: int) -> tuple[int, int]:
    def _invalid_range():
        return HTTPException(
            status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
            detail=f"Invalid request range (Range:{range_header!r})",
        )

    try:
        h = range_header.replace("bytes=", "").split("-")
        start = int(h[0]) if h[0] != "" else 0
        end = int(h[1]) if h[1] != "" else file_size - 1
    except ValueError:
        raise _invalid_range()

    if start > end or start < 0 or end > file_size - 1:
        raise _invalid_range()
    return start, end


def range_requests_response(
    request: Request, file_path: str, content_type: str
):
    """Returns StreamingResponse using Range Requests of a given file"""

    file_size = os.stat(file_path).st_size
    range_header = request.headers.get("range")

    headers = {
        "content-type": content_type,
        "accept-ranges": "bytes",
        "content-encoding": "identity",
        "content-length": str(file_size),
        "access-control-expose-headers": (
            "content-type, accept-ranges, content-length, "
            "content-range, content-encoding"
        ),
    }
    start = 0
    end = file_size - 1
    status_code = status.HTTP_200_OK

    if range_header is not None:
        start, end = _get_range_header(range_header, file_size)
        size = end - start + 1
        headers["content-length"] = str(size)
        headers["content-range"] = f"bytes {start}-{end}/{file_size}"
        status_code = status.HTTP_206_PARTIAL_CONTENT

    return StreamingResponse(
        send_bytes_range_requests(open(file_path, mode="rb"), start, end),
        headers=headers,
        status_code=status_code,
    )


app = FastAPI()


@app.get("/video")
def get_video(request: Request):
    return range_requests_response(
        request, file_path="path_to_my_video.mp4", content_type="video/mp4"
    )

@gimebreak
Copy link

Reusable implementation (Video/PDF/etc...)

import os
from typing import BinaryIO

from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import StreamingResponse


def send_bytes_range_requests(
    file_obj: BinaryIO, start: int, end: int, chunk_size: int = 10_000
):
    """Send a file in chunks using Range Requests specification RFC7233

    `start` and `end` parameters are inclusive due to specification
    """
    with file_obj as f:
        f.seek(start)
        while (pos := f.tell()) <= end:
            read_size = min(chunk_size, end + 1 - pos)
            yield f.read(read_size)


def _get_range_header(range_header: str, file_size: int) -> tuple[int, int]:
    def _invalid_range():
        return HTTPException(
            status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
            detail=f"Invalid request range (Range:{range_header!r})",
        )

    try:
        h = range_header.replace("bytes=", "").split("-")
        start = int(h[0]) if h[0] != "" else 0
        end = int(h[1]) if h[1] != "" else file_size - 1
    except ValueError:
        raise _invalid_range()

    if start > end or start < 0 or end > file_size - 1:
        raise _invalid_range()
    return start, end


def range_requests_response(
    request: Request, file_path: str, content_type: str
):
    """Returns StreamingResponse using Range Requests of a given file"""

    file_size = os.stat(file_path).st_size
    range_header = request.headers.get("range")

    headers = {
        "content-type": content_type,
        "accept-ranges": "bytes",
        "content-encoding": "identity",
        "content-length": str(file_size),
        "access-control-expose-headers": (
            "content-type, accept-ranges, content-length, "
            "content-range, content-encoding"
        ),
    }
    start = 0
    end = file_size - 1
    status_code = status.HTTP_200_OK

    if range_header is not None:
        start, end = _get_range_header(range_header, file_size)
        size = end - start + 1
        headers["content-length"] = str(size)
        headers["content-range"] = f"bytes {start}-{end}/{file_size}"
        status_code = status.HTTP_206_PARTIAL_CONTENT

    return StreamingResponse(
        send_bytes_range_requests(open(file_path, mode="rb"), start, end),
        headers=headers,
        status_code=status_code,
    )


app = FastAPI()


@app.get("/video")
def get_video(request: Request):
    return range_requests_response(
        request, file_path="path_to_my_video.mp4", content_type="video/mp4"
    )

this is quite helpful. I also found that the FileResponse doesnt work as good as other web framework, since its downloading speed is quite low comparing to flask's send_file function.

@mokrueger
Copy link

I want to add a note to the above solution:
It seems to work for most browsers, but I have encountered an issue using Firefox on Ubuntu.

For some reason the browser appears to try to download the whole file before starting to play it and even then I am not able to seek through the video.

It starts with no range header -> so the server naturally sends back the entire thing (which takes a whole while for large files)
Then Firefox asks for 0-{filesize-1} specifically and starts the playback.
Whenever I try to seek through the video it just goes to the latest loaded position from the 0-{filesize-1} stream, while not making a new request with a different range header.

I fixed this by removing the "content-encoding": "identity". (Even though the browser asks for "Accept-Encoding": "identity" specifically when using the range header in Firefox)
I hope this helps some people who have encountered a similar problem.

And if someone can explain why Firefox does that please enlighten me.

PPS: For those using a python version <3.8 make sure to resolve the walrus operator (in the send_bytes_range_requests function) properly as else you might get an LocalProtocolError("Too much data for declared Content-Length") error.

@tiangolo tiangolo reopened this Feb 28, 2023
@github-actions github-actions bot removed the answered label Feb 28, 2023
Repository owner locked and limited conversation to collaborators Feb 28, 2023
@tiangolo tiangolo converted this issue into discussion #7718 Feb 28, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
question Question or problem question-migrate
Projects
None yet
Development

No branches or pull requests

10 participants