From 600fd413532fc55da8bc98316ac9382b515146f5 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 10 Aug 2026 00:01:49 -0400 Subject: [PATCH] fix(dashcam): range-request video streaming, modal player, async thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 <223556219+Copilot@users.noreply.github.com> --- app.py | 52 ++++++++-- modules/app_video.py | 62 +++++++---- static/js/local-videos.js | 210 ++++++++++++++++++++++++++++++++++++-- templates/dash.html | 23 +++-- 4 files changed, 300 insertions(+), 47 deletions(-) diff --git a/app.py b/app.py index 48af551..10f1bed 100644 --- a/app.py +++ b/app.py @@ -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") diff --git a/modules/app_video.py b/modules/app_video.py index a78bb22..9216cff 100644 --- a/modules/app_video.py +++ b/modules/app_video.py @@ -3,6 +3,7 @@ import os import subprocess import time +import gevent from gevent import sleep from gevent.lock import Semaphore from modules import app_dash, app_sql @@ -12,6 +13,9 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails") RETENTION_SETTING = "local_video_retention_days" _thumbnail_lock = Semaphore() +# Tracks which relative paths are currently being generated (set of strings) +_generating = set() +_generating_lock = Semaphore() def _local_path(relative_path): @@ -72,24 +76,44 @@ def get_local_video_path(relative_path): def get_thumbnail_path(relative_path): + """Return the thumbnail path if it exists, or kick off background generation. + + Raises: + FileNotFoundError: source video not found. + ValueError: path escapes base directory. + RuntimeError("generating"): thumbnail is being generated — caller should 202. + RuntimeError: ffmpeg failed (only propagated synchronously on first generation if + the greenlet hasn't launched yet, but typically never reaches caller). + """ source_path, thumbnail_path = _thumbnail_path(relative_path) source_mtime = os.path.getmtime(source_path) - with _thumbnail_lock: - if ( - os.path.isfile(thumbnail_path) - and os.path.getmtime(thumbnail_path) >= source_mtime - ): - return thumbnail_path - if not app_dash.begin_local_video_use(relative_path): - raise RuntimeError( - "This video is currently being deleted. Try the thumbnail again shortly." - ) + # Fast path: thumbnail already exists and is fresh. + if ( + os.path.isfile(thumbnail_path) + and os.path.getmtime(thumbnail_path) >= source_mtime + ): + return thumbnail_path + # Check if generation is already running. + with _generating_lock: + if relative_path in _generating: + raise RuntimeError("generating") + _generating.add(relative_path) + + # Spawn background greenlet so the HTTP thread returns immediately. + gevent.spawn(_generate_thumbnail_bg, relative_path, source_path, thumbnail_path) + raise RuntimeError("generating") + + +def _generate_thumbnail_bg(relative_path, source_path, thumbnail_path): + """Generate thumbnail in background greenlet. Cleans up _generating on exit.""" + try: + if not app_dash.begin_local_video_use(relative_path): + return try: os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) temporary_path = thumbnail_path + ".tmp.jpg" - error = None for seek_seconds in ("1", "0"): command = [ "ffmpeg", @@ -116,27 +140,25 @@ def get_thumbnail_path(relative_path): stderr=subprocess.PIPE, text=True, ) - except FileNotFoundError as e: - raise RuntimeError("ffmpeg is not installed on this device.") from e + except FileNotFoundError: + return while process.poll() is None: sleep(0.05) - _stdout, error = process.communicate() + process.communicate() if ( process.returncode == 0 and os.path.isfile(temporary_path) and os.path.getsize(temporary_path) > 0 ): os.replace(temporary_path, thumbnail_path) - return thumbnail_path + return if os.path.exists(temporary_path): os.remove(temporary_path) - - raise RuntimeError( - (error or "").strip() - or f"ffmpeg could not extract a frame from {relative_path}" - ) finally: app_dash.end_local_video_use(relative_path) + finally: + with _generating_lock: + _generating.discard(relative_path) def delete_local_video(relative_path): diff --git a/static/js/local-videos.js b/static/js/local-videos.js index 39bbd0a..a90aba2 100644 --- a/static/js/local-videos.js +++ b/static/js/local-videos.js @@ -4,23 +4,213 @@ const summary = document.getElementById("local-video-summary"); const body = document.querySelector("#local-video-list [data-data-list-body]"); const refreshButton = document.getElementById("local-videos-refresh-btn"); - const playerPanel = document.getElementById("local-video-player-panel"); - const playerTitle = document.getElementById("local-video-player-title"); - const player = document.getElementById("local-video-player"); - const closePlayerButton = document.getElementById("local-video-player-close"); let playingPath = null; + // ── Modal player ────────────────────────────────────────────────────────── + const modalEl = document.getElementById("video-player-modal"); + const modalTitle = document.getElementById("video-player-modal-title"); + const player = document.getElementById("local-video-player"); + const bsModal = new bootstrap.Modal(modalEl); + + modalEl.addEventListener("hidden.bs.modal", function () { + player.pause(); + player.removeAttribute("src"); + player.load(); + playingPath = null; + }); + + function playVideo(path) { + playingPath = path; + modalTitle.textContent = path.split("/").pop(); + player.src = `/api/local-video/file?path=${encodeURIComponent(path)}`; + bsModal.show(); + player.load(); + player.play().catch(function () {}); + } + + // ── Thumbnail loading with retry on 202 ────────────────────────────────── + const THUMB_RETRY_MS = 2000; + const THUMB_MAX_RETRIES = 15; + + function loadThumbnail(image) { + const url = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`; + let attempts = 0; + + function attempt() { + fetch(url) + .then(function (resp) { + if (resp.status === 202 && attempts < THUMB_MAX_RETRIES) { + attempts++; + setTimeout(attempt, THUMB_RETRY_MS); + return; + } + if (!resp.ok) { + image.hidden = true; + return; + } + return resp.blob().then(function (blob) { + image.src = URL.createObjectURL(blob); + }); + }) + .catch(function () { + image.hidden = true; + }); + } + attempt(); + } + const thumbnailObserver = new IntersectionObserver(function (entries, observer) { entries.forEach(function (entry) { - if (!entry.isIntersecting) { - return; - } - const image = entry.target; - image.src = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`; - observer.unobserve(image); + if (!entry.isIntersecting) return; + observer.unobserve(entry.target); + loadThumbnail(entry.target); }); }, { rootMargin: "200px" }); + // ── Utilities ───────────────────────────────────────────────────────────── + function fetchJson(url, options) { + return fetch(url, options).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) { + throw new Error(data.message || data.error || data.status || `HTTP ${response.status}`); + } + return data; + }); + }); + } + + function textCell(text) { + const cell = document.createElement("td"); + cell.textContent = text; + return cell; + } + + // ── File list rendering ─────────────────────────────────────────────────── + function renderFiles(result) { + thumbnailObserver.disconnect(); + body.replaceChildren(); + const files = result.files || []; + let uploadedCount = 0; + + files.forEach(function (file) { + const row = document.createElement("tr"); + row.dataset.path = file.path; + + // Thumbnail cell + const preview = document.createElement("td"); + const image = document.createElement("img"); + image.className = "local-video-thumbnail"; + image.alt = ""; + image.dataset.path = file.path; + preview.appendChild(image); + row.appendChild(preview); + thumbnailObserver.observe(image); + + // File name + const nameCell = document.createElement("td"); + nameCell.className = "dash-col-file"; + nameCell.textContent = file.path; + row.appendChild(nameCell); + + // Size + const sizeCell = textCell(YarisUI.formatBytes(file.size)); + sizeCell.className = "dash-col-size"; + row.appendChild(sizeCell); + + // Date + const dateCell = textCell(new Date(file.modified_at * 1000).toLocaleString()); + dateCell.className = "dash-col-date"; + row.appendChild(dateCell); + + // Upload badge + const upload = document.createElement("td"); + upload.className = "dash-col-upload"; + const badge = document.createElement("span"); + badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark"; + badge.textContent = file.uploaded ? "✓" : "…"; + upload.appendChild(badge); + row.appendChild(upload); + if (file.uploaded) uploadedCount++; + + // Actions + const actions = document.createElement("td"); + actions.className = "data-list-actions"; + + const playButton = document.createElement("button"); + playButton.type = "button"; + playButton.className = "btn btn-sm btn-outline-primary btn-icon-mobile"; + playButton.dataset.playPath = file.path; + playButton.innerHTML = ' Play'; + actions.appendChild(playButton); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "btn btn-sm btn-outline-danger btn-icon-mobile"; + deleteButton.dataset.deleteLocalPath = file.path; + deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`; + deleteButton.innerHTML = ' Del'; + actions.appendChild(deleteButton); + row.appendChild(actions); + body.appendChild(row); + }); + + summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`; + if (result.retention_days > 0) { + summary.textContent += `; > ${result.retention_days}d uploaded auto-deleted`; + } + if (result.errors && result.errors.length) { + summary.textContent += ` (${result.errors.length} error(s))`; + } + } + + function refreshFiles() { + refreshButton.disabled = true; + summary.textContent = "Loading..."; + return fetchJson("/api/local-videos") + .then(renderFiles) + .catch(function (error) { + body.replaceChildren(); + summary.textContent = `Unable to list local videos: ${error.message}`; + }) + .finally(function () { + refreshButton.disabled = false; + }); + } + + function deleteVideo(path) { + return fetchJson("/api/local-video", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }).then(function () { + if (playingPath === path) bsModal.hide(); + return refreshFiles(); + }).catch(function (error) { + summary.textContent = `Local delete failed: ${error.message}`; + }); + } + + body.addEventListener("click", function (event) { + const playButton = event.target.closest("[data-play-path]"); + if (playButton) { + playVideo(playButton.dataset.playPath); + return; + } + const deleteButton = event.target.closest("[data-delete-local-path]"); + if (deleteButton) { + if (!confirm(deleteButton.dataset.confirm)) return; + deleteButton.disabled = true; + deleteVideo(deleteButton.dataset.deleteLocalPath).finally(function () { + deleteButton.disabled = false; + }); + } + }); + + refreshButton.addEventListener("click", refreshFiles); + refreshFiles(); +}()); + + function fetchJson(url, options) { return fetch(url, options).then(function (response) { return response.json().then(function (data) { diff --git a/templates/dash.html b/templates/dash.html index 0d0c116..7012525 100644 --- a/templates/dash.html +++ b/templates/dash.html @@ -99,16 +99,19 @@ -{# ── Video player (hidden until opened) ─────────────────────────────── #} -