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:
Mathieu Moran
2026-08-10 00:01:49 -04:00
co-authored by Copilot
parent 35ec705fe2
commit 600fd41353
4 changed files with 300 additions and 47 deletions
+45 -7
View File
@@ -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")