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
|
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
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
@@ -149,12 +149,48 @@ def local_video_file():
|
|||||||
return jsonify(error=str(error)), 400
|
return jsonify(error=str(error)), 400
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||||
return send_file(
|
|
||||||
path,
|
file_size = os.path.getsize(path)
|
||||||
conditional=True,
|
mime = "video/mp4"
|
||||||
as_attachment=False,
|
range_header = request.headers.get("Range")
|
||||||
download_name=os.path.basename(path),
|
|
||||||
)
|
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')
|
@app.route('/api/local-video/thumbnail')
|
||||||
def local_video_thumbnail():
|
def local_video_thumbnail():
|
||||||
@@ -166,6 +202,8 @@ def local_video_thumbnail():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
return jsonify(error=f"Local video not found: {relative_path}"), 404
|
||||||
except RuntimeError as error:
|
except RuntimeError as error:
|
||||||
|
if str(error) == "generating":
|
||||||
|
return jsonify(status="generating"), 202
|
||||||
return jsonify(error=str(error)), 422
|
return jsonify(error=str(error)), 422
|
||||||
return send_file(path, conditional=True, mimetype="image/jpeg")
|
return send_file(path, conditional=True, mimetype="image/jpeg")
|
||||||
|
|
||||||
|
|||||||
+37
-15
@@ -3,6 +3,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import gevent
|
||||||
from gevent import sleep
|
from gevent import sleep
|
||||||
from gevent.lock import Semaphore
|
from gevent.lock import Semaphore
|
||||||
from modules import app_dash, app_sql
|
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")
|
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
|
||||||
RETENTION_SETTING = "local_video_retention_days"
|
RETENTION_SETTING = "local_video_retention_days"
|
||||||
_thumbnail_lock = Semaphore()
|
_thumbnail_lock = Semaphore()
|
||||||
|
# Tracks which relative paths are currently being generated (set of strings)
|
||||||
|
_generating = set()
|
||||||
|
_generating_lock = Semaphore()
|
||||||
|
|
||||||
|
|
||||||
def _local_path(relative_path):
|
def _local_path(relative_path):
|
||||||
@@ -72,24 +76,44 @@ def get_local_video_path(relative_path):
|
|||||||
|
|
||||||
|
|
||||||
def get_thumbnail_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_path, thumbnail_path = _thumbnail_path(relative_path)
|
||||||
source_mtime = os.path.getmtime(source_path)
|
source_mtime = os.path.getmtime(source_path)
|
||||||
|
|
||||||
with _thumbnail_lock:
|
# Fast path: thumbnail already exists and is fresh.
|
||||||
if (
|
if (
|
||||||
os.path.isfile(thumbnail_path)
|
os.path.isfile(thumbnail_path)
|
||||||
and os.path.getmtime(thumbnail_path) >= source_mtime
|
and os.path.getmtime(thumbnail_path) >= source_mtime
|
||||||
):
|
):
|
||||||
return thumbnail_path
|
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:
|
try:
|
||||||
os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True)
|
os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True)
|
||||||
temporary_path = thumbnail_path + ".tmp.jpg"
|
temporary_path = thumbnail_path + ".tmp.jpg"
|
||||||
error = None
|
|
||||||
for seek_seconds in ("1", "0"):
|
for seek_seconds in ("1", "0"):
|
||||||
command = [
|
command = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@@ -116,27 +140,25 @@ def get_thumbnail_path(relative_path):
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError:
|
||||||
raise RuntimeError("ffmpeg is not installed on this device.") from e
|
return
|
||||||
while process.poll() is None:
|
while process.poll() is None:
|
||||||
sleep(0.05)
|
sleep(0.05)
|
||||||
_stdout, error = process.communicate()
|
process.communicate()
|
||||||
if (
|
if (
|
||||||
process.returncode == 0
|
process.returncode == 0
|
||||||
and os.path.isfile(temporary_path)
|
and os.path.isfile(temporary_path)
|
||||||
and os.path.getsize(temporary_path) > 0
|
and os.path.getsize(temporary_path) > 0
|
||||||
):
|
):
|
||||||
os.replace(temporary_path, thumbnail_path)
|
os.replace(temporary_path, thumbnail_path)
|
||||||
return thumbnail_path
|
return
|
||||||
if os.path.exists(temporary_path):
|
if os.path.exists(temporary_path):
|
||||||
os.remove(temporary_path)
|
os.remove(temporary_path)
|
||||||
|
|
||||||
raise RuntimeError(
|
|
||||||
(error or "").strip()
|
|
||||||
or f"ffmpeg could not extract a frame from {relative_path}"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
app_dash.end_local_video_use(relative_path)
|
app_dash.end_local_video_use(relative_path)
|
||||||
|
finally:
|
||||||
|
with _generating_lock:
|
||||||
|
_generating.discard(relative_path)
|
||||||
|
|
||||||
|
|
||||||
def delete_local_video(relative_path):
|
def delete_local_video(relative_path):
|
||||||
|
|||||||
+200
-10
@@ -4,23 +4,213 @@
|
|||||||
const summary = document.getElementById("local-video-summary");
|
const summary = document.getElementById("local-video-summary");
|
||||||
const body = document.querySelector("#local-video-list [data-data-list-body]");
|
const body = document.querySelector("#local-video-list [data-data-list-body]");
|
||||||
const refreshButton = document.getElementById("local-videos-refresh-btn");
|
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;
|
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) {
|
const thumbnailObserver = new IntersectionObserver(function (entries, observer) {
|
||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
if (!entry.isIntersecting) {
|
if (!entry.isIntersecting) return;
|
||||||
return;
|
observer.unobserve(entry.target);
|
||||||
}
|
loadThumbnail(entry.target);
|
||||||
const image = entry.target;
|
|
||||||
image.src = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`;
|
|
||||||
observer.unobserve(image);
|
|
||||||
});
|
});
|
||||||
}, { rootMargin: "200px" });
|
}, { 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) {
|
function fetchJson(url, options) {
|
||||||
return fetch(url, options).then(function (response) {
|
return fetch(url, options).then(function (response) {
|
||||||
return response.json().then(function (data) {
|
return response.json().then(function (data) {
|
||||||
|
|||||||
+12
-9
@@ -99,16 +99,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Video player (hidden until opened) ─────────────────────────────── #}
|
{# ── Video player modal ──────────────────────────────────────────────── #}
|
||||||
<div id="local-video-player-panel" class="card shadow-sm mb-3" hidden>
|
<div class="modal fade" id="video-player-modal" tabindex="-1" aria-labelledby="video-player-modal-title" aria-modal="true" role="dialog">
|
||||||
<div class="card-header py-2 d-flex align-items-center justify-content-between">
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
<p id="local-video-player-title" class="text-primary m-0 fw-bold small"></p>
|
<div class="modal-content bg-dark">
|
||||||
<button id="local-video-player-close" class="btn btn-sm btn-outline-secondary" type="button">
|
<div class="modal-header border-secondary py-2">
|
||||||
<i class="fas fa-times me-1"></i>Close
|
<h6 class="modal-title text-light text-truncate me-2" id="video-player-modal-title"></h6>
|
||||||
</button>
|
<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>
|
||||||
<div class="card-body p-0">
|
|
||||||
<video id="local-video-player" class="local-video-player" controls preload="metadata"></video>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user