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")
|
||||
|
||||
|
||||
+37
-15
@@ -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:
|
||||
# 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
|
||||
if not app_dash.begin_local_video_use(relative_path):
|
||||
raise RuntimeError(
|
||||
"This video is currently being deleted. Try the thumbnail again shortly."
|
||||
)
|
||||
|
||||
# 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):
|
||||
|
||||
+200
-10
@@ -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 = '<i class="fas fa-play"></i><span class="btn-label"> Play</span>';
|
||||
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 = '<i class="fas fa-trash"></i><span class="btn-label"> Del</span>';
|
||||
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) {
|
||||
|
||||
+12
-9
@@ -99,16 +99,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Video player (hidden until opened) ─────────────────────────────── #}
|
||||
<div id="local-video-player-panel" class="card shadow-sm mb-3" hidden>
|
||||
<div class="card-header py-2 d-flex align-items-center justify-content-between">
|
||||
<p id="local-video-player-title" class="text-primary m-0 fw-bold small"></p>
|
||||
<button id="local-video-player-close" class="btn btn-sm btn-outline-secondary" type="button">
|
||||
<i class="fas fa-times me-1"></i>Close
|
||||
</button>
|
||||
{# ── Video player modal ──────────────────────────────────────────────── #}
|
||||
<div class="modal fade" id="video-player-modal" tabindex="-1" aria-labelledby="video-player-modal-title" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-secondary py-2">
|
||||
<h6 class="modal-title text-light text-truncate me-2" id="video-player-modal-title"></h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<video id="local-video-player" class="w-100 d-block" style="max-height:75vh;background:#000;"
|
||||
controls preload="metadata"></video>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<video id="local-video-player" class="local-video-player" controls preload="metadata"></video>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user