14

Please help me to understand one moment.

I am trying to make Flask to stream .mp4 video. I know that i can use Response(generator_function())

But it does not allow to jump to specific minute while watching a video in browser.

So i am trying to use Range header. Here is how i try it:

app = Flask(__name__)


def get_chunk(byte1=None, byte2=None):
    filesize = os.path.getsize('try2.mp4')
    yielded = 0
    yield_size = 1024 * 1024

    if byte1 is not None:
        if not byte2:
            byte2 = filesize
        yielded = byte1
        filesize = byte2

    with open('try2.mp4', 'rb') as f:
        content = f.read()

    while True:
        remaining = filesize - yielded
        if yielded == filesize:
            break
        if remaining >= yield_size:
            yield content[yielded:yielded+yield_size]
            yielded += yield_size
        else:
            yield content[yielded:yielded+remaining]
            yielded += remaining


@app.route('/')
def get_file():
    filesize = os.path.getsize('try2.mp4')
    range_header = flask_request.headers.get('Range', None)

    if range_header:
        byte1, byte2 = None, None
        match = re.search(r'(\d+)-(\d*)', range_header)
        groups = match.groups()

        if groups[0]:
            byte1 = int(groups[0])
        if groups[1]:
            byte2 = int(groups[1])

        if not byte2:
            byte2 = byte1 + 1024 * 1024
            if byte2 > filesize:
                byte2 = filesize

        length = byte2 + 1 - byte1

        resp = Response(
            get_chunk(byte1, byte2),
            status=206, mimetype='video/mp4',
            content_type='video/mp4',
            direct_passthrough=True
        )

        resp.headers.add('Content-Range',
                         'bytes {0}-{1}/{2}'
                         .format(byte1,
                                 length,
                                 filesize))
        return resp

    return Response(
        get_chunk(),
        status=200, mimetype='video/mp4'
    )


@app.after_request
def after_request(response):
    response.headers.add('Accept-Ranges', 'bytes')
    return response

get_chunk yields chunks from byte1 to byte2 if this bytes are specified, and from 0 to filesize otherwise (chunk size = 1MB).

But it does not work. I see that firstly browser sends request with <200> status. And then with <206>. Please advice me how to make it working.

ReturnedVoid
  • 185
  • 1
  • 1
  • 6

2 Answers2

17

On development server you need to enable threaded=True for video stream to work correctly.

Updated:

import os
import re

...


@app.after_request
def after_request(response):
    response.headers.add('Accept-Ranges', 'bytes')
    return response


def get_chunk(byte1=None, byte2=None):
    full_path = "try2.mp4"
    file_size = os.stat(full_path).st_size
    start = 0
    
    if byte1 < file_size:
        start = byte1
    if byte2:
        length = byte2 + 1 - byte1
    else:
        length = file_size - start

    with open(full_path, 'rb') as f:
        f.seek(start)
        chunk = f.read(length)
    return chunk, start, length, file_size


@app.route('/video')
def get_file():
    range_header = request.headers.get('Range', None)
    byte1, byte2 = 0, None
    if range_header:
        match = re.search(r'(\d+)-(\d*)', range_header)
        groups = match.groups()

        if groups[0]:
            byte1 = int(groups[0])
        if groups[1]:
            byte2 = int(groups[1])
       
    chunk, start, length, file_size = get_chunk(byte1, byte2)
    resp = Response(chunk, 206, mimetype='video/mp4',
                      content_type='video/mp4', direct_passthrough=True)
    resp.headers.add('Content-Range', 'bytes {0}-{1}/{2}'.format(start, start + length - 1, file_size))
    return resp

if __name__ == '__main__':
    app.run(threaded=True)
waynetech
  • 731
  • 6
  • 11
  • Thank you for your answer. But i need to use my custom function for yielding chunks. send_file() just accepts the filename.. i need it to take a generator function. – ReturnedVoid Aug 02 '19 at 12:11
  • it does the same thing, pushes data in chunks(but you can't set the chunk size) and when you seek the video it sends ranges to server in request and server responds with http code `206 Partial Content success`. Try it yourself. Your code will work if you set `app.run(threaded=True)` – waynetech Aug 02 '19 at 12:24
  • Sorry, as i remember, send_file() fails if file is not complete. For example, i receive a file from some source and client should wait untill file is downloaded. With custom generator function i can add some logic for that. I mean the situation when client wants the exact minute of video file, but those bytes are not avaliable (even on server). – ReturnedVoid Aug 02 '19 at 13:34
  • read [this answer](https://stackoverflow.com/questions/50001356/serving-a-mp4-file-with-flask-and-playing-it-on-an-objective-c-app-causes-broke#answer-54879747) – waynetech Aug 02 '19 at 13:51
  • i read answer you linked. Changed my code a bit, but still can't get result. I edited my code. I need my code to work as **send_file** function, but as i said i can't use **send_file** because i need custom chunks yielding (for example with changing chunks size, or adding some additional logic there). Also **send_file** would not work if file on server is not complete and being uploaded. – ReturnedVoid Aug 03 '19 at 08:19
  • I've updated the answer, this works when downloading the file. You can put custom logic in `get_chunk()` – waynetech Aug 04 '19 at 06:48
  • very appreciate your help. But in that way file would be streamed chunked with response <206>. When i use Flask's **send_file** for complete file, responses looks like: Response<200> - Response<200> - Response<206> - .... So firstly browser does not send Partial Content request, right ? – ReturnedVoid Aug 05 '19 at 08:03
  • **If conditional=True and filename is provided, this method will try to upgrade the response stream to support range requests. This will allow the request to be answered with partial content response.** The first request returns <200> and details that `Accept-Ranges: bytes` are allowed and when range request is sent it upgrades and returns <206> – waynetech Aug 05 '19 at 08:31
  • See now, and do you know the way to upgrade your code to act like that ? – ReturnedVoid Aug 05 '19 at 08:36
  • If you want to serve partial responses you need <206>, here in your case you want to serve chunks of file. <206> should be default in your case, no need to upgrade. Use <200> when you want to send the complete file all at once (that is what send_file usually does, so instead of your custom logic use `send_from_directory()` function). You can read [206 browser compatibility](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206) – waynetech Aug 05 '19 at 09:01
  • `length = 102400` appears to be unused? It gets assigned in the if else below no matter what so no need to give it an initial value – Frikster Mar 15 '21 at 22:42
  • @Frikster right, forgot to remove it while coming up with a solution – waynetech Mar 16 '21 at 09:05
3

okay i this might be coming late but this is a simplified code i wrote. still same concept as above but better and simpler i think.

import os
import re
from flask import render_template, request, Blueprint, current_app, send_file

core = Blueprint("core", __name__)

# your request handles here with @core.route()


@core.route("/")
def home():
    return render_template("index.html")


@core.route("/video", methods=["GET"])
def video():
    headers = request.headers
    if not "range" in headers:
        return current_app.response_class(status=400)

    video_path = os.path.abspath(os.path.join("media", "test.mp4"))
    size = os.stat(video_path)
    size = size.st_size

    chunk_size = (10 ** 6) * 3 #1000kb makes 1mb * 3 = 3mb (this is based on your choice)
    start = int(re.sub("\D", "", headers["range"]))
    end = min(start + chunk_size, size - 1)

    content_lenght = end - start + 1

    def get_chunk(video_path, start, chunk_size):
        with open(video_path, "rb") as f:
            f.seek(start)
            chunk = f.read(chunk_size)
        return chunk

    headers = {
        "Content-Range": f"bytes {start}-{end}/{size}",
        "Accept-Ranges": "bytes",
        "Content-Length": content_lenght,
        "Content-Type": "video/mp4",
    }

    return current_app.response_class(get_chunk(video_path, start,chunk_size), 206, headers)

goodnews john
  • 431
  • 4
  • 9
  • I want to thank you personally! Your code is elegant and practical. TY for sharing! – lkaupp May 27 '22 at 21:23
  • you're welcome @lkaupp. Thanks you also for the encouragement – goodnews john Jun 06 '22 at 09:28
  • f.read(end) is incorrect, you'll need to pass the chunk_size instead. 1kb is probably too small and results in too many requests – uwe Mar 31 '23 at 17:23
  • 1
    @uwe thanks for spotting the mistake.the 10kb is just as an example you could always tweek the params. i'll go ahead and update the example – goodnews john Apr 06 '23 at 12:59