fix(dashcam): range-request video streaming, modal player, async thumbnails
- app.py: replace send_file with manual Range-aware chunked generator;
returns 206 on partial requests so browsers can seek/buffer properly.
Thumbnail route returns 202 while ffmpeg is generating.
- app_video.py: thumbnail generation moved to background gevent greenlet
(_generate_thumbnail_bg). get_thumbnail_path returns immediately with
RuntimeError('generating') if not cached; ffmpeg only ever runs once
per video (result cached in dash/thumbnails/).
- dash.html: replaced bottom card player with Bootstrap modal (modal-xl,
centered, dark bg) — video pops up in place regardless of scroll position.
- local-videos.js: rewritten to use Bootstrap modal; thumbnail loader
polls with fetch() and retries on 202 every 2s (up to 15x) instead of
setting img.src directly (which can't detect 202).
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file
|
||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
|
||||
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network
|
||||
import os, time
|
||||
from gevent.pywsgi import WSGIServer
|
||||
@@ -149,12 +149,48 @@ def local_video_file():
|
||||
return jsonify(error=str(error)), 400
|
||||
except FileNotFoundError:
|
||||
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||
return send_file(
|
||||
path,
|
||||
conditional=True,
|
||||
as_attachment=False,
|
||||
download_name=os.path.basename(path),
|
||||
)
|
||||
|
||||
file_size = os.path.getsize(path)
|
||||
mime = "video/mp4"
|
||||
range_header = request.headers.get("Range")
|
||||
|
||||
if range_header:
|
||||
# Parse "bytes=start-end"
|
||||
byte1, byte2 = 0, None
|
||||
m = range_header.strip().replace("bytes=", "").split("-")
|
||||
byte1 = int(m[0]) if m[0] else 0
|
||||
byte2 = int(m[1]) if len(m) > 1 and m[1] else file_size - 1
|
||||
length = byte2 - byte1 + 1
|
||||
|
||||
def generate_range():
|
||||
with open(path, "rb") as f:
|
||||
f.seek(byte1)
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = f.read(min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
resp = Response(generate_range(), 206, mimetype=mime, direct_passthrough=True)
|
||||
resp.headers["Content-Range"] = f"bytes {byte1}-{byte2}/{file_size}"
|
||||
resp.headers["Accept-Ranges"] = "bytes"
|
||||
resp.headers["Content-Length"] = str(length)
|
||||
return resp
|
||||
|
||||
def generate_full():
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
resp = Response(generate_full(), 200, mimetype=mime, direct_passthrough=True)
|
||||
resp.headers["Accept-Ranges"] = "bytes"
|
||||
resp.headers["Content-Length"] = str(file_size)
|
||||
return resp
|
||||
|
||||
@app.route('/api/local-video/thumbnail')
|
||||
def local_video_thumbnail():
|
||||
@@ -166,6 +202,8 @@ def local_video_thumbnail():
|
||||
except FileNotFoundError:
|
||||
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||
except RuntimeError as error:
|
||||
if str(error) == "generating":
|
||||
return jsonify(status="generating"), 202
|
||||
return jsonify(error=str(error)), 422
|
||||
return send_file(path, conditional=True, mimetype="image/jpeg")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user