Add region filter bar and navigation buttons for video clips
This commit is contained in:
1 parent
d12d5e5414
commit
1c7ce1dd0a
4 files changed
+410
-29
No files matched your search
+183
-21
@@ -12,6 +12,7 @@ play videos from different camera views.
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import secrets
|
||||
from functools import wraps
|
||||
|
||||
@@ -66,42 +67,134 @@ SCAN_CACHE_TTL = int(os.environ.get('SCAN_CACHE_TTL', '3600'))
|
||||
_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():
|
||||
"""
|
||||
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:
|
||||
dict: Keys are raw camera view identifiers (e.g. 'front'), values are lists
|
||||
of dictionaries containing file details sorted chronologically.
|
||||
"""
|
||||
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
|
||||
|
||||
# Recursive directory walk to catch nested folders in SavedClips/SentryClips
|
||||
for root, _, files in os.walk(TESLACAM_DIRECTORY):
|
||||
# ---- Pass 1: collect ALL event.json files first ----
|
||||
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:
|
||||
match = VIDEO_FILENAME_REGEX.match(filename)
|
||||
if match:
|
||||
timestamp, camera_view = match.groups()
|
||||
if not match:
|
||||
continue
|
||||
timestamp, camera_view = match.groups()
|
||||
normalized_ts = _get_video_timestamp_string(timestamp)
|
||||
|
||||
# Get path relative to TESLACAM_DIRECTORY for safe web serving
|
||||
full_path = os.path.join(root, filename)
|
||||
rel_path = os.path.relpath(
|
||||
full_path, TESLACAM_DIRECTORY).replace('\\', '/')
|
||||
# Get path relative to TESLACAM_DIRECTORY for safe web serving
|
||||
full_path = os.path.join(root, filename)
|
||||
rel_path = os.path.relpath(
|
||||
full_path, cam_dir).replace('\\', '/')
|
||||
|
||||
video_entry = {
|
||||
'filename': filename,
|
||||
'timestamp': timestamp,
|
||||
'rel_path': rel_path,
|
||||
'url': f'/video/{rel_path}'
|
||||
}
|
||||
video_entry = {
|
||||
'filename': filename,
|
||||
'timestamp': normalized_ts,
|
||||
'rel_path': rel_path,
|
||||
'url': f'/video/{rel_path}'
|
||||
}
|
||||
|
||||
if camera_view not in videos_by_camera:
|
||||
videos_by_camera[camera_view] = []
|
||||
videos_by_camera[camera_view].append(video_entry)
|
||||
# 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:
|
||||
videos_by_camera[camera_view] = []
|
||||
videos_by_camera[camera_view].append(video_entry)
|
||||
|
||||
# Sort each camera list chronologically by filename/timestamp
|
||||
for camera_view in videos_by_camera:
|
||||
@@ -218,11 +311,11 @@ def serve_video(filepath):
|
||||
"""
|
||||
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):
|
||||
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')
|
||||
@@ -253,6 +346,75 @@ def api_videos():
|
||||
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')
|
||||
def health():
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user