{% set phidden = 'hidden' if not default_url else '' %}
+
Now Playing:
—
+
+
diff --git a/videoplayer.py b/videoplayer.py
index 0ce7eea..5e23798 100644
--- a/videoplayer.py
+++ b/videoplayer.py
@@ -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():
"""