Add region filter bar and navigation buttons for video clips
This commit is contained in:
1 parent
d12d5e5414
commit
1c7ce1dd0a
4 files changed
+398
-17
No files matched your search
+87
-1
@@ -95,6 +95,50 @@ header {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Region filter bar */
|
||||||
|
.region-bar {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-btn {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Video Player Section */
|
/* Video Player Section */
|
||||||
.player-panel {
|
.player-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -182,7 +226,7 @@ header {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -194,6 +238,48 @@ header {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.now-playing .loc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.now-playing .loc::before {
|
||||||
|
content: "📍 ";
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav buttons (prev/next) */
|
||||||
|
.nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.nav-btn:active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================
|
/* =============================================
|
||||||
Camera Selector: 3-column grid + car graphic
|
Camera Selector: 3-column grid + car graphic
|
||||||
|
|||||||
+103
-7
@@ -10,6 +10,10 @@
|
|||||||
const nowPlaying = document.getElementById("nowPlaying");
|
const nowPlaying = document.getElementById("nowPlaying");
|
||||||
const npName = document.getElementById("npName");
|
const npName = document.getElementById("npName");
|
||||||
const npTime = document.getElementById("npTime");
|
const npTime = document.getElementById("npTime");
|
||||||
|
const npLocation = document.getElementById("npLocation");
|
||||||
|
const prevBtn = document.getElementById("prevBtn");
|
||||||
|
const nextBtn = document.getElementById("nextBtn");
|
||||||
|
const regionBar = document.getElementById("regionBar");
|
||||||
const csBtns = document.querySelectorAll(".cs-btn[data-cam]");
|
const csBtns = document.querySelectorAll(".cs-btn[data-cam]");
|
||||||
|
|
||||||
// ---- State ----
|
// ---- State ----
|
||||||
@@ -126,6 +130,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Prev/Next navigation ----
|
||||||
|
function findClipIndex(clip) {
|
||||||
|
const sameCam = (allVideos[clip.cam] && allVideos[clip.cam].videos) || [];
|
||||||
|
return sameCam.findIndex(function (c) {
|
||||||
|
return c.timestamp === clip.timestamp && c.filename === clip.filename;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevClip() {
|
||||||
|
if (!currentClip) return;
|
||||||
|
const sameCam =
|
||||||
|
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
|
||||||
|
const idx = findClipIndex(currentClip);
|
||||||
|
if (idx > 0) playClip(sameCam[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextClip() {
|
||||||
|
if (!currentClip) return;
|
||||||
|
const sameCam =
|
||||||
|
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
|
||||||
|
const idx = findClipIndex(currentClip);
|
||||||
|
if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevBtn.addEventListener("click", prevClip);
|
||||||
|
nextBtn.addEventListener("click", nextClip);
|
||||||
|
|
||||||
// ---- Playback ----
|
// ---- Playback ----
|
||||||
function playClip(clip) {
|
function playClip(clip) {
|
||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
@@ -160,6 +191,7 @@
|
|||||||
nowPlaying.hidden = false;
|
nowPlaying.hidden = false;
|
||||||
npName.textContent = clip.filename;
|
npName.textContent = clip.filename;
|
||||||
npTime.textContent = prettyTs(clip.timestamp);
|
npTime.textContent = prettyTs(clip.timestamp);
|
||||||
|
npLocation.textContent = clip.location || "";
|
||||||
|
|
||||||
updateCamButtons(clip.cam);
|
updateCamButtons(clip.cam);
|
||||||
}
|
}
|
||||||
@@ -180,6 +212,7 @@
|
|||||||
|
|
||||||
badgeText.textContent = target.camDisplay;
|
badgeText.textContent = target.camDisplay;
|
||||||
npName.textContent = target.filename;
|
npName.textContent = target.filename;
|
||||||
|
npLocation.textContent = target.location || "";
|
||||||
|
|
||||||
player.addEventListener("loadedmetadata", function onMeta() {
|
player.addEventListener("loadedmetadata", function onMeta() {
|
||||||
player.removeEventListener("loadedmetadata", onMeta);
|
player.removeEventListener("loadedmetadata", onMeta);
|
||||||
@@ -206,12 +239,7 @@
|
|||||||
if (!currentClip) return;
|
if (!currentClip) return;
|
||||||
const sameCam =
|
const sameCam =
|
||||||
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
|
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
|
||||||
const idx = sameCam.findIndex(function (c) {
|
const idx = findClipIndex(currentClip);
|
||||||
return (
|
|
||||||
c.timestamp === currentClip.timestamp &&
|
|
||||||
c.filename === currentClip.filename
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]);
|
if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,7 +262,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Keyboard shortcuts: 1-6 for camera angles ----
|
// ---- Keyboard shortcuts: 1-6 for camera angles, ArrowLeft/Right for prev/next ----
|
||||||
const camOrder = [
|
const camOrder = [
|
||||||
"front",
|
"front",
|
||||||
"left_repeater",
|
"left_repeater",
|
||||||
@@ -258,10 +286,78 @@
|
|||||||
if (videos.length > 0) playClip(videos[0]);
|
if (videos.length > 0) playClip(videos[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
prevClip();
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
nextClip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Region filter ----
|
||||||
|
let regionData = {};
|
||||||
|
|
||||||
|
function fetchRegions() {
|
||||||
|
fetch("/api/regions")
|
||||||
|
.then(function (r) {
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
regionData = data.regions || {};
|
||||||
|
buildRegionButtons();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.error("Failed to load regions:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegionButtons() {
|
||||||
|
// Keep "All" button, remove any existing region buttons
|
||||||
|
var existing = regionBar.querySelectorAll(
|
||||||
|
".region-btn:not([data-region=''])",
|
||||||
|
);
|
||||||
|
existing.forEach(function (b) {
|
||||||
|
b.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(regionData).forEach(function (name) {
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.className = "region-btn";
|
||||||
|
btn.setAttribute("data-region", name);
|
||||||
|
btn.textContent = name + " (" + regionData[name].count + ")";
|
||||||
|
regionBar.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
regionBar.addEventListener("click", function (e) {
|
||||||
|
var btn = e.target.closest(".region-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
var region = btn.getAttribute("data-region");
|
||||||
|
|
||||||
|
// Update active state
|
||||||
|
regionBar.querySelectorAll(".region-btn").forEach(function (b) {
|
||||||
|
b.classList.toggle("active", b === btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!region) return; // "All" selected — no jump
|
||||||
|
var info = regionData[region];
|
||||||
|
if (!info || !info.first_timestamp) return;
|
||||||
|
|
||||||
|
// Jump to first front-camera clip for this region
|
||||||
|
var frontVideos = (allVideos["front"] && allVideos["front"].videos) || [];
|
||||||
|
var clip = frontVideos.find(function (c) {
|
||||||
|
return c.timestamp === info.first_timestamp;
|
||||||
|
});
|
||||||
|
if (clip) playClip(clip);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Init: fetch full video data, then auto-play front ----
|
// ---- Init: fetch full video data, then auto-play front ----
|
||||||
function init() {
|
function init() {
|
||||||
|
// Fetch regions data for the filter bar
|
||||||
|
fetchRegions();
|
||||||
|
|
||||||
fetch("/api/videos")
|
fetch("/api/videos")
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
return r.json();
|
return r.json();
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
|
|
||||||
<!-- Main Layout: single column, centered -->
|
<!-- Main Layout: single column, centered -->
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
<!-- Region filter bar -->
|
||||||
|
<nav class="region-bar" id="regionBar">
|
||||||
|
<span class="region-label">Region:</span>
|
||||||
|
<button class="region-btn active" data-region="">All</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Video Player -->
|
<!-- Video Player -->
|
||||||
<section class="player-panel">
|
<section class="player-panel">
|
||||||
<div class="video-stage">
|
<div class="video-stage">
|
||||||
@@ -60,6 +66,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{% set phidden = 'hidden' if not default_url else '' %}
|
{% set phidden = 'hidden' if not default_url else '' %}
|
||||||
<div class="now-playing" id="nowPlaying" {{phidden}}>
|
<div class="now-playing" id="nowPlaying" {{phidden}}>
|
||||||
|
<button
|
||||||
|
class="nav-btn"
|
||||||
|
id="prevBtn"
|
||||||
|
title="Previous clip (←)"
|
||||||
|
aria-label="Previous clip"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span
|
<span
|
||||||
>Now Playing:
|
>Now Playing:
|
||||||
<strong id="npName"
|
<strong id="npName"
|
||||||
@@ -68,6 +89,22 @@
|
|||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<span class="ts" id="npTime">—</span>
|
<span class="ts" id="npTime">—</span>
|
||||||
|
<span class="loc" id="npLocation"></span>
|
||||||
|
<button
|
||||||
|
class="nav-btn"
|
||||||
|
id="nextBtn"
|
||||||
|
title="Next clip (→)"
|
||||||
|
aria-label="Next clip"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+171
-9
@@ -12,6 +12,7 @@ play videos from different camera views.
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
@@ -66,39 +67,131 @@ SCAN_CACHE_TTL = int(os.environ.get('SCAN_CACHE_TTL', '3600'))
|
|||||||
_scan_cache = {'data': None, 'expires_at': 0.0}
|
_scan_cache = {'data': None, 'expires_at': 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(path):
|
||||||
|
"""Normalize a filesystem path to use forward slashes and no trailing slash."""
|
||||||
|
return os.path.normpath(path).replace('\\', '/').rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_json(event_path):
|
||||||
|
"""
|
||||||
|
Parse an event.json file from a SavedClips subfolder.
|
||||||
|
Returns a dict mapping normalized timestamps (YYYY-MM-DD_HH-MM-SS) to
|
||||||
|
geodata dicts with lat, lon, and location fields.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
with open(event_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Handle both a single event object and a list of events
|
||||||
|
events = data if isinstance(data, list) else [data]
|
||||||
|
for evt in events:
|
||||||
|
ts_raw = evt.get('timestamp', '')
|
||||||
|
# Normalize ISO timestamp
|
||||||
|
ts_normalized = _normalize_timestamp(ts_raw)
|
||||||
|
lat = evt.get('est_lat', '')
|
||||||
|
lon = evt.get('est_lon', '')
|
||||||
|
city = evt.get('city', '')
|
||||||
|
street = evt.get('street', '')
|
||||||
|
parts = [p for p in [street, city] if p]
|
||||||
|
location = ', '.join(parts) if parts else ''
|
||||||
|
result[ts_normalized] = {
|
||||||
|
'lat': lat,
|
||||||
|
'lon': lon,
|
||||||
|
'location': location
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_timestamp(ts_string):
|
||||||
|
"""
|
||||||
|
Normalizes a timestamp string to the format YYYYMMDD_HHMM.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ts = ts_string.replace('T', '_')
|
||||||
|
ts = ts.replace('-', '')
|
||||||
|
ts = ts.replace(':', '')
|
||||||
|
ts = ts.split('.')[0]
|
||||||
|
# remove last 2 digits
|
||||||
|
ts = ts[:-2]
|
||||||
|
# set last 2 digits to '00' to represent the minute
|
||||||
|
ts = ts[:-2] + '00'
|
||||||
|
return ts
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_video_timestamp_string(ts_string):
|
||||||
|
"""
|
||||||
|
normalizes a timestamp string to the format YYYYMMDD_HHMM, or returns None if invalid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ts = ts_string.replace('T', '_')
|
||||||
|
ts = ts.replace('-', '')
|
||||||
|
ts = ts.replace(':', '')
|
||||||
|
ts = ts.split('.')[0]
|
||||||
|
# remove last 2 digits
|
||||||
|
ts = ts[:-2]
|
||||||
|
# set last 2 digits to '00' to represent the minute
|
||||||
|
ts = ts[:-2] + '00'
|
||||||
|
return ts
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _scan_teslacam_videos_uncached():
|
def _scan_teslacam_videos_uncached():
|
||||||
"""
|
"""
|
||||||
Recursively scans the TeslaCam directory tree for video files and categorizes
|
Recursively scans the TeslaCam directory tree for video files and categorizes
|
||||||
them by camera view.
|
them by camera view. Also parses event.json files in SavedClips subfolders
|
||||||
|
to attach geodata (lat, lon, location) to matching video entries.
|
||||||
|
|
||||||
|
Two-pass approach: first collect ALL event.json geodata across the entire
|
||||||
|
tree, then process videos and attach matching geodata.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Keys are raw camera view identifiers (e.g. 'front'), values are lists
|
dict: Keys are raw camera view identifiers (e.g. 'front'), values are lists
|
||||||
of dictionaries containing file details sorted chronologically.
|
of dictionaries containing file details sorted chronologically.
|
||||||
"""
|
"""
|
||||||
videos_by_camera = {}
|
videos_by_camera = {}
|
||||||
|
cam_dir = _normalize_path(TESLACAM_DIRECTORY)
|
||||||
|
|
||||||
if not os.path.exists(TESLACAM_DIRECTORY):
|
if not os.path.exists(cam_dir):
|
||||||
return videos_by_camera
|
return videos_by_camera
|
||||||
|
|
||||||
# Recursive directory walk to catch nested folders in SavedClips/SentryClips
|
# ---- Pass 1: collect ALL event.json files first ----
|
||||||
for root, _, files in os.walk(TESLACAM_DIRECTORY):
|
geodata_by_timestamp = {} # normalized_ts -> {lat, lon, location}
|
||||||
|
for root, _, files in os.walk(cam_dir):
|
||||||
|
if 'event.json' in files:
|
||||||
|
event_path = _normalize_path(os.path.join(root, 'event.json'))
|
||||||
|
geodata_by_timestamp.update(_parse_event_json(event_path))
|
||||||
|
|
||||||
|
# ---- Pass 2: process video files, attach geodata from fully populated dict ----
|
||||||
|
for root, _, files in os.walk(cam_dir):
|
||||||
for filename in files:
|
for filename in files:
|
||||||
match = VIDEO_FILENAME_REGEX.match(filename)
|
match = VIDEO_FILENAME_REGEX.match(filename)
|
||||||
if match:
|
if not match:
|
||||||
|
continue
|
||||||
timestamp, camera_view = match.groups()
|
timestamp, camera_view = match.groups()
|
||||||
|
normalized_ts = _get_video_timestamp_string(timestamp)
|
||||||
|
|
||||||
# Get path relative to TESLACAM_DIRECTORY for safe web serving
|
# Get path relative to TESLACAM_DIRECTORY for safe web serving
|
||||||
full_path = os.path.join(root, filename)
|
full_path = os.path.join(root, filename)
|
||||||
rel_path = os.path.relpath(
|
rel_path = os.path.relpath(
|
||||||
full_path, TESLACAM_DIRECTORY).replace('\\', '/')
|
full_path, cam_dir).replace('\\', '/')
|
||||||
|
|
||||||
video_entry = {
|
video_entry = {
|
||||||
'filename': filename,
|
'filename': filename,
|
||||||
'timestamp': timestamp,
|
'timestamp': normalized_ts,
|
||||||
'rel_path': rel_path,
|
'rel_path': rel_path,
|
||||||
'url': f'/video/{rel_path}'
|
'url': f'/video/{rel_path}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Attach geodata if available for this timestamp
|
||||||
|
if normalized_ts in geodata_by_timestamp:
|
||||||
|
video_entry.update(geodata_by_timestamp[normalized_ts])
|
||||||
|
|
||||||
if camera_view not in videos_by_camera:
|
if camera_view not in videos_by_camera:
|
||||||
videos_by_camera[camera_view] = []
|
videos_by_camera[camera_view] = []
|
||||||
videos_by_camera[camera_view].append(video_entry)
|
videos_by_camera[camera_view].append(video_entry)
|
||||||
@@ -218,11 +311,11 @@ def serve_video(filepath):
|
|||||||
"""
|
"""
|
||||||
Safely streams video files from the TeslaCam directory structure.
|
Safely streams video files from the TeslaCam directory structure.
|
||||||
"""
|
"""
|
||||||
full_path = os.path.join(TESLACAM_DIRECTORY, filepath)
|
full_path = os.path.join(_normalize_path(TESLACAM_DIRECTORY), filepath)
|
||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
abort(404, description="Video file not found")
|
abort(404, description="Video file not found")
|
||||||
|
|
||||||
return send_from_directory(TESLACAM_DIRECTORY, filepath)
|
return send_from_directory(_normalize_path(TESLACAM_DIRECTORY), filepath)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/refresh')
|
@app.route('/refresh')
|
||||||
@@ -253,6 +346,75 @@ def api_videos():
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# US region boundaries for geodata classification
|
||||||
|
# West: lon <= -104 (Pacific + Mountain)
|
||||||
|
# Midwest: -104 < lon <= -80 AND lat >= 37
|
||||||
|
# South: lat < 37 AND lon > -104
|
||||||
|
# East: lon > -80
|
||||||
|
US_REGION_BOUNDS = [
|
||||||
|
('West', lambda lat, lon: lon <= -104),
|
||||||
|
('Midwest', lambda lat, lon: -104 < lon <= -80 and lat >= 37),
|
||||||
|
('South', lambda lat, lon: lat < 37 and lon > -104),
|
||||||
|
('East', lambda lat, lon: lon > -80),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_region(lat, lon):
|
||||||
|
"""Classify a lat/lon pair into a US region name, or None if unclassified."""
|
||||||
|
try:
|
||||||
|
lat_f = float(lat)
|
||||||
|
lon_f = float(lon)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
for name, check in US_REGION_BOUNDS:
|
||||||
|
if check(lat_f, lon_f):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/regions')
|
||||||
|
@login_required
|
||||||
|
def api_regions():
|
||||||
|
"""
|
||||||
|
Returns available US regions with clip counts and a representative
|
||||||
|
front-camera timestamp for jumping to region.
|
||||||
|
"""
|
||||||
|
all_videos = scan_teslacam_videos()
|
||||||
|
regions = {}
|
||||||
|
|
||||||
|
for cam_key, video_list in all_videos.items():
|
||||||
|
for v in video_list:
|
||||||
|
lat = v.get('lat', '')
|
||||||
|
lon = v.get('lon', '')
|
||||||
|
if not lat or not lon:
|
||||||
|
continue
|
||||||
|
region = _classify_region(lat, lon)
|
||||||
|
if not region:
|
||||||
|
continue
|
||||||
|
ts = v['timestamp']
|
||||||
|
if region not in regions:
|
||||||
|
regions[region] = {'count': 0, 'timestamps': set()}
|
||||||
|
regions[region]['count'] += 1
|
||||||
|
regions[region]['timestamps'].add(ts)
|
||||||
|
|
||||||
|
# Convert sets to sorted lists, find earliest front-cam timestamp per region
|
||||||
|
front_videos = all_videos.get('front', [])
|
||||||
|
front_ts_set = {v['timestamp'] for v in front_videos}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for name, data in sorted(regions.items()):
|
||||||
|
ts_list = sorted(data['timestamps'])
|
||||||
|
# First timestamp that has a front camera clip
|
||||||
|
first_ts = next((ts for ts in ts_list if ts in front_ts_set),
|
||||||
|
ts_list[0] if ts_list else None)
|
||||||
|
result[name] = {
|
||||||
|
'count': data['count'],
|
||||||
|
'first_timestamp': first_ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({'regions': result})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health():
|
def health():
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in new issue
Block a user