""" TeslaCam Video Player This web app displays TeslaCam videos and allows users to change the camera view. It scans the TeslaCam folder for all videos and stores a list sorted by filename and categorized by camera view (format YYYY-MM-DD_HH-MM-SS-${camera_view}.mp4). The app then displays the videos in a web interface, allowing users to select and play videos from different camera views. """ import os import re import time import json import secrets from functools import wraps from dotenv import load_dotenv from flask import ( Flask, render_template, send_from_directory, abort, jsonify, request, session, redirect, url_for ) # Load variables from a .env file if present (does not override real env vars) load_dotenv() # Password for web login. Default: 'patreon'. Override via LOGIN_PASSWORD env var. LOGIN_PASSWORD = os.environ.get('LOGIN_PASSWORD', 'patreon') # Root TeslaCam directory containing RecentClips, SavedClips, SentryClips, etc. # Override via the TESLACAM_DIRECTORY environment variable (see .env.example). TESLACAM_DIRECTORY = os.environ.get('TESLACAM_DIRECTORY', '/mnt/TeslaCam') app = Flask(__name__) # Session secret key — MUST be set in production. Generate with: python -c "import secrets; print(secrets.token_hex(32))" app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32)) # Regular expression matching TeslaCam filenames: YYYY-MM-DD_HH-MM-SS-${camera_view}.mp4 VIDEO_FILENAME_REGEX = re.compile( r'^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(\w+)\.mp4$' ) # Friendly display names for camera views CAMERA_VIEW_MAPPING = { 'front': 'Front', 'left_repeater': 'Left Repeater', 'right_repeater': 'Right Repeater', 'back': 'Back', 'left_pillar': 'Left Pillar', 'right_pillar': 'Right Pillar' } # Camera button layout order: three columns around the car graphic CAMERA_LAYOUT = [ ['left_repeater', 'left_pillar'], ['front', 'back'], ['right_repeater', 'right_pillar'], ] # In-memory scan cache. Scanning a large TeslaCam tree on every request is # expensive, so the result is cached and only refreshed after this many seconds # (or when /refresh is hit). Override via the SCAN_CACHE_TTL env var. 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. 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(cam_dir): return videos_by_camera # ---- 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 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, cam_dir).replace('\\', '/') video_entry = { 'filename': filename, 'timestamp': normalized_ts, 'rel_path': 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: 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: videos_by_camera[camera_view].sort(key=lambda item: item['filename']) return videos_by_camera def scan_teslacam_videos(force_refresh=False): """ Returns the cached TeslaCam video scan, refreshing it when the TTL expires or when `force_refresh` is True. Safe to call on every request. """ now = time.time() if ( _scan_cache['data'] is None or now >= _scan_cache['expires_at'] or force_refresh ): _scan_cache['data'] = _scan_teslacam_videos_uncached() _scan_cache['expires_at'] = now + SCAN_CACHE_TTL return _scan_cache['data'] def get_camera_display_name(camera_view): """Returns human-readable title for a camera view key.""" return CAMERA_VIEW_MAPPING.get(camera_view, camera_view.replace('_', ' ').title()) def login_required(f): """Decorator: redirect to /login if not authenticated.""" @wraps(f) def decorated_function(*args, **kwargs): if not session.get('logged_in'): return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function @app.route('/login', methods=['GET', 'POST']) def login(): """Simple password-based login page.""" error = None if request.method == 'POST': if request.form.get('password') == LOGIN_PASSWORD: session['logged_in'] = True return redirect(url_for('index')) error = 'Invalid password. Please try again.' return render_template('login.html', error=error) @app.route('/logout') def logout(): """Clear session and return to login page.""" session.pop('logged_in', None) return redirect(url_for('login')) @app.route('/') @login_required def index(): """ Main page: video player with camera-angle switching via a 3-column button grid around a top-down Model Y graphic. """ all_videos = scan_teslacam_videos() # Build camera info list for the button grid camera_list = [] for col in CAMERA_LAYOUT: col_cams = [] for key in col: videos = all_videos.get(key, []) col_cams.append({ 'key': key, 'display': get_camera_display_name(key), 'count': len(videos), 'first_url': videos[0]['url'] if videos else None, }) camera_list.append(col_cams) # Default to the oldest front video front_videos = all_videos.get('front', []) default_url = front_videos[0]['url'] if front_videos else None return render_template( 'index.html', camera_list=camera_list, default_url=default_url, ) @app.route('/camera/') @login_required def camera_view(camera_view): """ Displays all available videos for a single camera view. """ all_videos = scan_teslacam_videos() videos = all_videos.get(camera_view, []) display_name = get_camera_display_name(camera_view) return render_template( 'camera_view.html', camera_view_key=camera_view, camera_view_name=display_name, videos=videos ) @app.route('/video/') @login_required def serve_video(filepath): """ Safely streams video files from the TeslaCam directory structure. """ 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(_normalize_path(TESLACAM_DIRECTORY), filepath) @app.route('/refresh') @login_required def refresh(): """ Forces a re-scan of the TeslaCam directory, bypassing the cache TTL. Useful after copying new clips onto the drive. """ scan_teslacam_videos(force_refresh=True) return 'Cache refreshed', 200 @app.route('/api/videos') @login_required def api_videos(): """ Returns all scanned videos as JSON, grouped by camera view. Used by the frontend for camera-angle switching. """ all_videos = scan_teslacam_videos() result = {} for cam_key, video_list in all_videos.items(): result[cam_key] = { 'display': get_camera_display_name(cam_key), 'videos': video_list } 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 = [ ('4-Back West', lambda lat, lon: lon <= -104), ('3-Midwest', lambda lat, lon: -104 < lon <= -80 and lat >= 37), ('1-Texas', lambda lat, lon: lat < 37 and lon > -104), ('2-East Coast', 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(): """ Health-check endpoint for load balancers / monitoring. Returns 200 with a JSON status when the app is running. """ return jsonify({'status': 'ok'}) @app.route('/static/') def serve_static(filename): """ Serves static assets (CSS, JS, images) from the static/ directory. """ static_dir = app.static_folder or os.path.join(app.root_path, 'static') return send_from_directory(static_dir, filename) if __name__ == '__main__': # Local dev server. For production, run behind a WSGI server instead, e.g.: # gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app' # or # waitress-serve --host=0.0.0.0 --port=5000 videoplayer:app host = os.environ.get('FLASK_RUN_HOST', '0.0.0.0') port = int(os.environ.get('FLASK_RUN_PORT', '5000')) debug = os.environ.get('FLASK_DEBUG', '0').lower() in ('1', 'true', 'yes') app.run(debug=debug, host=host, port=port)