543 lines
21 KiB
Python
543 lines
21 KiB
Python
"""
|
|
VideoDetect Review UI — Flask Application
|
|
|
|
Lightweight web interface for annotating pending REVIEW tasks.
|
|
Accessible via http://<server-ip>:5000 — no auth, no SSL (per TC-06).
|
|
|
|
The UI queries the generic API (http://api:3000/api/v1/...) for all data
|
|
and posts review results back through the same API. No direct DB access.
|
|
"""
|
|
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
from flask import Flask, Response, jsonify, render_template_string, request, send_file
|
|
|
|
app = Flask(__name__)
|
|
|
|
API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1")
|
|
VIDEO_CHUNK_SIZE = 1024 * 1024
|
|
|
|
VIDEO_MIME_TYPES = {
|
|
".3gp": "video/3gpp",
|
|
".avi": "video/x-msvideo",
|
|
".flv": "video/x-flv",
|
|
".m2ts": "video/mp2t",
|
|
".m4v": "video/mp4",
|
|
".mkv": "video/x-matroska",
|
|
".mov": "video/quicktime",
|
|
".mp4": "video/mp4",
|
|
".mpeg": "video/mpeg",
|
|
".mpg": "video/mpeg",
|
|
".ogv": "video/ogg",
|
|
".ts": "video/mp2t",
|
|
".webm": "video/webm",
|
|
".wmv": "video/x-ms-wmv",
|
|
}
|
|
|
|
|
|
def api_get(path: str, params: dict = None) -> dict:
|
|
"""Perform a GET request to the API and return JSON."""
|
|
url = f"{API_BASE}/{path}"
|
|
resp = requests.get(url, params=params, timeout=10)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def api_post(path: str, data: dict = None) -> dict:
|
|
"""Perform a POST request to the API and return JSON."""
|
|
url = f"{API_BASE}/{path}"
|
|
resp = requests.post(url, json=data or {}, timeout=10)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def candidate_video_paths(file_path: str) -> list[str]:
|
|
"""Return possible local paths for a video path stored by the API."""
|
|
if os.path.isabs(file_path):
|
|
return [file_path]
|
|
|
|
nfs_base = os.environ.get("NAS_INPUT_PATH", "/data")
|
|
return [os.path.join(nfs_base, file_path)]
|
|
|
|
|
|
def resolve_video_path(file_path: str) -> str | None:
|
|
"""Find the first readable local path for a video."""
|
|
for full_path in candidate_video_paths(file_path):
|
|
if os.path.isfile(full_path):
|
|
return full_path
|
|
return None
|
|
|
|
|
|
def guess_video_mimetype(file_path: str) -> str:
|
|
"""Infer a browser-facing media type from the filename, then file signature."""
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
if ext in VIDEO_MIME_TYPES:
|
|
return VIDEO_MIME_TYPES[ext]
|
|
|
|
guessed, _ = mimetypes.guess_type(file_path)
|
|
if guessed:
|
|
return guessed
|
|
|
|
try:
|
|
with open(file_path, "rb") as fh:
|
|
header = fh.read(512)
|
|
except OSError:
|
|
return "application/octet-stream"
|
|
|
|
if len(header) >= 12 and header[4:8] == b"ftyp":
|
|
return "video/quicktime" if header[8:12] == b"qt " else "video/mp4"
|
|
if header.startswith(b"\x1a\x45\xdf\xa3"):
|
|
return "video/webm" if b"webm" in header.lower() else "video/x-matroska"
|
|
if header.startswith(b"RIFF") and header[8:12] == b"AVI ":
|
|
return "video/x-msvideo"
|
|
if header.startswith(b"FLV"):
|
|
return "video/x-flv"
|
|
if header.startswith(b"\x00\x00\x01\xba") or header.startswith(b"\x00\x00\x01"):
|
|
return "video/mpeg"
|
|
|
|
return "application/octet-stream"
|
|
|
|
|
|
def parse_range_header(range_header: str, file_size: int) -> tuple[int, int] | None:
|
|
"""Parse a single HTTP byte range, returning inclusive start/end offsets."""
|
|
if not range_header.lower().startswith("bytes="):
|
|
return None
|
|
|
|
ranges = range_header.partition("=")[2].split(",")
|
|
if len(ranges) != 1 or "-" not in ranges[0]:
|
|
return None
|
|
|
|
start_s, end_s = ranges[0].strip().split("-", 1)
|
|
try:
|
|
if start_s:
|
|
start = int(start_s)
|
|
end = int(end_s) if end_s else file_size - 1
|
|
else:
|
|
suffix_length = int(end_s)
|
|
if suffix_length <= 0:
|
|
return None
|
|
start = max(file_size - suffix_length, 0)
|
|
end = file_size - 1
|
|
except ValueError:
|
|
return None
|
|
|
|
if start < 0 or end < start or start >= file_size:
|
|
return None
|
|
|
|
return start, min(end, file_size - 1)
|
|
|
|
|
|
def iter_file_range(file_path: str, start: int, end: int):
|
|
"""Yield a byte range from disk without loading the full video into memory."""
|
|
with open(file_path, "rb") as fh:
|
|
fh.seek(start)
|
|
remaining = end - start + 1
|
|
while remaining > 0:
|
|
chunk = fh.read(min(VIDEO_CHUNK_SIZE, remaining))
|
|
if not chunk:
|
|
break
|
|
remaining -= len(chunk)
|
|
yield chunk
|
|
|
|
|
|
def content_disposition(filename: str) -> str:
|
|
"""Build an inline Content-Disposition value that tolerates spaces/unicode."""
|
|
ascii_name = filename.encode("ascii", "ignore").decode("ascii").replace('"', "")
|
|
ascii_name = ascii_name or "video"
|
|
return f"inline; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route("/")
|
|
def index():
|
|
"""Index view: list of pending REVIEW tasks with pagination."""
|
|
page = request.args.get("page", 1, type=int)
|
|
per_page = int(os.environ.get("PER_PAGE", 20))
|
|
|
|
try:
|
|
data = api_get("tasks", {
|
|
"type": "REVIEW",
|
|
"status": "PENDING",
|
|
"page": page,
|
|
"per_page": per_page,
|
|
})
|
|
except requests.RequestException as exc:
|
|
return jsonify({"error": f"API unavailable: {exc}"}), 503
|
|
|
|
tasks = data.get("tasks", [])
|
|
total = data.get("total", 0)
|
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
|
|
|
# Enrich each task with its associated video info from the API
|
|
enriched = []
|
|
for t in tasks:
|
|
vid_id = t.get("video_id")
|
|
video_path = "unknown"
|
|
resolution = None
|
|
try:
|
|
vdata = api_get(f"video/{vid_id}")
|
|
video_path = vdata.get("file_path", "unknown")
|
|
res_w = vdata.get("resolution_w")
|
|
res_h = vdata.get("resolution_h")
|
|
if res_w and res_h:
|
|
resolution = f"{res_w}x{res_h}"
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
enriched.append({
|
|
"id": t["id"],
|
|
"video_id": vid_id,
|
|
"file_path": video_path,
|
|
"task_type": t.get("task_type"),
|
|
"status": t.get("status"),
|
|
"created_at": t.get("assigned_at") or t.get("created_at"),
|
|
"resolution": resolution,
|
|
})
|
|
|
|
return render_template_string(
|
|
_INDEX_TEMPLATE,
|
|
tasks=enriched,
|
|
page=page,
|
|
per_page=per_page,
|
|
total=total,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
|
|
@app.route("/review/<int:task_id>")
|
|
def review_task(task_id):
|
|
"""Detail view: show task info + embedded video player."""
|
|
try:
|
|
data = api_get(f"task/{task_id}")
|
|
if not data or not data.get("id"):
|
|
return "Task not found", 404
|
|
|
|
video_id = data["video_id"]
|
|
vid_data = api_get(f"video/{video_id}")
|
|
file_path = vid_data.get("file_path", "unknown")
|
|
except requests.RequestException as exc:
|
|
return jsonify({"error": f"API unavailable: {exc}"}), 503
|
|
|
|
video_obj = dict(data)
|
|
video_obj["file_path"] = file_path
|
|
video_obj["video_duration"] = vid_data.get("duration")
|
|
video_obj["resolution"] = vid_data.get("resolution_w") and vid_data.get("resolution_h") \
|
|
and f"{vid_data['resolution_w']}x{vid_data['resolution_h']}"
|
|
resolved_path = resolve_video_path(file_path)
|
|
video_obj["mime_type"] = guess_video_mimetype(resolved_path or file_path)
|
|
|
|
# Parse results if already present
|
|
raw_results = data.get("results", {})
|
|
try:
|
|
video_obj["prev_results"] = json.loads(raw_results) if isinstance(raw_results, str) else (raw_results or {})
|
|
except (json.JSONDecodeError, TypeError):
|
|
video_obj["prev_results"] = {}
|
|
|
|
return render_template_string(_REVIEW_TEMPLATE, video=video_obj)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API routes (Flask → NFS file serving + JSON wrappers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route("/video/<int:video_id>")
|
|
def serve_video(video_id):
|
|
"""Stream video from NFS-mounted /data; supports HTTP range requests."""
|
|
# Resolve video path via the API first
|
|
try:
|
|
vid_data = api_get(f"video/{video_id}")
|
|
file_path = vid_data.get("file_path", "")
|
|
except requests.RequestException:
|
|
return "Not found", 404
|
|
|
|
if not file_path or file_path == "unknown":
|
|
return "Video file path unknown", 404
|
|
|
|
full_path = resolve_video_path(file_path)
|
|
if not full_path:
|
|
return "Video file not found on disk", 404
|
|
|
|
mime_type = guess_video_mimetype(full_path)
|
|
file_size = os.path.getsize(full_path)
|
|
range_header = request.headers.get("Range")
|
|
|
|
if range_header:
|
|
byte_range = parse_range_header(range_header, file_size)
|
|
if not byte_range:
|
|
return Response(
|
|
status=416,
|
|
headers={"Content-Range": f"bytes */{file_size}"},
|
|
)
|
|
|
|
start, end = byte_range
|
|
response = Response(
|
|
iter_file_range(full_path, start, end),
|
|
status=206,
|
|
mimetype=mime_type,
|
|
direct_passthrough=True,
|
|
)
|
|
response.headers["Accept-Ranges"] = "bytes"
|
|
response.headers["Content-Length"] = str(end - start + 1)
|
|
response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
|
response.headers["Content-Disposition"] = content_disposition(os.path.basename(full_path))
|
|
return response
|
|
|
|
response = send_file(
|
|
full_path,
|
|
mimetype=mime_type,
|
|
as_attachment=False,
|
|
download_name=os.path.basename(full_path),
|
|
conditional=True,
|
|
)
|
|
response.headers["Accept-Ranges"] = "bytes"
|
|
return response
|
|
|
|
|
|
@app.route("/api/review/submit/<int:task_id>", methods=["POST"])
|
|
def api_submit_review(task_id):
|
|
"""Submit review verdict for a task via the generic complete endpoint."""
|
|
data = request.get_json(silent=True) or {}
|
|
verdict = data.get("verdict") # "false_positive", "correct", or other string
|
|
|
|
if not verdict:
|
|
return jsonify({"error": "verdict is required"}), 400
|
|
|
|
try:
|
|
result = api_post(f"task/{task_id}/complete", {
|
|
"results": {"reviewer_verdict": verdict},
|
|
})
|
|
return jsonify({"status": "completed", "task_id": task_id, "verdict": verdict})
|
|
except requests.RequestException as exc:
|
|
return jsonify({"error": f"API error: {exc}"}), 503
|
|
|
|
|
|
@app.route("/api/review/skip/<int:task_id>", methods=["POST"])
|
|
def api_skip_review(task_id):
|
|
"""Skip (unassign + reset) a task so it returns to the PENDING queue."""
|
|
try:
|
|
result = api_post(f"task/{task_id}/unassign", {"reset": 1})
|
|
return jsonify({"status": "skipped", "task_id": task_id})
|
|
except requests.RequestException as exc:
|
|
return jsonify({"error": f"API error: {exc}"}), 503
|
|
|
|
|
|
@app.route("/api/review/stats")
|
|
def api_stats():
|
|
"""Return a summary of REVIEW tasks (via two API calls)."""
|
|
try:
|
|
pending = api_get("tasks", {"type": "REVIEW", "status": "PENDING"}) or {}
|
|
completed = api_get("tasks", {"type": "REVIEW", "status": "COMPLETED"}) or {}
|
|
failed = api_get("tasks", {"type": "REVIEW", "status": "FAILED"}) or {}
|
|
|
|
return jsonify({
|
|
"total_in_queue": pending.get("total", 0),
|
|
"completed": completed.get("total", 0),
|
|
"failed": failed.get("total", 0),
|
|
})
|
|
except requests.RequestException as exc:
|
|
return jsonify({"error": f"API error: {exc}"}), 503
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Templates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_INDEX_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VideoDetect — Review Queue</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
|
|
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
|
|
.header h1{font-size:22px}.header .meta{font-size:13px;opacity:.75}
|
|
.container{max-width:1280px;margin:30px auto;padding:0 20px}
|
|
.stats{background:#fff;border-radius:8px;padding:16px 20px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;gap:24px;flex-wrap:wrap}
|
|
.stat{text-align:center}.stat .num{font-size:24px;font-weight:700;color:#007bff}.stat .lbl{font-size:11px;color:#888;text-transform:uppercase}
|
|
.card{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
table{width:100%;border-collapse:collapse}
|
|
th{background:#f8f9fa;padding:12px 14px;text-align:left;font-size:12px;text-transform:uppercase;color:#666;border-bottom:2px solid #e9ecef}
|
|
td{padding:12px 14px;border-bottom:1px solid #e9ecef;font-size:13px}
|
|
tr:hover{background:#fafafa}
|
|
.badge{display:inline-block;padding:3px 9px;border-radius:10px;font-size:11px;font-weight:600;background:#fff3cd;color:#856404}
|
|
.btn{padding:6px 14px;background:#007bff;color:#fff;text-decoration:none;border-radius:5px;font-size:12px;border:none;cursor:pointer}
|
|
.btn:hover{background:#0056b3}
|
|
.pagination{display:flex;justify-content:center;gap:6px;margin-top:20px}
|
|
.pagination a,.pagination span{padding:7px 13px;border-radius:5px;font-size:13px;text-decoration:none}
|
|
.pagination a{background:#fff;color:#007bff;border:1px solid #dee2e6}.pagination a:hover{background:#e9ecef}
|
|
.pagination .cur{background:#007bff;color:#fff}
|
|
.empty{text-align:center;padding:60px;color:#999}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>VideoDetect</h1>
|
|
<span class="meta">Review Queue — {{ total }} pending — Page {{ page }}/{{ total_pages }}</span>
|
|
</div>
|
|
<div class="container">
|
|
<div class="stats">
|
|
<div class="stat"><div class="num">{{ total }}</div><div class="lbl">In Queue</div></div>
|
|
<div class="stat"><div class="num">{{ per_page }}</div><div class="lbl">Per Page</div></div>
|
|
</div>
|
|
{% if tasks %}
|
|
<div class="card">
|
|
<table>
|
|
<thead><tr>
|
|
<th>ID</th><th>File Path</th><th>Resolution</th><th>Task Type</th><th>Status</th><th>Date</th><th></th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for t in tasks %}
|
|
<tr>
|
|
<td>{{ t.id }}</td>
|
|
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ t.file_path }}">{{ t.file_path }}</td>
|
|
<td>{{ t.resolution or '—' }}</td>
|
|
<td><span class="badge">{{ t.task_type }}</span></td>
|
|
<td>{{ t.status }}</td>
|
|
<td>{{ t.created_at|string|truncate(19, True, '') if t.created_at else '—' }}</td>
|
|
<td><a href="/review/{{ t.id }}" class="btn">Review</a></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination">
|
|
{% if page > 1 %}<a href="?page={{ page-1 }}">« Prev</a>{% endif %}
|
|
<span class="cur">{{ page }}</span>
|
|
{% if page < total_pages %}<a href="?page={{ page+1 }}">Next »</a>{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW tasks have been completed.</p></div>
|
|
{% endif %}
|
|
</div>
|
|
</body></html>
|
|
"""
|
|
|
|
_REVIEW_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VideoDetect — Review #{{ video.id }}</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
|
|
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
|
|
.header h1{font-size:22px}.header a{color:#adb5bd;text-decoration:none}.header a:hover{color:#fff}
|
|
.container{max-width:960px;margin:30px auto;padding:0 20px}
|
|
.card{background:#fff;border-radius:8px;padding:22px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
.card h2{font-size:16px;margin-bottom:14px;color:#1a1a2e}
|
|
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
|
.info-label{font-size:11px;text-transform:uppercase;color:#888;margin-bottom:3px}
|
|
.info-value{font-size:15px;font-weight:600}
|
|
.video-wrap{width:100%;background:#000;border-radius:6px;overflow:hidden}
|
|
video{width:100%;display:block}
|
|
.actions{display:flex;gap:12px;margin-top:14px}
|
|
.action-btn{flex:1;padding:14px;border:2px solid #dee2e6;border-radius:8px;background:#fff;cursor:pointer;font-size:15px;font-weight:600;transition:all .15s;text-align:center}
|
|
.action-btn:hover{border-color:#007bff;transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.1)}
|
|
.action-btn.false-positive:hover{border-color:#dc3545;color:#dc3545;background:#fff5f5}
|
|
.action-btn.correct:hover{border-color:#28a745;color:#28a745;background:#f0fff4}
|
|
.skip-btn:hover{border-color:#6c757d;color:#6c757d;background:#f8f9fa}
|
|
.already{color:#28a745;font-weight:600;margin-top:8px;padding:14px;background:#d4edda;border-radius:8px;text-align:center}
|
|
#flash{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;color:#fff;font-weight:600;z-index:999;display:none}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>VideoDetect</h1>
|
|
<a href="/">« Back to queue</a>
|
|
</div>
|
|
<div class="container">
|
|
{% if video.prev_results and video.prev_results.review_verdict %}
|
|
<div class="already">Previously reviewed: {{ video.prev_results.review_verdict }}</div>
|
|
{% endif %}
|
|
|
|
<div class="card">
|
|
<h2>Task information</h2>
|
|
<div class="grid">
|
|
<div><div class="info-label">Task ID</div><div class="info-value">{{ video.id }}</div></div>
|
|
<div><div class="info-label">Task Type</div><div class="info-value">{{ video.task_type }}</div></div>
|
|
<div><div class="info-label">Status</div><div class="info-value">{{ video.status }}</div></div>
|
|
<div><div class="info-label">Resolution</div><div class="info-value">{{ video.resolution or '—' }}</div></div>
|
|
<div><div class="info-label">Duration</div><div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else '—' }}s</div></div>
|
|
<div><div class="info-label">Assigned</div><div class="info-value">{{ video.assigned_at|string|truncate(19, True, '') if video.assigned_at else '—' }}</div></div>
|
|
</div>
|
|
<div style="margin-top:10px;font-size:12px;color:#888;word-break:break-all">{{ video.file_path }}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Video</h2>
|
|
<div class="video-wrap">
|
|
<video controls preload="metadata" id="review-video">
|
|
<source src="/video/{{ video.video_id }}"{% if video.mime_type.startswith('video/') %} type="{{ video.mime_type }}"{% endif %}>
|
|
Video playback not supported.
|
|
</video>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Review Actions</h2>
|
|
<div class="actions">
|
|
<button class="action-btn false-positive" onclick="submitVerdict('false_positive')">False Positive</button>
|
|
<button class="action-btn correct" onclick="submitVerdict('correct')">Correct</button>
|
|
<button class="action-btn skip-btn" onclick="skipTask()">Skip</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function submitVerdict(verdict) {
|
|
const resp = await fetch(`/api/review/submit/{{ video.id }}`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({verdict})
|
|
});
|
|
if (resp.ok) {
|
|
showFlash('Verdict submitted ✓', '#28a745');
|
|
setTimeout(() => location.href = '/', 1000);
|
|
} else {
|
|
const err = await resp.json();
|
|
alert(err.error || 'Submission failed');
|
|
}
|
|
}
|
|
|
|
async function skipTask() {
|
|
if (!confirm('Skip this task? It will be returned to the PENDING queue.')) return;
|
|
const resp = await fetch(`/api/review/skip/{{ video.id }}`, {method: 'POST'});
|
|
if (resp.ok) {
|
|
showFlash('Task skipped, returned to queue', '#6c757d');
|
|
setTimeout(() => location.href = '/', 1000);
|
|
} else {
|
|
alert('Skip failed');
|
|
}
|
|
}
|
|
|
|
function showFlash(msg, color) {
|
|
const el = document.getElementById('flash');
|
|
el.textContent = msg; el.style.background = color; el.style.display = 'block';
|
|
setTimeout(() => el.style.display = 'none', 2500);
|
|
}
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
port = int(os.environ.get("PORT", 5000))
|
|
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
|
|
app.run(host="0.0.0.0", port=port, debug=debug)
|