From 979b29765275b46f4127304ecbc1d9351b4356d8 Mon Sep 17 00:00:00 2001 From: inauner Date: Wed, 26 Aug 2026 13:00:10 -0700 Subject: [PATCH 01/24] galaxy: fix Toyota and Lexus door lock and unlock vehicle check and execution --- starpilot/system/the_galaxy/the_galaxy.py | 63 ++++++++++++++--------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 5891ada7c..29303d685 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -4682,48 +4682,63 @@ def setup(app): try: with car.CarParams.from_bytes(params.get("CarParamsPersistent")) as cp: if tool == "doors": - return jsonify({"result": HARDWARE.get_device_type() != "tici" and cp.carName == "toyota"}) + car_brand = getattr(cp, "brand", getattr(cp, "carName", "")) + return jsonify({"result": car_brand == "toyota"}) elif tool == "tsk": - return jsonify({"result": cp.secOcRequired}) + return jsonify({"result": getattr(cp, "secOcRequired", False)}) except Exception: pass return jsonify({"result": False}) @app.route("/api/doors/lock", methods=["POST"]) def lock_doors(): - can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0) - can_sock = messaging.sub_sock("can", timeout=100) + try: + can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0) + can_sock = messaging.sub_sock("can", timeout=100) - while True: - with Panda(disable_checks=True) as panda: - if not params.get_bool("IsOnroad"): - panda.set_safety_mode(panda.SAFETY_TOYOTA) - panda.can_send(0x750, LOCK_CMD, 0) + for _ in range(6): + try: + with Panda(disable_checks=True) as panda: + if not params.get_bool("IsOnroad"): + panda.set_safety_mode(car.CarParams.SafetyModel.toyota) + panda.can_send(0x750, LOCK_CMD, 0) + panda.can_send(0x750, LOCK_CMD, 1) + except Exception: + pass - time.sleep(1) + time.sleep(1) - lock_status = get_lock_status(can_parser, can_sock) - if lock_status == 0: - break + lock_status = get_lock_status(can_parser, can_sock) + if lock_status == 0: + break + except Exception as e: + return {"message": f"Lock failed: {e}"}, 500 return {"message": "Doors locked!"} @app.route("/api/doors/unlock", methods=["POST"]) def unlock_doors(): - can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0) - can_sock = messaging.sub_sock("can", timeout=100) + try: + can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0) + can_sock = messaging.sub_sock("can", timeout=100) - while True: - with Panda(disable_checks=True) as panda: - if not params.get_bool("IsOnroad"): - panda.set_safety_mode(panda.SAFETY_TOYOTA) - panda.can_send(0x750, UNLOCK_CMD, 0) + for _ in range(6): + try: + with Panda(disable_checks=True) as panda: + if not params.get_bool("IsOnroad"): + panda.set_safety_mode(car.CarParams.SafetyModel.toyota) + panda.can_send(0x750, UNLOCK_CMD, 0) + panda.can_send(0x750, UNLOCK_CMD, 1) + except Exception: + pass - time.sleep(1) + time.sleep(1) - lock_status = get_lock_status(can_parser, can_sock) - if lock_status != 0: - break + lock_status = get_lock_status(can_parser, can_sock) + if lock_status != 0: + break + except Exception as e: + return {"message": f"Unlock failed: {e}"}, 500 return {"message": "Doors unlocked!"} From 47ccab65d90cd93049733898c0d78c85dadf2ee8 Mon Sep 17 00:00:00 2001 From: inauner Date: Wed, 26 Aug 2026 13:52:27 -0700 Subject: [PATCH 02/24] hardware: add 30s sustained low voltage debounce and sentry power off notifications --- system/hardware/hardwared.py | 47 +++++++++++++++++++++++++++++ system/hardware/power_monitoring.py | 26 +++++++++++----- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index ec7c52a59..9fb9be0f6 100644 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -82,6 +82,32 @@ def notify_sentry_power_off(reason: str, power_monitor: PowerMonitoring) -> bool return False +def notify_sentry_low_voltage(power_monitor: PowerMonitoring) -> bool: + port = os.environ.get("SP_GALAXY_PORT", "8083" if PC else "8082") + v = round(power_monitor.car_voltage_mV / 1000, 2) + event = { + "eventId": f"low-voltage-{time.time_ns()}", + "kind": "warning", + "detectedAt": datetime.now(timezone.utc).isoformat(), + "reason": "low_voltage", + "message": f"Low vehicle battery warning: {v:.2f}V (at or below 11.8V).", + "voltage": v, + "instantVoltage": round(power_monitor.car_voltage_instant_mV / 1000, 2), + "batteryCapacityUwh": power_monitor.get_car_battery_capacity(), + } + try: + response = requests.post( + f"http://127.0.0.1:{port}/api/sentry/events", + json=event, + timeout=4, + ) + response.raise_for_status() + return True + except requests.RequestException as error: + cloudlog.warning(f"Sentry low-voltage notification unavailable: {error}") + return False + + class Chestnut: """Keep the ASM2464PD dock on the firmware expected by the GPU runtime.""" MAX_ATTEMPTS = 3 @@ -305,6 +331,8 @@ def hardware_thread(end_event, hw_queue) -> None: pwrsave = False offroad_cycle_count = 0 sentry_power_off_notified = False + sentry_low_voltage_notified = False + last_low_voltage_notify_ts = 0.0 params = Params() power_monitor = PowerMonitoring() @@ -523,6 +551,10 @@ def hardware_thread(end_event, hw_queue) -> None: statlog.sample("som_power_draw", som_power_draw) msg.deviceState.somPowerDrawW = som_power_draw + if not onroad_conditions["ignition"] and (count % int(30. / DT_HW) == 0): + low_v_str = f" [LOW VOLTAGE SUSTAINED: {time.monotonic() - power_monitor.low_voltage_start_time:.1f}s / 30.0s]" if power_monitor.low_voltage_start_time else "" + print(f"[hardwared] Offroad Power: {power_monitor.car_voltage_mV / 1000.0:.2f}V (instant: {power_monitor.car_voltage_instant_mV / 1000.0:.2f}V), draw: {current_power_draw:.1f}W{low_v_str}", flush=True) + # Check if we need to shut down shutdown_reason = power_monitor.shutdown_reason( onroad_conditions["ignition"], in_car, off_ts, started_seen, starpilot_toggles, @@ -536,6 +568,21 @@ def hardware_thread(end_event, hw_queue) -> None: else: sentry_power_off_notified = False + # Low voltage warning notification (without device shutdown) + if in_car and not onroad_conditions["ignition"] and off_ts is not None: + voltage_v = power_monitor.car_voltage_mV / 1000.0 + if voltage_v <= 11.8: + now_mono = time.monotonic() + if not sentry_low_voltage_notified or (now_mono - last_low_voltage_notify_ts > 1800): + sentry_low_voltage_notified = True + last_low_voltage_notify_ts = now_mono + if params.get_bool("SentryModeEnabled"): + notify_sentry_low_voltage(power_monitor) + elif voltage_v > 12.2: + sentry_low_voltage_notified = False + else: + sentry_low_voltage_notified = False + msg.deviceState.started = started_ts is not None msg.deviceState.startedMonoTime = int(1e9*(started_ts or 0)) diff --git a/system/hardware/power_monitoring.py b/system/hardware/power_monitoring.py index baf22fbd0..0e3224c88 100644 --- a/system/hardware/power_monitoring.py +++ b/system/hardware/power_monitoring.py @@ -19,6 +19,7 @@ MAX_TIME_OFFROAD_S = 30*3600 MIN_ON_TIME_S = 3600 DELAY_SHUTDOWN_TIME_S = 300 # Wait at least DELAY_SHUTDOWN_TIME_S seconds after offroad_time to shutdown. VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S = 60 +VOLTAGE_SHUTDOWN_SUSTAINED_TIME_S = 30.0 class PowerMonitoring: def __init__(self): @@ -29,12 +30,13 @@ class PowerMonitoring: self.next_pulsed_measurement_time = None self.car_voltage_mV = 12e3 # Low-passed version of peripheralState voltage self.car_voltage_instant_mV = 12e3 # Last value of peripheralState voltage + self.low_voltage_start_time = None # Monotonic timestamp when low voltage was first observed self.integration_lock = threading.Lock() - car_battery_capacity_uWh = self.params.get("CarBatteryCapacity") or 0 + car_battery_capacity_uWh = self.params.get("CarBatteryCapacity") or CAR_BATTERY_CAPACITY_uWh # Reset capacity if it's low - self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 10), car_battery_capacity_uWh) + self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 2), car_battery_capacity_uWh) # Calculation tick def calculate(self, voltage: int | None, ignition: bool): @@ -110,19 +112,29 @@ class PowerMonitoring: def shutdown_reason(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool, starpilot_toggles: SimpleNamespace) -> str | None: if offroad_timestamp is None: + self.low_voltage_start_time = None return None now = time.monotonic() offroad_time = (now - offroad_timestamp) - low_voltage_shutdown = (self.car_voltage_mV < (starpilot_toggles.low_voltage_shutdown * 1e3) and - offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S) + + cutoff_voltage = starpilot_toggles.low_voltage_shutdown if getattr(starpilot_toggles, "low_voltage_shutdown", 0) > 0 else 11.8 + is_below_voltage = self.car_voltage_mV < (cutoff_voltage * 1e3) + + if is_below_voltage and offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S: + if self.low_voltage_start_time is None: + self.low_voltage_start_time = now + low_voltage_sustained_time = now - self.low_voltage_start_time + low_voltage_shutdown = low_voltage_sustained_time >= VOLTAGE_SHUTDOWN_SUSTAINED_TIME_S + else: + self.low_voltage_start_time = None + low_voltage_shutdown = False + reason = None - if offroad_time > starpilot_toggles.device_shutdown_time: + if starpilot_toggles.device_shutdown_time > 0 and offroad_time > starpilot_toggles.device_shutdown_time: reason = "offroad_timeout" elif low_voltage_shutdown: reason = "low_voltage" - elif self.car_battery_capacity_uWh <= 0: - reason = "battery_capacity_exhausted" should_shutdown = reason is not None should_shutdown &= not ignition From 00fc6d81589ecc8262aca205fc98dd0319ca7854 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:38:51 -0700 Subject: [PATCH 03/24] Add ability to download route logs --- .../components/recordings/dashcam_routes.css | 48 ++++++++ .../components/recordings/dashcam_routes.js | 46 ++++++++ .../the_galaxy/tests/test_route_logs.py | 111 ++++++++++++++++++ starpilot/system/the_galaxy/the_galaxy.py | 96 +++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 starpilot/system/the_galaxy/tests/test_route_logs.py diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 8c68365e3..65647709e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -244,6 +244,47 @@ transform: var(--hover-scale-sm); } +.route-logs { + margin-top: var(--padding-base); + max-height: 40vh; + overflow-y: auto; + text-align: left; +} + +.route-logs-message { + margin: 0; + padding: var(--padding-sm) 0; + font-size: var(--font-size-sm); +} + +.route-logs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--padding-sm); + padding-bottom: var(--padding-sm); + font-size: var(--font-size-sm); +} + +.route-logs-list { + list-style: none; + margin: 0; + padding: 0; +} + +.route-logs-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--padding-sm); + padding: var(--padding-sm) 0; + font-size: var(--font-size-sm); +} + +.route-logs a { + text-decoration: underline; +} + @media only screen and (max-width: 768px) and (orientation: portrait) { .media-player-content { width: 90%; @@ -266,4 +307,11 @@ text-align: center; width: 100%; } + + .route-logs-header, + .route-logs-list li { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index e0ccefd2e..29fc852a1 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -298,8 +298,10 @@ async function openOverlay(route) { + + `; document.body.appendChild(overlay); @@ -312,6 +314,50 @@ async function openOverlay(route) { const vid = overlay.querySelector("video"); const downloadButton = overlay.querySelector(".action-download"); + const logsButton = overlay.querySelector(".action-logs"); + const logsPanel = overlay.querySelector(".route-logs"); + + const formatBytes = bytes => { + if (!bytes) return "0 MB"; + const mb = bytes / 1e6; + return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`; + }; + + let logsLoaded = false; + logsButton.onclick = async () => { + if (logsLoaded) { + logsPanel.hidden = !logsPanel.hidden; + return; + } + + logsPanel.hidden = false; + logsPanel.innerHTML = `

Looking for full logs...

`; + try { + const response = await fetch(`/api/routes/${route.name}/logs`); + const data = await response.json(); + if (!response.ok) { + logsPanel.innerHTML = `

${data.error || "Could not read logs."}

`; + return; + } + + // sizes are shown up front so a metered connection is a deliberate choice + logsPanel.innerHTML = ` +
+ ${data.segments.length} segment${data.segments.length === 1 ? "" : "s"} · ${formatBytes(data.totalBytes)} total + Download all (.tar) +
+ `; + logsLoaded = true; + } catch (error) { + logsPanel.innerHTML = `

Could not reach the device: ${error.message}

`; + } + }; let segments; let current = 0; diff --git a/starpilot/system/the_galaxy/tests/test_route_logs.py b/starpilot/system/the_galaxy/tests/test_route_logs.py new file mode 100644 index 000000000..ce8439d1a --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_route_logs.py @@ -0,0 +1,111 @@ +import io +import tarfile + +from test_dashboard_stats import MODULE_DIR, _install_server_import_stubs + + +def _load_server_module(): + import importlib.util + import sys + + _install_server_import_stubs() + spec = importlib.util.spec_from_file_location("route_logs_server", MODULE_DIR / "the_galaxy.py") + module = importlib.util.module_from_spec(spec) + sys.modules["route_logs_server"] = module + spec.loader.exec_module(module) + return module + + +the_galaxy = _load_server_module() + + +def _make_route(root, name, segments, filename="rlog.zst", size=32): + for segment_num in segments: + segment_dir = root / f"{name}--{segment_num}" + segment_dir.mkdir(parents=True) + (segment_dir / filename).write_bytes(bytes([segment_num]) * size) + # a sibling that must never be offered as a full log + (segment_dir / "qlog.zst").write_bytes(b"q") + + +def _use_footage_root(monkeypatch, root): + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"]) + + +def test_route_log_files_are_ordered_numerically(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1, 2, 10]) + _use_footage_root(monkeypatch, tmp_path) + + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + # 10 must sort after 2, not lexically between 1 and 2 + assert [segment for segment, _, _, _ in logs] == [ + "0000006a--9f0a7bdf9c--0", + "0000006a--9f0a7bdf9c--1", + "0000006a--9f0a7bdf9c--2", + "0000006a--9f0a7bdf9c--10", + ] + assert {filename for _, filename, _, _ in logs} == {"rlog.zst"} + assert [size for _, _, _, size in logs] == [32, 32, 32, 32] + + +def test_route_log_files_prefers_newest_available_format(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0], filename="rlog.bz2") + (tmp_path / "0000006a--9f0a7bdf9c--0" / "rlog.zst").write_bytes(b"zstd") + _use_footage_root(monkeypatch, tmp_path) + + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + assert [filename for _, filename, _, _ in logs] == ["rlog.zst"] + + +def test_route_log_files_rejects_names_that_are_not_routes(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0]) + _use_footage_root(monkeypatch, tmp_path) + + for name in ("", None, "..", "../..", "0000006a--9f0a7bdf9c--0", "0000006a--9f0a7bdf9cx", "/etc"): + assert the_galaxy._route_log_files(name) == [], name + + +def test_route_log_files_skips_segments_without_logs(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1]) + (tmp_path / "0000006a--9f0a7bdf9c--1" / "rlog.zst").unlink() + _use_footage_root(monkeypatch, tmp_path) + + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + assert [segment for segment, _, _, _ in logs] == ["0000006a--9f0a7bdf9c--0"] + + +def test_tar_buffer_hands_back_each_write_once(): + buffer = the_galaxy._TarBuffer() + + buffer.write(b"one") + buffer.write(b"two") + + assert buffer.pop() == b"onetwo" + assert buffer.pop() == b"" + + +def test_streamed_archive_is_a_readable_tar(monkeypatch, tmp_path): + _make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1], size=4096) + _use_footage_root(monkeypatch, tmp_path) + logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c") + + buffer = the_galaxy._TarBuffer() + chunks = [] + with tarfile.open(fileobj=buffer, mode="w|") as archive: + for segment, filename, path, _ in logs: + archive.add(path, arcname=f"{segment}/{filename}") + chunks.append(buffer.pop()) + chunks.append(buffer.pop()) + + # more than one chunk means a long route never has to be buffered whole + assert sum(1 for chunk in chunks if chunk) > 1 + + with tarfile.open(fileobj=io.BytesIO(b"".join(chunks)), mode="r:") as archive: + assert archive.getnames() == [ + "0000006a--9f0a7bdf9c--0/rlog.zst", + "0000006a--9f0a7bdf9c--1/rlog.zst", + ] + assert archive.extractfile("0000006a--9f0a7bdf9c--1/rlog.zst").read() == bytes([1]) * 4096 diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 5891ada7c..556efbaec 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -10,6 +10,7 @@ import sys import sysconfig import tarfile +import io from io import BytesIO from pathlib import Path @@ -1116,6 +1117,24 @@ def _get_toggle_backup_keys(): return keys +def _route_log_files(name): + """Full logs for a route as [(segment, filename, path, size)], oldest segment first.""" + if not utilities.ROUTE_RE.match(name or ""): + return [] + + for footage_path in FOOTAGE_PATHS: + logs = [] + for segment in sorted(utilities.get_segments_in_route(name, footage_path), key=lambda s: int(s.rsplit("--", 1)[1])): + for filename in ROUTE_LOG_CANDIDATES: + path = os.path.join(footage_path, segment, filename) + if os.path.isfile(path): + logs.append((segment, filename, path, os.path.getsize(path))) + break + if logs: + return logs + return [] + + def _coerce_toggle_restore_value(key, value): value_type = _get_param_key_type(_params_raw, key) @@ -1192,6 +1211,29 @@ except TypeError: str(Paths.log_root()), ] +# Full drive logs, newest format first. comma only accepts qlog/qcamera uploads, so these come off the device directly. +ROUTE_LOG_CANDIDATES = ("rlog.zst", "rlog.bz2", "rlog") + + +class _TarBuffer(io.RawIOBase): + """Collects tarfile output so a route archive can be streamed out instead of built on disk.""" + + def __init__(self): + self._chunks = [] + + def writable(self): + return True + + def write(self, data): + self._chunks.append(bytes(data)) + return len(data) + + def pop(self): + data = b"".join(self._chunks) + self._chunks.clear() + return data + + KEYS = { "amap1": ("amap1", "", "AMapKey1", "AMap / Gaode key #1", 39), "amap2": ("amap2", "", "AMapKey2", "AMap / Gaode key #2", 39), @@ -6258,6 +6300,60 @@ def setup(app): }, 200 return {"error": "Route not found"}, 404 + @app.route("/api/routes//logs", methods=["GET"]) + def list_route_logs(name): + logs = _route_log_files(name) + if not logs: + return jsonify({"error": "No full logs are stored on the device for this route."}), 404 + + return jsonify({ + "name": name, + "totalBytes": sum(size for *_, size in logs), + "segments": [ + { + "segment": segment, + "segmentNum": int(segment.rsplit("--", 1)[1]), + "filename": filename, + "bytes": size, + "url": f"/api/routes/{name}/logs/{int(segment.rsplit('--', 1)[1])}", + } + for segment, filename, _, size in logs + ], + }), 200 + + @app.route("/api/routes//logs/", methods=["GET"]) + def download_route_log(name, segment_num): + for segment, filename, path, _ in _route_log_files(name): + if int(segment.rsplit("--", 1)[1]) == segment_num: + return send_file(path, as_attachment=True, download_name=f"{segment}-{filename}") + return jsonify({"error": "No full log is stored on the device for this segment."}), 404 + + @app.route("/api/routes//logs/download", methods=["GET"]) + def download_route_logs_archive(name): + logs = _route_log_files(name) + if not logs: + return jsonify({"error": "No full logs are stored on the device for this route."}), 404 + + def generate(): + buffer = _TarBuffer() + # streamed a file at a time so a long route never needs its whole archive in memory + with tarfile.open(fileobj=buffer, mode="w|") as archive: + for segment, filename, path, _ in logs: + try: + archive.add(path, arcname=f"{segment}/{filename}") + except OSError: + continue + chunk = buffer.pop() + if chunk: + yield chunk + chunk = buffer.pop() + if chunk: + yield chunk + + response = Response(generate(), mimetype="application/x-tar") + response.headers["Content-Disposition"] = f'attachment; filename="{name}-logs.tar"' + return response + @app.route("/api/routes/clear_name", methods=["POST"]) def clear_route_name(): data = request.get_json() From b3c417ce050b930de16450f056f13b2f0932bb38 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:59:01 -0700 Subject: [PATCH 04/24] Change to an overlay --- .../components/recordings/dashcam_routes.css | 174 +++++++++++++++--- .../components/recordings/dashcam_routes.js | 93 +++++++--- 2 files changed, 219 insertions(+), 48 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 65647709e..029ecc63c 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -244,45 +244,164 @@ transform: var(--hover-scale-sm); } -.route-logs { - margin-top: var(--padding-base); - max-height: 40vh; - overflow-y: auto; +.dialog-box.route-logs-dialog { + border: var(--border-width-thin) solid var(--sidebar-border-color); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + max-height: min(42rem, calc(100vh - 2rem)); + max-width: none; + min-width: 0; + overflow: hidden; + padding: 0; text-align: left; + width: min(36rem, calc(100vw - 2rem)); +} + +.route-logs-toolbar { + align-items: center; + background: var(--input-bg); + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; + flex-shrink: 0; + justify-content: space-between; + padding: var(--padding-base) var(--padding-lg); +} + +.route-logs-toolbar h2, +.route-logs-eyebrow { + margin: 0; +} + +.route-logs-toolbar h2 { + color: var(--text-color); + font-size: var(--font-size-xl); + line-height: 1.2; +} + +.route-logs-eyebrow { + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + margin-bottom: var(--margin-xs); + text-transform: uppercase; +} + +.route-logs-close { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + display: flex; + font-size: 2rem; + height: 2.5rem; + justify-content: center; + line-height: 1; + transition: background-color var(--transition-fast), color var(--transition-fast); + width: 2.5rem; +} + +.route-logs-close:hover, +.route-logs-close:focus-visible { + background: var(--sidebar-active-bg); + color: var(--text-color); + outline: none; +} + +.route-logs-content { + overflow-y: auto; + padding: var(--padding-lg); } .route-logs-message { + color: var(--text-color); + font-size: var(--font-size-base); + line-height: var(--line-height-base); margin: 0; - padding: var(--padding-sm) 0; - font-size: var(--font-size-sm); + padding: var(--padding-lg) var(--padding-sm); + text-align: center; } -.route-logs-header { - display: flex; +.route-logs-error { + color: var(--danger-fg); +} + +.route-logs-summary { align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + display: flex; justify-content: space-between; - gap: var(--padding-sm); - padding-bottom: var(--padding-sm); - font-size: var(--font-size-sm); + gap: var(--gap-md); + padding: var(--padding-base); +} + +.route-logs-summary > div, +.route-log-details { + display: flex; + flex-direction: column; + gap: var(--gap-xs); + min-width: 0; +} + +.route-logs-summary strong, +.route-log-details strong { + color: var(--text-color); + font-size: var(--font-size-base); +} + +.route-logs-summary div span, +.route-log-details span { + color: var(--text-muted); + font-size: var(--font-size-base); + line-height: 1.4; +} + +.route-logs-download-all, +.route-log-download { + background: var(--main-fg); + border-radius: var(--border-radius-md); + color: var(--text-on-primary); + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); + padding: var(--padding-sm) var(--padding-base); + text-decoration: none; + transition: filter var(--transition-fast), transform var(--transition-fast); +} + +.route-logs-download-all span { + opacity: 0.75; +} + +.route-logs-download-all:hover, +.route-log-download:hover, +.route-logs-download-all:focus-visible, +.route-log-download:focus-visible { + filter: brightness(1.15); + outline: none; + transform: translateY(-1px); } .route-logs-list { + display: grid; + gap: var(--gap-sm); list-style: none; - margin: 0; + margin: var(--margin-base) 0 0; padding: 0; } .route-logs-list li { - display: flex; align-items: center; + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; justify-content: space-between; - gap: var(--padding-sm); - padding: var(--padding-sm) 0; - font-size: var(--font-size-sm); -} - -.route-logs a { - text-decoration: underline; + gap: var(--gap-md); + padding: var(--padding-sm) var(--padding-xs) var(--padding-base); } @media only screen and (max-width: 768px) and (orientation: portrait) { @@ -308,10 +427,19 @@ width: 100%; } - .route-logs-header, + .route-logs-toolbar, + .route-logs-content { + padding: var(--padding-base); + } + + .route-logs-summary, .route-logs-list li { flex-direction: column; - align-items: flex-start; - gap: 0.25rem; + align-items: stretch; + } + + .route-logs-download-all, + .route-log-download { + text-align: center; } } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 29fc852a1..e19d545b0 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -301,7 +301,6 @@ async function openOverlay(route) { - `; document.body.appendChild(overlay); @@ -315,7 +314,6 @@ async function openOverlay(route) { const vid = overlay.querySelector("video"); const downloadButton = overlay.querySelector(".action-download"); const logsButton = overlay.querySelector(".action-logs"); - const logsPanel = overlay.querySelector(".route-logs"); const formatBytes = bytes => { if (!bytes) return "0 MB"; @@ -323,39 +321,84 @@ async function openOverlay(route) { return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`; }; - let logsLoaded = false; + let logsData = null; logsButton.onclick = async () => { - if (logsLoaded) { - logsPanel.hidden = !logsPanel.hidden; - return; - } + const logsDialog = openDialog(` + `); + const logsContent = logsDialog.querySelector(".route-logs-content"); + const logsCloseButton = logsDialog.querySelector(".route-logs-close"); - logsPanel.hidden = false; - logsPanel.innerHTML = `

Looking for full logs...

`; - try { - const response = await fetch(`/api/routes/${route.name}/logs`); - const data = await response.json(); - if (!response.ok) { - logsPanel.innerHTML = `

${data.error || "Could not read logs."}

`; - return; - } + const closeLogsDialog = () => { + document.removeEventListener("keydown", handleLogsKeydown); + closeDialog(logsDialog); + logsButton.focus(); + }; + const handleLogsKeydown = event => { + if (event.key === "Escape") closeLogsDialog(); + }; + logsCloseButton.onclick = closeLogsDialog; + logsDialog.addEventListener("click", event => { + if (event.target === logsDialog) closeLogsDialog(); + }); + document.addEventListener("keydown", handleLogsKeydown); + logsCloseButton.focus(); - // sizes are shown up front so a metered connection is a deliberate choice - logsPanel.innerHTML = ` -
- ${data.segments.length} segment${data.segments.length === 1 ? "" : "s"} · ${formatBytes(data.totalBytes)} total - Download all (.tar) + const renderLogs = data => { + logsContent.innerHTML = ` +
+
+ ${data.segments.length} segment${data.segments.length === 1 ? "" : "s"} + ${formatBytes(data.totalBytes)} total download +
+ + Download all .tar +
    ${data.segments.map(segment => `
  • - Segment ${segment.segmentNum} · ${formatBytes(segment.bytes)} - ${segment.filename} +
    + Segment ${segment.segmentNum} + ${formatBytes(segment.bytes)} · ${segment.filename} +
    + Download
  • `).join("")}
`; - logsLoaded = true; + }; + + if (logsData) { + renderLogs(logsData); + return; + } + + try { + const response = await fetch(`/api/routes/${route.name}/logs`); + const data = await response.json(); + if (!response.ok) { + if (logsContent.isConnected) { + logsContent.innerHTML = `

${data.error || "Could not read logs."}

`; + } + return; + } + + // sizes are shown up front so a metered connection is a deliberate choice + logsData = data; + if (logsContent.isConnected) renderLogs(data); } catch (error) { - logsPanel.innerHTML = `

Could not reach the device: ${error.message}

`; + if (logsContent.isConnected) { + logsContent.innerHTML = `

Could not reach the device: ${error.message}

`; + } } }; From 328f2d9ff703b7fa2fef3827575fcaef27cba422 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:59:42 -0700 Subject: [PATCH 05/24] Routes Page - Cleanup --- .../components/recordings/dashcam_routes.css | 508 ++++++++++- .../components/recordings/dashcam_routes.js | 850 ++++++++---------- .../recordings/dashcam_routes_helpers.js | 129 +++ .../system/the_galaxy/assets/js/utils.js | 14 + .../the_galaxy/tests/test_dashboard_stats.py | 38 +- .../the_galaxy/tests/test_dashcam_routes.py | 416 +++++++++ .../tests/test_dashcam_routes_helpers.py | 186 ++++ starpilot/system/the_galaxy/the_galaxy.py | 241 ++++- starpilot/system/the_galaxy/utilities.py | 64 +- 9 files changed, 1882 insertions(+), 564 deletions(-) create mode 100644 starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js create mode 100644 starpilot/system/the_galaxy/tests/test_dashcam_routes.py create mode 100644 starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 029ecc63c..4286d1e6b 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -159,34 +159,6 @@ color: white; } -.show-preserved-button { - background-color: var(--input-bg); - border: none; - border-radius: var(--border-radius-lg); - color: var(--text-color); - font-size: var(--font-size-base); - font-weight: var(--font-weight-demi-bold); - margin-top: var(--margin-sm); - padding: var(--padding-sm) var(--padding-base); - text-align: center; - transition: - background-color var(--transition-fast), - box-shadow var(--transition-fast), - transform var(--transition-fast); - width: auto; -} - -.show-preserved-button:hover { - background-color: var(--success-hover-bg); - box-shadow: var(--shadow-md); - transform: var(--hover-scale-sm); -} - -.show-preserved-button[disabled] { - cursor: not-allowed; - opacity: var(--disabled-opacity); -} - .delete-all-button { background-color: var(--danger-bg); border: none; @@ -443,3 +415,483 @@ text-align: center; } } + +/* Date-organized route library */ +.screen-recordings-wrapper.dashcam-routes-wrapper { + padding: 0 var(--padding-base) var(--padding-xl); + width: 100%; +} + +.screen-recordings-widget.dashcam-library { + align-items: stretch; + max-width: 92rem; + padding: clamp(1rem, 2vw, 2rem); +} + +.screen-recordings-widget.dashcam-library:hover { + transform: none; +} + +.dashcam-library-header, +.dashcam-toolbar, +.dashcam-results-summary, +.dashcam-danger-zone, +.dashcam-player-header, +.dashcam-player-actions, +.dashcam-camera-selector { + align-items: center; + display: flex; +} + +.dashcam-library-header { + justify-content: space-between; + gap: var(--gap-md); +} + +.dashcam-library-header h1, +.dashcam-library-eyebrow, +.dashcam-player-header h2, +.dashcam-player-eyebrow { + margin: 0; +} + +.dashcam-library-header h1 { + color: var(--text-color); + font-size: clamp(1.5rem, 3vw, 2.25rem); + line-height: 1.15; +} + +.dashcam-library-eyebrow, +.dashcam-player-eyebrow { + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + margin-bottom: var(--margin-xs); + text-transform: uppercase; +} + +.dashcam-refresh-button, +.dashcam-preserved-filter, +.dashcam-sort, +.dashcam-search { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); +} + +.dashcam-refresh-button, +.dashcam-preserved-filter { + cursor: pointer; + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); + padding: 0.7rem 1rem; +} + +.dashcam-refresh-button:disabled, +.dashcam-player-actions button:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); +} + +.dashcam-toolbar { + flex-wrap: wrap; + gap: var(--gap-sm); + margin-top: var(--margin-lg); +} + +.dashcam-search { + align-items: center; + display: flex; + flex: 1 1 22rem; + gap: var(--gap-sm); + padding: 0 0.9rem; +} + +.dashcam-search input, +.dashcam-sort select { + background: transparent; + border: 0; + color: var(--text-color); + font: inherit; + outline: 0; +} + +.dashcam-search input { + min-width: 0; + padding: 0.75rem 0; + width: 100%; +} + +.dashcam-sort { + gap: var(--gap-sm); + padding: 0.65rem 0.85rem; +} + +.dashcam-sort span { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.dashcam-sort select { + cursor: pointer; +} + +.dashcam-preserved-filter[aria-pressed="true"] { + background: rgba(230, 78, 102, 0.16); + border-color: rgba(230, 78, 102, 0.65); +} + +.dashcam-preserved-filter .bi-heart-fill { + color: #ef6078; + margin-right: 0.35rem; +} + +.dashcam-results-summary { + color: var(--text-muted); + font-size: var(--font-size-sm); + justify-content: space-between; + margin-top: var(--margin-base); + min-height: 1.5rem; +} + +.dashcam-date-groups, +.dashcam-date-group { + width: 100%; +} + +.dashcam-date-group { + margin-top: var(--margin-lg); +} + +.dashcam-date-group > h2 { + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + color: var(--text-color); + font-size: var(--font-size-lg); + margin: 0; + padding-bottom: var(--padding-sm); +} + +.dashcam-library .screen-recordings-grid.dashcam-routes-grid { + gap: clamp(0.8rem, 1.5vw, 1.25rem); + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); + margin-top: var(--margin-base); +} + +.dashcam-library .recording-card.dashcam-route-card { + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + position: relative; + text-align: left; +} + +.dashcam-library .recording-card.dashcam-route-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.dashcam-route-card .preserved-icon { + align-items: center; + background: rgba(10, 12, 18, 0.7); + border: 0; + border-radius: 50%; + color: white; + display: flex; + height: 2.6rem; + justify-content: center; + right: 0.7rem; + top: 0.7rem; + width: 2.6rem; +} + +.dashcam-route-card .dashcam-preview { + background: linear-gradient(135deg, var(--sidebar-bg), var(--input-bg)); + overflow: hidden; +} + +.dashcam-preview-fallback { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + gap: var(--gap-xs); + inset: 0; + justify-content: center; + position: absolute; +} + +.dashcam-preview-fallback i { + font-size: 2rem; +} + +.dashcam-preview:not(.thumbnail-failed) .dashcam-preview-fallback { + visibility: hidden; +} + +.dashcam-preview img { + background: var(--sidebar-bg); + z-index: 1; +} + +.dashcam-card-body { + padding: var(--padding-base); +} + +.dashcam-card-body h3 { + color: var(--text-color); + font-size: var(--font-size-lg); + line-height: 1.3; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashcam-card-date, +.dashcam-card-details, +.dashcam-preserve-status { + color: var(--text-muted); + font-size: var(--font-size-sm); + margin: var(--margin-xs) 0 0; +} + +.dashcam-card-details { + display: flex; + gap: var(--gap-sm); +} + +.dashcam-card-details span + span::before { + content: "·"; + margin-right: var(--gap-sm); +} + +.dashcam-preserve-status { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-demi-bold); + text-transform: uppercase; +} + +.dashcam-preserve-status.preserved { + color: #ef6078; +} + +.dashcam-loading, +.dashcam-empty-state { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + justify-content: center; + min-height: 15rem; + width: 100%; +} + +.dashcam-loading span { + animation: dashcam-spin 0.8s linear infinite; + border: 3px solid var(--sidebar-border-color); + border-radius: 50%; + border-top-color: var(--main-fg); + height: 2.5rem; + width: 2.5rem; +} + +.dashcam-empty-state i { + font-size: 3rem; +} + +.dashcam-error { + color: var(--danger-fg); +} + +@keyframes dashcam-spin { + to { transform: rotate(360deg); } +} + +.dashcam-danger-zone { + border-top: var(--border-width-thin) solid var(--sidebar-border-color); + gap: var(--gap-md); + justify-content: space-between; + margin-top: 2.5rem; + padding-top: var(--padding-lg); +} + +.dashcam-danger-zone > div { + display: flex; + flex-direction: column; + gap: var(--gap-xs); +} + +.dashcam-danger-zone span { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +/* Focused route player */ +.dashcam-player-overlay .media-player-content.dashcam-player { + background: var(--secondary-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.55); + max-height: calc(100vh - 2rem); + max-width: calc(100vw - 2rem); + overflow-y: auto; + padding: 0; + text-align: left; + width: min(62rem, calc(100vw - 2rem)); +} + +.dashcam-player-header { + justify-content: space-between; + padding: var(--padding-base) var(--padding-lg); +} + +.dashcam-player-header h2 { + color: var(--text-color); + font-size: var(--font-size-xl); + line-height: 1.25; +} + +.dashcam-player-close { + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + font-size: 2rem; +} + +.dashcam-video-shell { + aspect-ratio: 16 / 9; + background: #07080b; + position: relative; + width: 100%; +} + +.dashcam-player-overlay .dashcam-player .dashcam-video-shell video { + border-radius: 0; + height: 100%; + object-fit: contain; + width: 100%; +} + +.dashcam-player-state { + align-items: center; + background: rgba(7, 8, 11, 0.78); + color: white; + display: flex; + inset: 0; + justify-content: center; + position: absolute; + text-align: center; +} + +.dashcam-player-state.error { + color: #ff9aaa; +} + +.dashcam-player-state[hidden], +.dashcam-segment-status[hidden], +.dashcam-camera-selector button[hidden] { + display: none; +} + +.dashcam-segment-status { + background: var(--sidebar-bg); + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + color: var(--text-muted); + font-size: var(--font-size-sm); + padding: 0.65rem var(--padding-lg); +} + +.dashcam-camera-selector { + gap: var(--gap-xs); + padding: var(--padding-base) var(--padding-lg) 0; +} + +.dashcam-camera-selector button, +.dashcam-player-actions button { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + font-size: var(--font-size-base); + padding: 0.65rem 1rem; +} + +.dashcam-camera-selector button.active { + background: var(--sidebar-active-bg); + box-shadow: inset 0 0 0 1px var(--main-fg); +} + +.dashcam-player-actions { + flex-wrap: wrap; + gap: var(--gap-sm); + padding: var(--padding-base) var(--padding-lg) var(--padding-lg); +} + +.dashcam-player-actions .action-download { + background: var(--color-confirm); +} + +.dashcam-player-actions .action-delete { + background: var(--danger-bg); + margin-left: auto; +} + +@media only screen and (max-width: 768px) { + .screen-recordings-wrapper.dashcam-routes-wrapper { + padding: 0 var(--padding-sm) var(--padding-lg); + } + + .screen-recordings-widget.dashcam-library { + margin-top: var(--padding-base); + padding: var(--padding-base); + } + + .dashcam-library-header, + .dashcam-danger-zone { + align-items: stretch; + flex-direction: column; + } + + .dashcam-refresh-button, + .dashcam-preserved-filter, + .dashcam-sort { + justify-content: center; + } + + .dashcam-sort { + display: flex; + flex: 1; + } + + .dashcam-library .screen-recordings-grid.dashcam-routes-grid { + grid-template-columns: 1fr; + } + + .dashcam-player-overlay .media-player-content.dashcam-player { + max-height: calc(100vh - 1rem); + max-width: calc(100vw - 1rem); + width: calc(100vw - 1rem); + } + + .dashcam-player-header, + .dashcam-segment-status, + .dashcam-camera-selector, + .dashcam-player-actions { + padding-left: var(--padding-base); + padding-right: var(--padding-base); + } + + .dashcam-camera-selector, + .dashcam-player-actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + } + + .dashcam-player-actions .action-delete { + margin-left: 0; + } +} diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index e19d545b0..d60554c6a 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -1,165 +1,92 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" -import { isGalaxyTunnel } from "/assets/js/utils.js" -import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js" -import { Modal } from "/assets/components/modal.js"; +import { escapeHtml, isGalaxyTunnel } from "/assets/js/utils.js" +import { Modal } from "/assets/components/modal.js" +import { + buildRouteView, + cameraVideoUrl, + formatApproxDuration, + getSegmentStatus, + groupRoutesByDate, + MAX_RENDERED_ROUTES, + normalizeRoute, +} from "/assets/components/recordings/dashcam_routes_helpers.js" const state = reactive({ loading: true, error: null, routes: [], selectedRoute: null, + searchQuery: "", + sortOrder: "newest", showPreservedOnly: false, progress: 0, total: 0, showDeleteAllModal: false, isDeletingAll: false, - truncated: false, }) -const MAX_RENDERED_ROUTES = 250 -const ROUTE_FLUSH_INTERVAL_MS = 120 - let routesAbortController = null let routesRequestToken = 0 -let pendingRoutes = [] -let flushTimerId = null let seenRouteNames = new Set() +let overlay = null -function formatRouteDate(dateString) { - if (!dateString) { - return "Unknown Date" - } - - const date = new Date(dateString) - if (isNaN(date.getTime())) { - return dateString - } - const month = date.toLocaleString("en-US", { month: "long" }) - const day = date.getDate() - const year = date.getFullYear() - let hour = date.getHours() - const minute = date.getMinutes() - const ampm = hour >= 12 ? "pm" : "am" - hour = hour % 12 - hour = hour || 12 - const minuteStr = minute < 10 ? "0" + minute : minute - return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}` +function routeLabel(route) { + return route.displayName || route.displayDate || route.name } -function resetRouteStreamState() { - pendingRoutes = [] - if (flushTimerId !== null) { - clearTimeout(flushTimerId) - flushTimerId = null - } - seenRouteNames = new Set() -} - -function flushPendingRoutes() { - if (pendingRoutes.length === 0) return - - const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0) - if (availableSlots <= 0) { - pendingRoutes = [] - state.truncated = true - return - } - - const toAppend = pendingRoutes.slice(0, availableSlots) - pendingRoutes = [] - if (toAppend.length > 0) { - state.routes = [...state.routes, ...toAppend] - } - if (state.routes.length >= MAX_RENDERED_ROUTES) { - state.truncated = true - } -} - -function enqueueRoutes(rawRoutes) { +function mergeRoutes(rawRoutes) { if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return - - const nextRoutes = [] - for (const route of rawRoutes) { - const name = String(route?.name || "") + const additions = [] + for (const rawRoute of rawRoutes) { + const name = String(rawRoute?.name || "") if (!name || seenRouteNames.has(name)) continue seenRouteNames.add(name) - nextRoutes.push({ - ...route, - timestamp: formatRouteDate(route.timestamp), - }) - } - - if (nextRoutes.length === 0) return - pendingRoutes.push(...nextRoutes) - - if (flushTimerId === null) { - flushTimerId = setTimeout(() => { - flushTimerId = null - flushPendingRoutes() - }, ROUTE_FLUSH_INTERVAL_MS) + additions.push(normalizeRoute(rawRoute)) } + // Worker completion order is irrelevant: buildRouteView sorts the list at render time. + if (additions.length) state.routes = [...state.routes, ...additions] } async function fetchRoutes() { const requestToken = ++routesRequestToken - if (routesAbortController) { - routesAbortController.abort() - } + routesAbortController?.abort() const controller = new AbortController() routesAbortController = controller try { - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, { - signal: controller.signal, - }); - if (!response.ok) throw new Error(); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + const response = await fetch("/api/routes", { signal: controller.signal }) + if (!response.ok || !response.body) throw new Error(`Route request failed (${response.status})`) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" while (true) { - const { value, done } = await reader.read(); - if (done) break; - + const { value, done } = await reader.read() + if (done) break if (requestToken !== routesRequestToken) return - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split(/\r?\n\r?\n/); - buffer = lines.pop(); - - for (const line of lines) { - if (line.startsWith("data:")) { - try { - const payload = line.substring(5).trim() - if (!payload) continue - const data = JSON.parse(payload); - if (data.progress !== undefined && data.total !== undefined) { - state.progress = data.progress; - state.total = data.total; - } - if (data.routes) { - enqueueRoutes(data.routes) - } - } catch (e) { - console.error("Failed to parse JSON:", e); - } + buffer += decoder.decode(value, { stream: true }) + const events = buffer.split(/\r?\n\r?\n/) + buffer = events.pop() || "" + for (const event of events) { + const dataLines = event.split(/\r?\n/).filter(line => line.startsWith("data:")) + if (!dataLines.length) continue + try { + const payload = JSON.parse(dataLines.map(line => line.slice(5).trimStart()).join("\n")) + if (Number.isFinite(payload.progress)) state.progress = payload.progress + if (Number.isFinite(payload.total)) state.total = payload.total + mergeRoutes(payload.routes) + } catch (error) { + console.error("Failed to parse route stream event:", error) } } } - flushPendingRoutes() } catch (error) { - if (error?.name !== "AbortError") { - state.error = "Couldn't load routes. Please try again later..." - } + if (error?.name !== "AbortError") state.error = "Couldn't load routes. Try refreshing." } finally { if (requestToken === routesRequestToken) { - flushPendingRoutes() state.loading = false - if (routesAbortController === controller) { - routesAbortController = null - } + if (routesAbortController === controller) routesAbortController = null } } } @@ -170,313 +97,325 @@ function refresh() { state.routes = [] state.progress = 0 state.total = 0 - state.truncated = false - resetRouteStreamState() - fetchRoutes() + seenRouteNames = new Set() + return fetchRoutes() } -refresh() +if (!isGalaxyTunnel()) refresh() -let overlay = null - -function openDialog(htmlStr) { - const o = document.createElement("div") - o.className = "dialog-overlay" - o.innerHTML = htmlStr - document.body.appendChild(o) - return o +function openDialog(htmlString) { + const dialog = document.createElement("div") + dialog.className = "dialog-overlay" + dialog.innerHTML = htmlString + document.body.appendChild(dialog) + return dialog } -function closeDialog(o) { - if (o) o.remove() +function closeDialog(dialog) { + dialog?.remove() +} + +function replaceRoute(updatedRoute) { + state.routes = state.routes.map(route => route.name === updatedRoute.name ? updatedRoute : route) + if (state.selectedRoute?.name === updatedRoute.name) state.selectedRoute = updatedRoute } async function deleteRoute(route) { - const dlg = openDialog(` + const dialog = openDialog(`
-

Delete “${route.timestamp}”?

+

Delete “${escapeHtml(routeLabel(route))}”?

- - + +
`) - dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg) - dlg.querySelector(".btn-del").onclick = async () => { - const res = await fetch(`/api/routes/${route.name}`, { method: "DELETE" }) - if (res.ok) { - state.routes = state.routes.filter(r => r.name !== route.name) - closeDialog(dlg) - closeOverlay() - refresh() - showSnackbar("Route deleted!") - } else { + dialog.querySelector(".btn-cancel").onclick = () => closeDialog(dialog) + dialog.querySelector(".btn-del").onclick = async () => { + const response = await fetch(`/api/routes/${route.name}`, { method: "DELETE" }) + if (!response.ok) { showSnackbar("Delete failed...", "error") + return } + closeDialog(dialog) + closeOverlay() + await refresh() + showSnackbar("Route deleted!") } } -async function resetRouteName(route, dlg) { - const res = await fetch(`/api/routes/reset_name`, { +async function resetRouteName(route, dialog) { + const response = await fetch("/api/routes/reset_name", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: route.name }) - }); - if (res.ok) { - const { timestamp } = await res.json(); - closeDialog(dlg); - const routeInList = state.routes.find(r => r.name === route.name); - if (routeInList) { - routeInList.timestamp = formatRouteDate(timestamp); - } - route.timestamp = formatRouteDate(timestamp); - const overlayTitleSpan = overlay.querySelector(".media-player-title span"); - if (overlayTitleSpan) { - overlayTitleSpan.textContent = formatRouteDate(timestamp); - } - showSnackbar("Route name reset!"); - } else { - showSnackbar("Resetting name failed...", "error"); + body: JSON.stringify({ name: route.name }), + }) + if (!response.ok) { + showSnackbar("Resetting name failed...", "error") + return } + + const { timestamp } = await response.json() + const updatedRoute = normalizeRoute({ ...route, timestamp, isCustomName: false }) + replaceRoute(updatedRoute) + closeDialog(dialog) + const title = overlay?.querySelector(".media-player-title-text") + if (title) title.textContent = routeLabel(updatedRoute) + showSnackbar("Route name reset!") } async function renameRoute(route) { - const dlg = openDialog(` + const dialog = openDialog(`
-

Rename "${route.timestamp}"

- +

Rename “${escapeHtml(routeLabel(route))}”

+
- - - + + +
-
`); - dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg); - dlg.querySelector(".btn-reset").onclick = () => resetRouteName(route, dlg); - dlg.querySelector(".btn-save").onclick = async () => { - const newName = dlg.querySelector(".rn-input").value.trim(); - if (!newName) return; - const res = await fetch(`/api/routes/rename`, { +
`) + dialog.querySelector(".btn-cancel").onclick = () => closeDialog(dialog) + dialog.querySelector(".btn-reset").onclick = () => resetRouteName(route, dialog) + dialog.querySelector(".btn-save").onclick = async () => { + const newName = dialog.querySelector(".rn-input").value.trim() + if (!newName) return + const response = await fetch("/api/routes/rename", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ old: route.name, new: newName }) - }); - if (res.ok) { - closeDialog(dlg); - const routeInList = state.routes.find(r => r.name === route.name); - if (routeInList) { - routeInList.timestamp = newName; - } - route.timestamp = newName; - const overlayTitleSpan = overlay.querySelector(".media-player-title span"); - if (overlayTitleSpan) { - overlayTitleSpan.textContent = newName; - } - showSnackbar("Route renamed!"); - } else { - showSnackbar("Rename failed...", "error"); + body: JSON.stringify({ old: route.name, new: newName }), + }) + if (!response.ok) { + showSnackbar("Rename failed...", "error") + return } - }; + + const updatedRoute = normalizeRoute({ ...route, timestamp: newName, isCustomName: true }) + replaceRoute(updatedRoute) + closeDialog(dialog) + const title = overlay?.querySelector(".media-player-title-text") + if (title) title.textContent = newName + showSnackbar("Route renamed!") + } +} + +function formatBytes(bytes) { + if (!bytes) return "0 MB" + const megabytes = bytes / 1e6 + return megabytes >= 1000 ? `${(megabytes / 1000).toFixed(2)} GB` : `${megabytes.toFixed(1)} MB` +} + +function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) { + const logsDialog = openDialog(` + `) + const content = logsDialog.querySelector(".route-logs-content") + const closeButton = logsDialog.querySelector(".route-logs-close") + const closeLogsDialog = () => { + document.removeEventListener("keydown", handleKeydown) + closeDialog(logsDialog) + logsButton.focus() + } + const handleKeydown = event => { if (event.key === "Escape") closeLogsDialog() } + closeButton.onclick = closeLogsDialog + logsDialog.addEventListener("click", event => { if (event.target === logsDialog) closeLogsDialog() }) + document.addEventListener("keydown", handleKeydown) + closeButton.focus() + + const renderLogs = data => { + content.innerHTML = ` +
+
${data.segments.length} segment${data.segments.length === 1 ? "" : "s"}${formatBytes(data.totalBytes)} total download
+ Download all .tar +
+
    + ${data.segments.map(segment => `
  • +
    Segment ${Number(segment.segmentNum)}${formatBytes(segment.bytes)} · ${escapeHtml(segment.filename)}
    + Download +
  • `).join("")} +
` + } + + const cachedLogs = getCachedLogs() + if (cachedLogs) { + renderLogs(cachedLogs) + return + } + + fetch(`/api/routes/${route.name}/logs`) + .then(async response => ({ response, data: await response.json() })) + .then(({ response, data }) => { + if (!response.ok) { + if (content.isConnected) content.innerHTML = `

${escapeHtml(data.error || "Could not read logs.")}

` + return + } + setCachedLogs(data) + if (content.isConnected) renderLogs(data) + }) + .catch(error => { + if (content.isConnected) content.innerHTML = `

Could not reach the device: ${escapeHtml(error.message)}

` + }) } async function openOverlay(route) { - if (overlay) return; - - overlay = document.createElement("div"); - overlay.className = "media-player-overlay"; + if (overlay) return + overlay = document.createElement("div") + overlay.className = "media-player-overlay dashcam-player-overlay" overlay.innerHTML = ` -
-
- ${route.timestamp} - + ` + document.body.appendChild(overlay) - overlay.addEventListener("click", e => { - if (e.target === overlay) closeOverlay(); - }); - overlay.querySelector(".action-rename-icon").onclick = () => renameRoute(route); - overlay.querySelector(".action-close").onclick = closeOverlay; - overlay.querySelector(".action-delete").onclick = () => deleteRoute(route); + const video = overlay.querySelector("video") + const playerState = overlay.querySelector(".dashcam-player-state") + const statusStrip = overlay.querySelector(".dashcam-segment-status") + const downloadButton = overlay.querySelector(".action-download") + const logsButton = overlay.querySelector(".action-logs") + const cameraButtons = [...overlay.querySelectorAll(".camera-button")] + let segments = [] + let current = 0 + let selectedCamera = null + let logsData = null - const vid = overlay.querySelector("video"); - const downloadButton = overlay.querySelector(".action-download"); - const logsButton = overlay.querySelector(".action-logs"); + const setPlayerMessage = (message, isError = false) => { + playerState.textContent = message + playerState.hidden = !message + playerState.classList.toggle("error", isError) + } + const updateSegmentStatus = () => { + const status = getSegmentStatus(segments, current) + statusStrip.textContent = status + statusStrip.hidden = !status + } + const playCurrentSegment = () => { + if (!segments[current] || !selectedCamera) return + updateSegmentStatus() + setPlayerMessage("Loading video…") + video.src = cameraVideoUrl(segments[current], selectedCamera) + video.load() + video.play().catch(() => {}) + } + const closeOnEscape = event => { + if (event.key === "Escape" && !document.querySelector(".route-logs-dialog")) closeOverlay() + } - const formatBytes = bytes => { - if (!bytes) return "0 MB"; - const mb = bytes / 1e6; - return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`; - }; - - let logsData = null; - logsButton.onclick = async () => { - const logsDialog = openDialog(` - `); - const logsContent = logsDialog.querySelector(".route-logs-content"); - const logsCloseButton = logsDialog.querySelector(".route-logs-close"); - - const closeLogsDialog = () => { - document.removeEventListener("keydown", handleLogsKeydown); - closeDialog(logsDialog); - logsButton.focus(); - }; - const handleLogsKeydown = event => { - if (event.key === "Escape") closeLogsDialog(); - }; - logsCloseButton.onclick = closeLogsDialog; - logsDialog.addEventListener("click", event => { - if (event.target === logsDialog) closeLogsDialog(); - }); - document.addEventListener("keydown", handleLogsKeydown); - logsCloseButton.focus(); - - const renderLogs = data => { - logsContent.innerHTML = ` -
-
- ${data.segments.length} segment${data.segments.length === 1 ? "" : "s"} - ${formatBytes(data.totalBytes)} total download -
- - Download all .tar - -
-
    - ${data.segments.map(segment => ` -
  • -
    - Segment ${segment.segmentNum} - ${formatBytes(segment.bytes)} · ${segment.filename} -
    - Download -
  • `).join("")} -
`; - }; - - if (logsData) { - renderLogs(logsData); - return; - } - - try { - const response = await fetch(`/api/routes/${route.name}/logs`); - const data = await response.json(); - if (!response.ok) { - if (logsContent.isConnected) { - logsContent.innerHTML = `

${data.error || "Could not read logs."}

`; - } - return; - } - - // sizes are shown up front so a metered connection is a deliberate choice - logsData = data; - if (logsContent.isConnected) renderLogs(data); - } catch (error) { - if (logsContent.isConnected) { - logsContent.innerHTML = `

Could not reach the device: ${error.message}

`; - } - } - }; - - let segments; - let current = 0; - let selectedCamera = "forward"; + overlay.addEventListener("click", event => { if (event.target === overlay) closeOverlay() }) + document.addEventListener("keydown", closeOnEscape) + overlay._closeOnEscape = closeOnEscape + overlay.querySelector(".action-close").onclick = closeOverlay + overlay.querySelector(".action-delete").onclick = () => deleteRoute(state.selectedRoute || route) + overlay.querySelector(".action-rename").onclick = () => renameRoute(state.selectedRoute || route) + logsButton.onclick = () => openLogsDialog(route, logsButton, () => logsData, value => { logsData = value }) downloadButton.onclick = () => { - const link = document.createElement("a"); - const videoPath = `/video/${route.name}/combined?camera=${selectedCamera}`; - link.href = videoPath; - link.download = `${route.timestamp}-${selectedCamera}.mp4`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; + if (!selectedCamera) return + const link = document.createElement("a") + link.href = `/video/${route.name}/combined?camera=${encodeURIComponent(selectedCamera)}` + link.download = `${routeLabel(state.selectedRoute || route)}-${selectedCamera}.mp4` + document.body.appendChild(link) + link.click() + link.remove() + } - (async () => { - try { - const response = await fetch(`/api/routes/${route.name}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - segments = data.segment_urls; + video.addEventListener("loadeddata", () => setPlayerMessage("")) + video.addEventListener("playing", () => setPlayerMessage("")) + video.addEventListener("waiting", () => setPlayerMessage("Loading video…")) + video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true)) + video.addEventListener("ended", () => { + if (current + 1 >= segments.length) return + current += 1 + playCurrentSegment() + }) - if (!segments || segments.length === 0) { - segments = [`/video/${route.name}--0`]; - } - vid.src = `${segments[0]}?camera=forward`; - vid.load(); - vid.play(); - } catch (error) { - showSnackbar("Error: Could not load combined route video.", "error"); + for (const button of cameraButtons) { + button.addEventListener("click", () => { + if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return + const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 + const shouldResume = !video.paused && !video.ended + selectedCamera = button.dataset.camera + cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) + video.addEventListener("loadedmetadata", () => { + if (playbackTime > 0) { + try { + video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) + } catch (_) {} + } + if (shouldResume) video.play().catch(() => {}) + }, { once: true }) + setPlayerMessage("Switching camera…") + video.src = cameraVideoUrl(segments[current], selectedCamera) + video.load() + // Switching cameras deliberately leaves current and the status strip unchanged. + }) + } + + try { + const response = await fetch(`/api/routes/${route.name}`) + if (!response.ok) throw new Error(`Route metadata request failed (${response.status})`) + const data = await response.json() + segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter(url => typeof url === "string") : [] + const availableCameras = ["forward", "wide", "driver"].filter(camera => data.available_cameras?.includes(camera)) + if (!segments.length) throw new Error("No video segments are stored for this route") + if (!availableCameras.length) throw new Error("No camera video is stored for this route") + + selectedCamera = availableCameras.includes("forward") ? "forward" : availableCameras[0] + for (const button of cameraButtons) { + const available = availableCameras.includes(button.dataset.camera) + button.hidden = !available + button.disabled = !available + button.classList.toggle("active", button.dataset.camera === selectedCamera) } - })(); - - vid.addEventListener("ended", () => { - current++; - if (current < segments.length) { - const videoPath = segments[current].includes("?") ? `${segments[current]}&camera=${selectedCamera}` : `${segments[current]}?camera=${selectedCamera}` - vid.src = videoPath; - vid.load(); - vid.play(); - } - }); - - overlay.querySelectorAll(".camera-button").forEach(button => { - button.addEventListener("click", e => { - overlay.querySelectorAll(".camera-button").forEach(btn => btn.classList.remove("active")); - e.target.classList.add("active"); - selectedCamera = e.target.dataset.camera; - vid.src = segments[current].includes("?") ? `${segments[current]}&camera=${selectedCamera}` : `${segments[current]}?camera=${selectedCamera}`; - vid.load(); - vid.play(); - }); - }); + downloadButton.disabled = false + playCurrentSegment() + } catch (error) { + cameraButtons.forEach(button => { button.disabled = true }) + setPlayerMessage(error.message || "Could not load this route.", true) + } } function closeOverlay() { if (!overlay) return + document.removeEventListener("keydown", overlay._closeOnEscape) overlay.remove() overlay = null state.selectedRoute = null } -async function togglePreserved(route, e) { - e.stopPropagation() - const newPreservedState = !route.is_preserved - const method = newPreservedState ? "POST" : "DELETE" +async function togglePreserved(route, event) { + event.stopPropagation() + const isPreserved = !route.is_preserved try { - const response = await fetch(`/api/routes/${route.name}/preserve`, { method }) - if (response.ok) { - route.is_preserved = newPreservedState - } else { + const response = await fetch(`/api/routes/${route.name}/preserve`, { method: isPreserved ? "POST" : "DELETE" }) + if (!response.ok) { const errorData = await response.json() showSnackbar(errorData.error || "Failed to update preserved state...", "error") + return } + replaceRoute({ ...route, is_preserved: isPreserved }) } catch (_) { showSnackbar("An error occurred...", "error") } @@ -486,17 +425,22 @@ async function deleteAllRoutes() { state.showDeleteAllModal = false state.isDeletingAll = true try { - const res = await fetch("/api/routes/delete_all", { method: "DELETE" }) - if (!res.ok) throw new Error() + const response = await fetch("/api/routes/delete_all", { method: "DELETE" }) + if (!response.ok) throw new Error() await refresh() showSnackbar("All routes deleted!") - } catch { + } catch (_) { showSnackbar("An error occurred while deleting all routes...", "error") } finally { state.isDeletingAll = false } } +function thumbnailFailed(event) { + event.currentTarget.hidden = true + event.currentTarget.parentElement?.classList.add("thumbnail-failed") +} + export function RouteRecordings() { if (isGalaxyTunnel()) { return html` @@ -504,97 +448,87 @@ export function RouteRecordings() {
🛰️

Dashcam Routes Unavailable via Galaxy

Loading dashcam routes requires a direct connection.
Connect to your device's local network to use this feature.

-
- `; +
` } - if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute); + if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute) return html` -
-
-
Dashcam Routes
- +
+
+
+

Local recordings

Dashcam Routes

+ +
+ +
+ + + +
${() => { - const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved); - - if (routesToShow.length === 0) { - if (state.loading && state.total > 0) { - return html`

Processing Routes: ${state.progress} of ${state.total}

`; - } - if (state.loading && !state.isDeletingAll) { - return html`

Loading...

`; - } - if (state.isDeletingAll) { - return html`

Deleting routes...

`; - } - if (state.showPreservedOnly) { - return html`

No preserved routes...

`; - } - if (state.error) { - return html`

${state.error}

`; - } - return html`

No routes found...

`; - } - - return html` -
- ${routesToShow.map( - route => html` -
-
- ${() => html``} -
-
- -
-

${route.timestamp}

-
- ` - )} + const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder }) + const groups = groupRoutesByDate(view.visible) + return html` +
+ ${view.matching.length} matching route${view.matching.length === 1 ? "" : "s"} + ${state.loading ? html`Loading ${state.progress} of ${state.total}` : html`${state.routes.length} total`}
- `; - }} - ${() => state.truncated ? html` -

Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.

- ` : ""} - ${() => { - if (state.routes.length > 0) { - return html` - - `; - } - return ""; - }} -
+ ${state.error ? html`

${state.error}

` : ""} + ${state.isDeletingAll ? html`

Deleting routes…

` : ""} + ${!view.visible.length && state.loading ? html`

Finding local routes…

` : ""} + ${!view.visible.length && !state.loading && !state.isDeletingAll ? html`

${state.routes.length ? "No routes match these filters." : "No routes found."}

` : ""} +
+ ${groups.map(group => html` +
+

${group.label}

+
+ ${group.routes.map(route => html` +
+ +
+ Preview unavailable + +
+
+

${route.displayName}

+ ${route.isCustomName ? html`

${route.displayDate}

` : ""} +

${formatApproxDuration(route.approxDurationSeconds)}${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"}

+

${route.is_preserved ? "Preserved" : "Not preserved"}

+
+
`)} +
+
`)} +
+ ${view.truncated ? html`

Showing the first ${MAX_RENDERED_ROUTES} of ${view.matching.length} matching routes.

` : ""}` + }} + + ${() => state.routes.length ? html` +
+
Delete all local routesPreserved routes are included.
+ +
` : ""} +
${() => state.showDeleteAllModal ? Modal({ - title: "Confirm Delete All", - message: "Are you sure you want to delete all routes? This action cannot be undone...", - onConfirm: deleteAllRoutes, - onCancel: () => { state.showDeleteAllModal = false; }, - confirmText: "Delete All" - }) : ""} -
- `; + title: "Confirm Delete All", + message: "Are you sure you want to delete all routes? This action cannot be undone...", + onConfirm: deleteAllRoutes, + onCancel: () => { state.showDeleteAllModal = false }, + confirmText: "Delete All", + }) : ""} +
` } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js new file mode 100644 index 000000000..41372c48a --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -0,0 +1,129 @@ +export const MAX_RENDERED_ROUTES = 250 + +function validDate(value) { + if (!value) return null + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +export function formatRouteDate(value, locale) { + const date = validDate(value) + if (!date) return "Unknown date" + return new Intl.DateTimeFormat(locale, { + dateStyle: "long", + timeStyle: "short", + }).format(date) +} + +export function normalizeRoute(route, locale) { + const timestamp = route?.timestamp == null ? null : String(route.timestamp) + const startedAtDate = validDate(route?.startedAt) + const timestampDate = validDate(timestamp) + const displayDate = formatRouteDate(startedAtDate || timestampDate, locale) + const isCustomName = Boolean(route?.isCustomName) || Boolean(timestamp && !timestampDate) + + return { + ...route, + name: String(route?.name || ""), + timestamp, + startedAt: route?.startedAt || null, + isCustomName, + displayDate, + displayName: isCustomName ? timestamp : displayDate, + _startedAtMs: startedAtDate?.getTime() ?? timestampDate?.getTime() ?? null, + } +} + +export function sortRoutes(routes, sortOrder = "newest") { + const direction = sortOrder === "oldest" ? 1 : -1 + return [...routes].sort((left, right) => { + const leftTime = left?._startedAtMs + const rightTime = right?._startedAtMs + if (leftTime == null && rightTime == null) return String(left?.name || "").localeCompare(String(right?.name || "")) + if (leftTime == null) return 1 + if (rightTime == null) return -1 + if (leftTime !== rightTime) return (leftTime - rightTime) * direction + return String(left?.name || "").localeCompare(String(right?.name || "")) * -direction + }) +} + +export function routeMatchesSearch(route, searchQuery) { + const query = String(searchQuery || "").trim().toLocaleLowerCase() + if (!query) return true + return [route?.name, route?.timestamp, route?.displayName, route?.displayDate] + .filter(Boolean) + .some(value => String(value).toLocaleLowerCase().includes(query)) +} + +export function buildRouteView(routes, options = {}) { + const matching = sortRoutes( + routes.filter(route => (!options.preservedOnly || route.is_preserved) && routeMatchesSearch(route, options.searchQuery)), + options.sortOrder, + ) + return { + matching, + visible: matching.slice(0, MAX_RENDERED_ROUTES), + truncated: matching.length > MAX_RENDERED_ROUTES, + } +} + +function localDayKey(date) { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` +} + +export function groupRoutesByDate(routes, now = new Date(), locale) { + const today = validDate(now) || new Date() + today.setHours(0, 0, 0, 0) + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const groups = [] + const byKey = new Map() + + for (const route of routes) { + const routeDate = route?._startedAtMs == null ? null : new Date(route._startedAtMs) + const key = routeDate ? localDayKey(routeDate) : "unknown" + let group = byKey.get(key) + if (!group) { + let label = "Unknown date" + if (routeDate) { + if (key === localDayKey(today)) label = "Today" + else if (key === localDayKey(yesterday)) label = "Yesterday" + else label = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate) + } + group = { key, label, routes: [] } + byKey.set(key, group) + groups.push(group) + } + group.routes.push(route) + } + return groups +} + +export function formatApproxDuration(seconds) { + const minutes = Math.max(0, Math.round(Number(seconds) / 60) || 0) + if (minutes < 1) return "Less than 1 min" + if (minutes < 60) return `About ${minutes} min` + const hours = Math.floor(minutes / 60) + const remaining = minutes % 60 + return `About ${hours} hr${remaining ? ` ${remaining} min` : ""}` +} + +export function parseStoredSegmentNumber(segmentUrl) { + const cleanPath = String(segmentUrl || "").split(/[?#]/, 1)[0] + const match = cleanPath.match(/--(\d+)\/?$/) + if (!match) return null + const value = Number(match[1]) + return Number.isSafeInteger(value) ? value : null +} + +export function getSegmentStatus(segmentUrls, playbackIndex) { + if (!Array.isArray(segmentUrls) || !Number.isInteger(playbackIndex) || playbackIndex < 0 || playbackIndex >= segmentUrls.length) return "" + const segmentNumber = parseStoredSegmentNumber(segmentUrls[playbackIndex]) + if (segmentNumber == null) return "" + return `Segment ${segmentNumber} · ${playbackIndex + 1} of ${segmentUrls.length}` +} + +export function cameraVideoUrl(segmentUrl, camera) { + const separator = String(segmentUrl).includes("?") ? "&" : "?" + return `${segmentUrl}${separator}camera=${encodeURIComponent(camera)}` +} diff --git a/starpilot/system/the_galaxy/assets/js/utils.js b/starpilot/system/the_galaxy/assets/js/utils.js index b0303bee3..b00d24aac 100644 --- a/starpilot/system/the_galaxy/assets/js/utils.js +++ b/starpilot/system/the_galaxy/assets/js/utils.js @@ -37,6 +37,20 @@ export function parseErrorLogToDate(filename) { return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`) } +/** + * Escape a value for interpolation into an HTML string + * @param {unknown} value + * @returns {string} + */ +export function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + /** * Capitalize the first character of a string * @param {string} str diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index 95a94a738..2f50f7ab8 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -59,6 +59,8 @@ sys.modules.setdefault("openpilot.starpilot.assets.theme_manager", theme_manager import utilities +_REAL_COMMON_PARAMS_MODULE = sys.modules.get("openpilot.common.params") + for _module_name, _module in _INITIAL_MODULES.items(): if _module is None: sys.modules.pop(_module_name, None) @@ -86,6 +88,8 @@ def _simple_module(name, **attrs): def _install_server_import_stubs(): + if _REAL_COMMON_PARAMS_MODULE is not None: + sys.modules["openpilot.common.params"] = _REAL_COMMON_PARAMS_MODULE sys.modules["openpilot.system.loggerd.config"] = loggerd_config sys.modules["openpilot.system.loggerd.deleter"] = loggerd_deleter sys.modules["openpilot.system.loggerd.uploader"] = loggerd_uploader @@ -130,6 +134,14 @@ def _install_server_import_stubs(): ) sys.modules["openpilot.common.realtime"] = _simple_module("openpilot.common.realtime", DT_HW=0.01) + sys.modules["openpilot.common.swaglog"] = _simple_module( + "openpilot.common.swaglog", + cloudlog=SimpleNamespace( + error=lambda *args, **kwargs: None, + exception=lambda *args, **kwargs: None, + info=lambda *args, **kwargs: None, + ), + ) sys.modules["openpilot.common.time_helpers"] = _simple_module("openpilot.common.time_helpers", system_time_valid=lambda: True) sys.modules["openpilot.system.hardware"] = _simple_module( "openpilot.system.hardware", @@ -149,6 +161,15 @@ def _install_server_import_stubs(): get_longitudinal_maneuver_support=lambda *args, **kwargs: {}, ) sys.modules["panda"] = _simple_module("panda", Panda=lambda *args, **kwargs: SimpleNamespace(can_send=lambda *send_args, **send_kwargs: None)) + msgq_module = _simple_module("msgq") + msgq_visionipc = _simple_module( + "msgq.visionipc", + VisionIpcClient=lambda *args, **kwargs: SimpleNamespace(connect=lambda *connect_args: False), + VisionStreamType=SimpleNamespace(VISION_STREAM_DRIVER=0), + ) + msgq_module.visionipc = msgq_visionipc + sys.modules["msgq"] = msgq_module + sys.modules["msgq.visionipc"] = msgq_visionipc model_manager.is_builtin_model_key = lambda value: False model_manager.model_key_aliases = lambda value: [value] @@ -337,17 +358,16 @@ class FakeDashboardAnalyzerProcess: def test_route_inventory_counts_segments_without_video_probing(monkeypatch): - segments = [ - SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")), - SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")), - SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")), - SimpleNamespace(route_name=SimpleNamespace(time_str="route-old")), - ] + def segment(time_str, segment_num): + return SimpleNamespace(route_name=SimpleNamespace(time_str=time_str), segment_num=segment_num) + + # route-new has aged out of its first two segments, so it no longer starts at --0. + segments = [segment("route-new", 4), segment("route-new", 2), segment("route-new", 3), segment("route-old", 0)] monkeypatch.setattr(utilities, "get_all_segment_names", lambda _path: segments) - assert utilities.get_routes_with_segment_counts("/tmp/routes") == [ - ("route-old", 1), - ("route-new", 3), + assert utilities.get_routes_with_segment_details("/tmp/routes") == [ + ("route-old", {"segmentCount": 1, "firstSegmentNum": 0}), + ("route-new", {"segmentCount": 3, "firstSegmentNum": 2}), ] diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py new file mode 100644 index 000000000..4c3d9dbac --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -0,0 +1,416 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +import io +from pathlib import Path +import threading +import time + +import pytest + +from test_dashboard_stats import FakeParams, MODULE_DIR, _install_server_import_stubs + + +def _load_server_module(): + import importlib.util + import sys + + _install_server_import_stubs() + spec = importlib.util.spec_from_file_location("dashcam_routes_server", MODULE_DIR / "the_galaxy.py") + module = importlib.util.module_from_spec(spec) + sys.modules["dashcam_routes_server"] = module + spec.loader.exec_module(module) + return module + + +the_galaxy = _load_server_module() +utilities = the_galaxy.utilities +ROUTE_NAME = "0000006a--9f0a7bdf9c" + + +def _make_segment(root, route_name=ROUTE_NAME, segment_num=0): + segment = root / f"{route_name}--{segment_num}" + segment.mkdir(parents=True) + return segment + + +def _make_client(monkeypatch, root): + assert the_galaxy._import_galaxy_web_symbols() + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"]) + monkeypatch.setattr(the_galaxy, "params", FakeParams()) + app = the_galaxy.Flask( + f"dashcam_routes_{time.monotonic_ns()}", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + the_galaxy.setup(app) + return app.test_client() + + +def test_process_route_is_metadata_only_and_retains_fields(monkeypatch, tmp_path): + segment = _make_segment(tmp_path, segment_num=3) + (segment / "qlog.zst").write_bytes(b"log") + (segment / "Morning school run").touch() + started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc) + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at) + monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: True) + monkeypatch.setattr(utilities, "video_to_png", lambda *args: (_ for _ in ()).throw(AssertionError("preview generation must stay lazy"))) + + result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=4, first_segment_num=3) + + assert result == { + "name": ROUTE_NAME, + "png": f"/thumbnails/{ROUTE_NAME}--3/preview.png", + "timestamp": "Morning school run", + "startedAt": "2026-08-26T15:30:00Z", + "isCustomName": True, + "is_preserved": True, + "segmentCount": 4, + "approxDurationSeconds": 240, + } + + +def test_process_route_uses_display_timestamp_without_losing_started_at(monkeypatch, tmp_path): + _make_segment(tmp_path) + started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc) + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at) + + result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=1) + + assert result["timestamp"] == started_at.isoformat() + assert result["startedAt"] == "2026-08-26T15:30:00Z" + assert result["isCustomName"] is False + + +def test_route_scan_deduplicates_using_footage_root_priority(monkeypatch): + first = "/priority/" + second = "/fallback/" + details = { + first: [(ROUTE_NAME, {"segmentCount": 2, "firstSegmentNum": 1})], + second: [ + (ROUTE_NAME, {"segmentCount": 8, "firstSegmentNum": 0}), + ("0000006b--9f0a7bdf9d", {"segmentCount": 1, "firstSegmentNum": 4}), + ], + } + monkeypatch.setattr(utilities, "get_routes_with_segment_details", lambda path: details[path]) + + entries = the_galaxy._route_scan_entries([first, second]) + + assert entries == [ + (first, ROUTE_NAME, 2, 1), + (second, "0000006b--9f0a7bdf9d", 1, 4), + ] + + +def test_route_metadata_stream_batches_eight_with_progress_and_retained_fields(): + entries = [ + ("/routes/", f"{index:08x}--{index:010x}", index + 1, index % 3) + for index in range(18) + ] + + def process(path, name, segment_count, first_segment_num): + return { + "name": name, + "png": f"/thumbnails/{name}--{first_segment_num}/preview.png", + "timestamp": f"Route {segment_count}", + "startedAt": "2026-08-26T15:30:00Z", + "isCustomName": True, + "is_preserved": False, + "segmentCount": segment_count, + "approxDurationSeconds": segment_count * 60, + } + + events = list(the_galaxy._route_metadata_events(entries, "dongle", process)) + + assert events[0] == {"routes": [], "progress": 0, "total": 18, "connectDongleId": "dongle"} + assert [len(event["routes"]) for event in events[1:]] == [8, 8, 2] + assert [event["progress"] for event in events[1:]] == [8, 16, 18] + assert all(event["total"] == 18 for event in events) + results = [route for event in events[1:] for route in event["routes"]] + assert len(results) == 18 + assert all({ + "name", "png", "timestamp", "startedAt", "isCustomName", + "is_preserved", "segmentCount", "approxDurationSeconds", + } <= result.keys() for result in results) + + +def test_route_metadata_stream_cancels_queued_work_when_closed(): + entries = [("/routes/", f"{index:08x}--{index:010x}", 1, 0) for index in range(40)] + release = threading.Event() + started = [] + lock = threading.Lock() + + def process(path, name, segment_count, first_segment_num): + index = int(name.split("--", 1)[0], 16) + with lock: + started.append(index) + if index >= 8: + release.wait(timeout=2) + return {"name": name} + + stream = the_galaxy._route_metadata_events(entries, process_route=process) + next(stream) + batch = next(stream) + assert len(batch["routes"]) == 8 + stream.close() + release.set() + time.sleep(0.1) + + # At most four already-running workers continue; the remaining queue is cancelled. + assert len(started) <= 12 + + +def test_thumbnail_path_validation_is_strict(tmp_path): + _make_segment(tmp_path) + valid = f"{ROUTE_NAME}--0/preview.png" + + assert the_galaxy._resolve_route_thumbnail(valid, [tmp_path]) == tmp_path / f"{ROUTE_NAME}--0" / "preview.png" + for invalid in ( + "../preview.png", + f"{ROUTE_NAME}--0/qcamera.ts", + f"{ROUTE_NAME}--0/subdir/preview.png", + f"{ROUTE_NAME}--nope/preview.png", + f"/{ROUTE_NAME}--0/preview.png", + f"{ROUTE_NAME}--0\\preview.png", + ): + assert the_galaxy._resolve_route_thumbnail(invalid, [tmp_path]) is None + + +def test_thumbnail_path_validation_rejects_symlinks_outside_the_footage_root(tmp_path): + footage_root = tmp_path / "footage" + outside_segment = tmp_path / "outside" + footage_root.mkdir() + outside_segment.mkdir() + (footage_root / f"{ROUTE_NAME}--0").symlink_to(outside_segment, target_is_directory=True) + + assert the_galaxy._resolve_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [footage_root]) is None + + +def test_thumbnail_generation_is_lazy_and_reuses_completed_preview(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + calls = [] + + def generate(source, output): + calls.append((Path(source), Path(output))) + Path(output).write_bytes(b"png") + return True + + monkeypatch.setattr(utilities, "video_to_png", generate) + relative_path = f"{ROUTE_NAME}--0/preview.png" + + first = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) + second = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) + + assert first == second == segment / "preview.png" + assert len(calls) == 1 + assert calls[0][0] == segment / "qcamera.ts" + + +def test_thumbnail_failure_returns_none_and_does_not_cache_partial_file(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + monkeypatch.setattr(utilities, "video_to_png", lambda source, output: False) + + result = the_galaxy._get_or_create_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [tmp_path]) + + assert result is None + assert not (segment / "preview.png").exists() + + +def test_duplicate_thumbnail_requests_share_one_generation_job(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + release = threading.Event() + started = threading.Event() + calls = [] + + def generate(preview_path): + calls.append(preview_path) + started.set() + release.wait(timeout=2) + preview_path.write_bytes(b"png") + return preview_path + + monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate) + relative_path = f"{ROUTE_NAME}--0/preview.png" + with ThreadPoolExecutor(max_workers=2) as callers: + first = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path]) + assert started.wait(timeout=1) + second = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path]) + time.sleep(0.05) + release.set() + assert first.result(timeout=1) == segment / "preview.png" + assert second.result(timeout=1) == segment / "preview.png" + + assert len(calls) == 1 + assert the_galaxy._ROUTE_THUMBNAIL_EXECUTOR._max_workers == 2 + + +def test_timed_out_thumbnail_job_stays_deduplicated_until_completion(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + release = threading.Event() + started = threading.Event() + calls = [] + + def generate(preview_path): + calls.append(preview_path) + started.set() + release.wait(timeout=2) + preview_path.write_bytes(b"png") + return preview_path + + monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate) + monkeypatch.setattr(the_galaxy, "ROUTE_THUMBNAIL_WAIT_SECONDS", 0.01) + relative_path = f"{ROUTE_NAME}--0/preview.png" + preview_key = str((tmp_path / f"{ROUTE_NAME}--0" / "preview.png").resolve()) + + assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None + assert started.is_set() + assert preview_key in the_galaxy._ROUTE_THUMBNAIL_FUTURES + + # A retry while the original job is still running must reuse that job. + assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None + assert len(calls) == 1 + + release.set() + for _ in range(100): + if preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES: + break + time.sleep(0.01) + + assert preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES + assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) == segment / "preview.png" + assert len(calls) == 1 + + +def test_routes_endpoint_uses_sse_no_buffering_headers(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qlog.zst").write_bytes(b"log") + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + client = _make_client(monkeypatch, tmp_path) + + response = client.get("/api/routes") + + assert response.status_code == 200 + assert response.mimetype == "text/event-stream" + assert response.headers["X-Accel-Buffering"] == "no" + assert "no-cache" in response.headers["Cache-Control"] + assert b'"progress": 1' in response.data + assert b'"startedAt": "2026-08-26T00:00:00Z"' in response.data + + +def test_thumbnail_endpoint_sets_cache_headers(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + preview = segment / "preview.png" + preview.write_bytes(b"not-a-real-png-but-send-file-does-not-mind") + client = _make_client(monkeypatch, tmp_path) + + response = client.get(f"/thumbnails/{ROUTE_NAME}--0/preview.png") + + assert response.status_code == 200 + assert response.mimetype == "image/png" + assert response.headers["Cache-Control"] == f"public, max-age={the_galaxy.ROUTE_THUMBNAIL_CACHE_SECONDS}" + assert response.data == preview.read_bytes() + + +def test_rename_and_reset_keep_logs_and_use_both_reset_urls(monkeypatch, tmp_path): + segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3)] + for segment in segments: + (segment / "qlog.zst").write_bytes(b"log") + (segment / "Old_name").touch() + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + client = _make_client(monkeypatch, tmp_path) + + renamed = client.post("/api/routes/rename", json={"old": ROUTE_NAME, "new": "New name"}) + assert renamed.status_code == 200 + assert all((segment / "New_name").exists() for segment in segments) + assert all((segment / "qlog.zst").read_bytes() == b"log" for segment in segments) + + reset = client.post("/api/routes/reset_name", json={"name": ROUTE_NAME}) + assert reset.status_code == 200 + assert reset.get_json()["timestamp"].startswith("2026-08-26") + assert all(not (segment / "New_name").exists() for segment in segments) + assert all((segment / "qlog.zst").exists() for segment in segments) + + # The legacy URL remains available for older clients. + for segment in segments: + (segment / "Another_name").touch() + assert client.post("/api/routes/clear_name", json={"name": ROUTE_NAME}).status_code == 200 + + +def test_preserve_unpreserve_and_delete_route_endpoints(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + client = _make_client(monkeypatch, tmp_path) + attributes = set() + deleted = [] + monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10) + monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes), raising=False) + monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False) + monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.add(name), raising=False) + monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes.discard(name), raising=False) + monkeypatch.setattr(the_galaxy, "delete_file", deleted.append) + + assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert the_galaxy.PRESERVE_ATTR_NAME in attributes + assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert the_galaxy.PRESERVE_ATTR_NAME not in attributes + assert client.delete(f"/api/routes/{ROUTE_NAME}").status_code == 200 + assert deleted == [str(segment)] + + +def test_preserve_follows_the_first_surviving_segment(monkeypatch, tmp_path): + segment = _make_segment(tmp_path, segment_num=3) # --0 and --1 already aged out + client = _make_client(monkeypatch, tmp_path) + attributes = {} + monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10) + monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes.get(str(path), ())), raising=False) + monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False) + monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.setdefault(str(path), set()).add(name), raising=False) + monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes[str(path)].discard(name), raising=False) + + assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert attributes == {str(segment): {the_galaxy.PRESERVE_ATTR_NAME}} + assert utilities.process_route(str(tmp_path) + "/", ROUTE_NAME, 1, 3)["is_preserved"] is True + + assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert attributes[str(segment)] == set() + + +def test_preserve_limit_counts_routes_not_segments(monkeypatch, tmp_path): + for segment_num in (5, 6, 7): + _make_segment(tmp_path, segment_num=segment_num) + client = _make_client(monkeypatch, tmp_path) + monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 1) + monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: [the_galaxy.PRESERVE_ATTR_NAME], raising=False) + monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False) + monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: None, raising=False) + + # Three preserved segments belong to one route, so the cap of 1 is not already spent on it. + assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert client.post("/api/routes/00000099--9f0a7bdf9c/preserve").status_code == 400 + + +def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path): + segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3, 11)] + for segment in segments: + (segment / "fcamera.hevc").write_bytes(b"hevc") + monkeypatch.setattr(utilities, "get_video_duration", lambda path: 60) + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_process_builder", lambda path: io.BytesIO(b"wrapped-video")) + monkeypatch.setattr(utilities, "ffmpeg_concat_segments_to_mp4", lambda paths, cache_key=None: io.BytesIO(b"combined-video")) + client = _make_client(monkeypatch, tmp_path) + + metadata = client.get(f"/api/routes/{ROUTE_NAME}") + assert metadata.status_code == 200 + assert metadata.get_json()["segment_urls"] == [f"/video/{ROUTE_NAME}--{number}" for number in (0, 3, 11)] + + segment_video = client.get(f"/video/{ROUTE_NAME}--3?camera=forward") + assert segment_video.status_code == 200 + assert segment_video.mimetype == "video/mp4" + assert segment_video.data == b"wrapped-video" + + combined_video = client.get(f"/video/{ROUTE_NAME}/combined?camera=forward") + assert combined_video.status_code == 200 + assert combined_video.mimetype == "video/mp4" + assert combined_video.data == b"combined-video" diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py new file mode 100644 index 000000000..d0818191b --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -0,0 +1,186 @@ +"""Covers assets/components/recordings/dashcam_routes_helpers.js. + +The helpers are browser ES modules, so pytest drives them through node rather than +re-implementing the date/sort/grouping rules in Python. Snippets run with helper +exports in scope and return JSON, which keeps every assertion here in pytest. +""" + +import json +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +HELPERS_PATH = Path(__file__).resolve().parent.parent / "assets" / "components" / "recordings" / "dashcam_routes_helpers.js" + +# node infers ESM from `export` syntax in a bare .js file from 22.7 on, so the helpers +# need no package.json and stay a normal asset next to the component that imports them. +MIN_NODE_MAJOR = 23 + +HARNESS = f''' +import * as helpers from {json.dumps(HELPERS_PATH.as_uri())} +const run = new Function(...Object.keys(helpers), process.env.DASHCAM_HELPER_SNIPPET) +process.stdout.write(JSON.stringify(run(...Object.values(helpers)) ?? null)) +''' + +PRELUDE = ''' +const route = (name, startedAt, extra = {}) => normalizeRoute({ + name, + startedAt, + timestamp: startedAt, + segmentCount: 1, + approxDurationSeconds: 60, + is_preserved: false, + ...extra, +}, "en-US") +''' + + +def _node_binary(): + node = shutil.which("node") + if node is None: + pytest.skip("node is not installed") + + version = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30).stdout.strip() + try: + major = int(version.lstrip("v").split(".")[0]) + except ValueError: + pytest.skip(f"could not read node version from {version!r}") + if major < MIN_NODE_MAJOR: + pytest.skip(f"node {version} cannot import a bare .js ES module; need v{MIN_NODE_MAJOR}+") + return node + + +def evaluate(snippet): + """Run a snippet with the helper exports in scope and return its JSON value.""" + node = _node_binary() + # Fixed TZ so "Today"/"Yesterday" grouping does not depend on the developer's clock. + environment = {**os.environ, "TZ": "UTC", "DASHCAM_HELPER_SNIPPET": PRELUDE + snippet} + result = subprocess.run([node, "--input-type=module"], input=HARNESS, env=environment, + capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_helpers_module_is_a_plain_js_asset(): + assert HELPERS_PATH.is_file() + assert not list(HELPERS_PATH.parent.glob("*.mjs")) + + +def test_groups_routes_into_today_yesterday_dates_and_unknown(): + groups = evaluate(''' + const routes = [ + route("today", "2026-08-26T08:00:00Z"), + route("yesterday", "2026-08-25T08:00:00Z"), + route("older", "2026-08-20T08:00:00Z"), + route("unknown", null, { timestamp: null }), + ] + return groupRoutesByDate(routes, new Date("2026-08-26T12:00:00Z"), "en-US") + .map(group => [group.label, group.routes[0].name]) + ''') + + assert groups == [ + ["Today", "today"], + ["Yesterday", "yesterday"], + ["August 20, 2026", "older"], + ["Unknown date", "unknown"], + ] + + +def test_sorts_newest_and_oldest_while_leaving_unknown_dates_last(): + order = evaluate(''' + const routes = [ + route("middle", "2026-08-20T08:00:00Z"), + route("unknown", null, { timestamp: null }), + route("new", "2026-08-26T08:00:00Z"), + route("old", "2026-08-10T08:00:00Z"), + ] + return { + newest: sortRoutes(routes, "newest").map(item => item.name), + oldest: sortRoutes(routes, "oldest").map(item => item.name), + } + ''') + + assert order["newest"] == ["new", "middle", "old", "unknown"] + assert order["oldest"] == ["old", "middle", "new", "unknown"] + + +def test_searches_custom_names_displayed_dates_and_route_ids(): + matches = evaluate(''' + const custom = route("0000006a--9f0a7bdf9c", "2026-08-26T08:00:00Z", { + timestamp: "Morning school run", + isCustomName: true, + }) + return ["school", "August 26", "9f0a7b", "evening"] + .map(searchQuery => buildRouteView([custom], { searchQuery }).matching.length) + ''') + + assert matches == [1, 1, 1, 0] + + +def test_filters_preserved_routes_before_applying_the_render_limit(): + view = evaluate(''' + const routes = Array.from({ length: MAX_RENDERED_ROUTES + 25 }, (_, index) => route( + `route-${index}`, + new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(), + { is_preserved: index % 2 === 0 }, + )) + const all = buildRouteView(routes) + const preserved = buildRouteView(routes, { preservedOnly: true }) + return { + limit: MAX_RENDERED_ROUTES, + all: [all.matching.length, all.visible.length, all.truncated], + preserved: [preserved.matching.length, preserved.visible.length, preserved.truncated], + allPreserved: preserved.visible.every(item => item.is_preserved), + } + ''') + + assert view["limit"] == 250 + assert view["all"] == [275, 250, True] + assert view["preserved"] == [138, 138, False] + assert view["allPreserved"] is True + + +def test_segment_status_uses_stored_sparse_numbers_and_playback_position(): + statuses = evaluate(''' + const segments = [ + "/video/0000006a--9f0a7bdf9c--0", + "/video/0000006a--9f0a7bdf9c--3", + "/video/0000006a--9f0a7bdf9c--11", + ] + return segments.map((_, index) => getSegmentStatus(segments, index)) + ''') + + assert statuses == [ + "Segment 0 · 1 of 3", + "Segment 3 · 2 of 3", + "Segment 11 · 3 of 3", + ] + + +def test_hides_segment_status_when_the_stored_number_is_unsafe(): + results = evaluate(''' + return [ + parseStoredSegmentNumber("/video/route--9007199254740992"), + getSegmentStatus(["/video/not-a-segment"], 0), + getSegmentStatus(undefined, 0), + ] + ''') + + assert results == [None, "", ""] + + +def test_switching_camera_changes_only_the_url_and_not_segment_status(): + result = evaluate(''' + const segments = ["/video/0000006a--9f0a7bdf9c--7"] + const before = getSegmentStatus(segments, 0) + return { + url: cameraVideoUrl(segments[0], "driver"), + unchanged: getSegmentStatus(segments, 0) === before, + } + ''') + + assert result["url"] == "/video/0000006a--9f0a7bdf9c--7?camera=driver" + assert result["unchanged"] is True diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 556efbaec..392d65679 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -1124,7 +1124,11 @@ def _route_log_files(name): for footage_path in FOOTAGE_PATHS: logs = [] - for segment in sorted(utilities.get_segments_in_route(name, footage_path), key=lambda s: int(s.rsplit("--", 1)[1])): + try: + segments = utilities.get_segments_in_route(name, footage_path) + except OSError: + continue + for segment in sorted(segments, key=lambda s: int(s.rsplit("--", 1)[1])): for filename in ROUTE_LOG_CANDIDATES: path = os.path.join(footage_path, segment, filename) if os.path.isfile(path): @@ -1213,6 +1217,143 @@ except TypeError: # Full drive logs, newest format first. comma only accepts qlog/qcamera uploads, so these come off the device directly. ROUTE_LOG_CANDIDATES = ("rlog.zst", "rlog.bz2", "rlog") +ROUTE_METADATA_WORKERS = 4 +ROUTE_METADATA_BATCH_SIZE = 8 +ROUTE_THUMBNAIL_CACHE_SECONDS = 7 * 24 * 60 * 60 +# Browsers only allow a handful of connections per origin, so a request must never +# park on the preview queue: give up and let the card fall back, the job keeps running. +ROUTE_THUMBNAIL_WAIT_SECONDS = 25 +_ROUTE_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="route-thumbnail") +_ROUTE_THUMBNAIL_FUTURES = {} +_ROUTE_THUMBNAIL_LOCK = threading.Lock() + + +def _route_scan_entries(footage_paths): + """Route scan entries in footage-root priority order, deduplicated by route id.""" + entries = [] + seen_names = set() + for footage_path in footage_paths: + try: + route_details = utilities.get_routes_with_segment_details(footage_path) + except OSError: + continue + for name, details in route_details: + if name in seen_names: + continue + seen_names.add(name) + entries.append(( + footage_path, + name, + max(0, int(details.get("segmentCount", 0))), + max(0, int(details.get("firstSegmentNum", 0))), + )) + return entries + + +def _route_metadata_events(entries, connect_dongle_id="", process_route=None): + """Yield SSE payloads while keeping queued metadata work cancellable.""" + route_processor = process_route or utilities.process_route + total = len(entries) + yield {"routes": [], "progress": 0, "total": total, "connectDongleId": connect_dongle_id} + if total == 0: + return + + executor = ThreadPoolExecutor(max_workers=ROUTE_METADATA_WORKERS, thread_name_prefix="route-metadata") + futures = [] + try: + futures = [ + executor.submit(route_processor, path, name, segment_count, first_segment_num) + for path, name, segment_count, first_segment_num in entries + ] + batch = [] + for processed, future in enumerate(as_completed(futures), start=1): + try: + batch.append(future.result()) + except Exception as exception: + print(f"Error processing route: {exception}") + + if len(batch) >= ROUTE_METADATA_BATCH_SIZE or processed == total: + yield {"routes": batch, "progress": processed, "total": total} + batch = [] + finally: + for future in futures: + future.cancel() + executor.shutdown(wait=False, cancel_futures=True) + + +def _route_first_segment_path(name, footage_path): + """Oldest surviving segment of a route. loggerd ages out --0 first, so it is not always --0.""" + try: + segments = utilities.get_segments_in_route(name, footage_path) + except OSError: + return None + return os.path.join(footage_path, segments[0]) if segments else None + + +def _resolve_route_thumbnail(file_path, footage_paths=None): + """Resolve only /preview.png below a configured footage root.""" + parts = Path(str(file_path or "")).parts + if len(parts) != 2 or parts[1] != "preview.png" or not utilities.SEGMENT_RE.fullmatch(parts[0]): + return None + + for footage_path in footage_paths if footage_paths is not None else FOOTAGE_PATHS: + footage_root = Path(footage_path).resolve() + segment_path = (footage_root / parts[0]).resolve() + if segment_path.parent != footage_root or not segment_path.is_dir(): + continue + preview_path = segment_path / "preview.png" + if preview_path.is_symlink(): + continue + if preview_path.exists(): + resolved_preview = preview_path.resolve() + if resolved_preview.parent != segment_path: + continue + return resolved_preview + return preview_path + return None + + +def _generate_route_thumbnail(preview_path): + if preview_path.is_file(): + return preview_path + + for filename in ("qcamera.ts", "fcamera.hevc"): + source_path = preview_path.parent / filename + if source_path.resolve().parent == preview_path.parent and source_path.is_file() and utilities.video_to_png(source_path, preview_path) and preview_path.is_file(): + return preview_path + return None + + +def _remove_route_thumbnail_future(key, future): + with _ROUTE_THUMBNAIL_LOCK: + if _ROUTE_THUMBNAIL_FUTURES.get(key) is future: + _ROUTE_THUMBNAIL_FUTURES.pop(key, None) + + +def _get_or_create_route_thumbnail(file_path, footage_paths=None): + preview_path = _resolve_route_thumbnail(file_path, footage_paths) + if preview_path is None: + return None + if preview_path.is_file(): + return preview_path + + key = str(preview_path) + created = False + with _ROUTE_THUMBNAIL_LOCK: + future = _ROUTE_THUMBNAIL_FUTURES.get(key) + if future is None: + future = _ROUTE_THUMBNAIL_EXECUTOR.submit(_generate_route_thumbnail, preview_path) + _ROUTE_THUMBNAIL_FUTURES[key] = future + created = True + + if created: + future.add_done_callback(lambda completed: _remove_route_thumbnail_future(key, completed)) + + try: + return future.result(timeout=ROUTE_THUMBNAIL_WAIT_SECONDS) + except TimeoutError: + # The completion callback keeps the running job deduplicated, then evicts it when done. + return None class _TarBuffer(io.RawIOBase): @@ -6154,33 +6295,22 @@ def setup(app): @app.route("/api/routes", methods=["GET"]) def list_routes(): def generate(): - routes = [ - (path, name, segment_count) - for path in FOOTAGE_PATHS - for name, segment_count in utilities.get_routes_with_segment_counts(path) - ] - total = len(routes) + routes = _route_scan_entries(FOOTAGE_PATHS) connect_dongle_id = params.get("StockDongleId", encoding="utf-8") or params.get("DongleId", encoding="utf-8") or "" - yield f"data: {json.dumps({'progress': 0, 'total': total, 'connectDongleId': connect_dongle_id})}\n\n" + for payload in _route_metadata_events(routes, connect_dongle_id): + yield f"data: {json.dumps(payload)}\n\n" - with ThreadPoolExecutor(max_workers=10) as executor: - futures = { - executor.submit(utilities.process_route, path, name, segment_count): (path, name) - for path, name, segment_count in routes - } - for processed, future in enumerate(as_completed(futures), start=1): - try: - result = future.result() - yield f"data: {json.dumps({'routes': [result]})}\n\n" - except Exception as exception: - print(f"Error processing route: {exception}") - yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n" - - return Response(generate(), mimetype="text/event-stream") + response = Response(generate(), mimetype="text/event-stream") + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + return response @app.route("/api/routes/", methods=["DELETE"]) def delete_route(name): for footage_path in FOOTAGE_PATHS: + if not os.path.isdir(footage_path): + continue for segment in os.listdir(footage_path): if segment.startswith(name): delete_file(os.path.join(footage_path, segment)) @@ -6226,21 +6356,21 @@ def setup(app): @app.route("/api/routes//preserve", methods=["POST"]) def preserve_route(name): - preserved_routes = 0 + preserved_routes = set() for footage_path in FOOTAGE_PATHS: + if not os.path.isdir(footage_path): + continue for segment in os.listdir(footage_path): - if segment.endswith("--0"): - segment_path = os.path.join(footage_path, segment) - if PRESERVE_ATTR_NAME in os.listxattr(segment_path) and os.getxattr(segment_path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE: - preserved_routes += 1 + if utilities.SEGMENT_RE.fullmatch(segment) and utilities.has_preserve_attr(os.path.join(footage_path, segment)): + preserved_routes.add(segment.rsplit("--", 1)[0]) - if preserved_routes >= PRESERVE_COUNT: + if name not in preserved_routes and len(preserved_routes) >= PRESERVE_COUNT: return {"error": f"Maximum of {PRESERVE_COUNT} preserved routes reached..."}, 400 for footage_path in FOOTAGE_PATHS: - route_path = os.path.join(footage_path, f"{name}--0") - if os.path.exists(route_path): - os.setxattr(route_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE) + segment_path = _route_first_segment_path(name, footage_path) + if segment_path is not None: + os.setxattr(segment_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE) return {"message": "Route preserved!!"}, 200 return {"error": "Route not found"}, 404 @@ -6248,9 +6378,9 @@ def setup(app): @app.route("/api/routes//preserve", methods=["DELETE"]) def un_preserve_route(name): for footage_path in FOOTAGE_PATHS: - route_path = os.path.join(footage_path, f"{name}--0") - if PRESERVE_ATTR_NAME in os.listxattr(route_path): - os.removexattr(route_path, PRESERVE_ATTR_NAME) + segment_path = _route_first_segment_path(name, footage_path) + if segment_path is not None and utilities.has_preserve_attr(segment_path): + os.removexattr(segment_path, PRESERVE_ATTR_NAME) return {"message": "Route unpreserved!"}, 200 return {"error": "Route not found"}, 404 @@ -6258,7 +6388,10 @@ def setup(app): def get_combined_route_video(name): camera = request.args.get("camera", "forward") for footage_path in FOOTAGE_PATHS: - segments = utilities.get_segments_in_route(name, footage_path) + try: + segments = utilities.get_segments_in_route(name, footage_path) + except OSError: + continue if segments: cam_file = { "forward": "fcamera.hevc", @@ -6282,15 +6415,20 @@ def setup(app): @app.route("/api/routes/", methods=["GET"]) def get_route(name): + if not utilities.ROUTE_RE.fullmatch(name or ""): + return {"error": "Invalid route name"}, 400 for footage_path in FOOTAGE_PATHS: - base_path = f"{footage_path}{name}--0" - if os.path.exists(base_path): + try: segments = utilities.get_segments_in_route(name, footage_path) - if not segments: - break - + except OSError: + continue + if segments: + base_path = os.path.join(footage_path, segments[0]) segment_urls = [f"/video/{segment}" for segment in segments] - total_duration = sum(utilities.get_video_duration(f"{footage_path}{name}--{i}/fcamera.hevc") for i in range(len(segment_urls))) + total_duration = sum( + utilities.get_video_duration(os.path.join(footage_path, segment, "fcamera.hevc")) + for segment in segments + ) return { "name": name, "segment_urls": segment_urls, @@ -6355,6 +6493,7 @@ def setup(app): return response @app.route("/api/routes/clear_name", methods=["POST"]) + @app.route("/api/routes/reset_name", methods=["POST"]) def clear_route_name(): data = request.get_json() route_name = data.get("name") @@ -6375,7 +6514,7 @@ def setup(app): for segment in segments_to_process: segment_dir = os.path.join(footage_path, segment) for item in os.listdir(segment_dir): - if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in utilities.LOG_CANDIDATES: + if utilities.is_route_marker_file(item): try: os.remove(os.path.join(segment_dir, item)) cleared = True @@ -6414,7 +6553,7 @@ def setup(app): for segment in segments_to_process: segment_dir = os.path.join(footage_path, segment) for item in os.listdir(segment_dir): - if not item.endswith((".hevc", ".ts", ".png", ".gif", "rlog")): + if utilities.is_route_marker_file(item): try: os.remove(os.path.join(segment_dir, item)) except OSError: @@ -8768,10 +8907,18 @@ def setup(app): @app.route("/thumbnails/", methods=["GET"]) def get_thumbnail(file_path): - for footage_path in FOOTAGE_PATHS: - if os.path.exists(os.path.join(footage_path, file_path)): - return send_from_directory(footage_path, file_path, as_attachment=True) - return {"error": "Thumbnail not found"}, 404 + preview_path = _get_or_create_route_thumbnail(file_path) + if preview_path is None: + return {"error": "Thumbnail not found"}, 404 + + response = send_file( + preview_path, + mimetype="image/png", + conditional=True, + max_age=ROUTE_THUMBNAIL_CACHE_SECONDS, + ) + response.headers["Cache-Control"] = f"public, max-age={ROUTE_THUMBNAIL_CACHE_SECONDS}" + return response @app.route("/video/", methods=["GET"]) def get_video(path): diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py index db0a97d0f..4bee69c82 100644 --- a/starpilot/system/the_galaxy/utilities.py +++ b/starpilot/system/the_galaxy/utilities.py @@ -15,7 +15,7 @@ import sys import threading import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import List from urllib.parse import quote @@ -2993,19 +2993,23 @@ def get_routes_names(footage_path): route_times = {segment.route_name.time_str for segment in segments} return sorted(route_times, reverse=True) -def get_routes_with_segment_counts(footage_path): - route_counts = {} +def get_routes_with_segment_details(footage_path): + route_details = {} for segment in get_all_segment_names(footage_path): route_name = segment.route_name.time_str - route_counts[route_name] = route_counts.get(route_name, 0) + 1 - return sorted(route_counts.items(), reverse=True) + segment_num = int(getattr(segment, "segment_num", 0)) + details = route_details.setdefault(route_name, {"segmentCount": 0, "firstSegmentNum": segment_num}) + details["segmentCount"] += 1 + details["firstSegmentNum"] = min(details["firstSegmentNum"], segment_num) + return sorted(route_details.items(), reverse=True) def get_segments_in_route(route_time_str, footage_path): - return [ + segments = [ f"{segment.time_str}--{segment.segment_num}" for segment in get_all_segment_names(footage_path) if segment.time_str == route_time_str ] + return sorted(segments, key=lambda segment: int(segment.rsplit("--", 1)[1])) def get_video_duration(input_path): try: @@ -3018,7 +3022,10 @@ def get_video_duration(input_path): return 60 def has_preserve_attr(path: str): - return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE + try: + return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE + except (AttributeError, OSError): + return False def list_file(path): return sorted(os.listdir(path), reverse=True) @@ -3035,30 +3042,35 @@ def normalize_theme_name(name, for_path=False): return f"{normalized_parts[0]} ({' '.join(normalized_parts[1:])})".replace(" Week", "") return ' '.join(normalized_parts).replace(" Week", "") -def process_route(footage_path, route_name, segment_count=0): - segment_path = f"{footage_path}{route_name}--0" - qcamera_path = f"{segment_path}/qcamera.ts" +def is_route_marker_file(filename): + """A renamed route stores its display name as an empty marker file in the segment.""" + return not filename.endswith((".hevc", ".ts", ".png", ".gif")) and filename not in LOG_CANDIDATES - png_output_path = os.path.join(segment_path, "preview.png") - if not os.path.exists(png_output_path): - video_to_png(qcamera_path, png_output_path) +def _utc_rfc3339(value): + if value is None: + return None + # Naive values come off the filesystem in local time; astimezone reads them that way. + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") +def process_route(footage_path, route_name, segment_count=0, first_segment_num=0): + segment_name = f"{route_name}--{max(0, int(first_segment_num))}" + segment_path = os.path.join(footage_path, segment_name) custom_name = None if os.path.isdir(segment_path): for item in os.listdir(segment_path): - if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in LOG_CANDIDATES: + if is_route_marker_file(item): custom_name = item break - route_timestamp_str = custom_name - if not custom_name: - route_timestamp_dt = get_route_start_time(segment_path) - route_timestamp_str = route_timestamp_dt.isoformat() if route_timestamp_dt else None + route_timestamp_dt = get_route_start_time(segment_path) + route_timestamp_str = custom_name or (route_timestamp_dt.isoformat() if route_timestamp_dt else None) return { "name": route_name, - "png": f"/thumbnails/{route_name}--0/preview.png", + "png": f"/thumbnails/{segment_name}/preview.png", "timestamp": route_timestamp_str, + "startedAt": _utc_rfc3339(route_timestamp_dt), + "isCustomName": custom_name is not None, "is_preserved": has_preserve_attr(segment_path), "segmentCount": max(0, int(segment_count)), "approxDurationSeconds": max(0, int(segment_count)) * 60, @@ -3088,6 +3100,8 @@ def segment_to_segment_name(data_dir, segment): full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}") return SegmentName(full_path) +VIDEO_TO_PNG_TIMEOUT_SECONDS = 20 + def video_to_png(input_path, output_path): try: subprocess.run([ @@ -3097,11 +3111,17 @@ def video_to_png(input_path, output_path): "-frames:v", "1", "-y", str(output_path) - ], capture_output=True, check=True, text=True) - except subprocess.CalledProcessError as e: + ], capture_output=True, check=True, text=True, timeout=VIDEO_TO_PNG_TIMEOUT_SECONDS) + return os.path.isfile(output_path) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: print(f"Failed to generate PNG for {input_path}") - if e.stderr: + if getattr(e, "stderr", None): print(e.stderr) + try: + Path(output_path).unlink(missing_ok=True) + except OSError: + pass + return False def xor_encrypt_decrypt(data, key): return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data)) From e3bfb66ded91e946c743a0925d5ac9877117ca32 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:43:35 -0700 Subject: [PATCH 06/24] Update Layout --- .../components/recordings/dashcam_routes.css | 637 +++++++++++++++--- .../components/recordings/dashcam_routes.js | 192 ++++-- .../recordings/dashcam_routes_helpers.js | 48 ++ 3 files changed, 759 insertions(+), 118 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 4286d1e6b..3d3186b02 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -445,7 +445,15 @@ .dashcam-library-header { justify-content: space-between; + align-items: flex-start; gap: var(--gap-md); + flex-wrap: wrap; +} + +.dashcam-header-info { + display: flex; + flex-direction: column; + gap: 0.25rem; } .dashcam-library-header h1, @@ -471,89 +479,230 @@ text-transform: uppercase; } -.dashcam-refresh-button, -.dashcam-preserved-filter, -.dashcam-sort, -.dashcam-search { +/* Stats Chips in Header */ +.dashcam-stats-bar { + display: flex; + flex-wrap: wrap; + gap: var(--gap-sm); + margin-top: 0.4rem; +} + +.dashcam-stat-chip { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-xl); + color: var(--text-muted); + display: inline-flex; + font-size: var(--font-size-xs); + gap: 0.35rem; + padding: 0.3rem 0.75rem; +} + +.dashcam-stat-chip strong { + color: var(--text-color); + font-weight: var(--font-weight-demi-bold); +} + +.dashcam-stat-chip i { + color: var(--main-fg); + font-size: 0.85rem; +} + +.dashcam-stat-chip.stat-chip-preserved i { + color: #ef6078; +} + +.dashcam-header-controls { + display: flex; + align-items: center; + gap: var(--gap-sm); +} + +.dashcam-refresh-button { background: var(--input-bg); border: var(--border-width-thin) solid var(--sidebar-border-color); border-radius: var(--border-radius-md); color: var(--text-color); -} - -.dashcam-refresh-button, -.dashcam-preserved-filter { cursor: pointer; - font-size: var(--font-size-base); + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: var(--font-size-sm); font-weight: var(--font-weight-demi-bold); - padding: 0.7rem 1rem; + padding: 0.6rem 0.95rem; + transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); } -.dashcam-refresh-button:disabled, -.dashcam-player-actions button:disabled { +.dashcam-refresh-button:hover { + background: var(--sidebar-active-bg); + border-color: var(--main-fg); + transform: translateY(-1px); +} + +.dashcam-refresh-button:disabled { cursor: not-allowed; opacity: var(--disabled-opacity); } +/* Modern Toolbar */ .dashcam-toolbar { flex-wrap: wrap; gap: var(--gap-sm); margin-top: var(--margin-lg); } -.dashcam-search { +.dashcam-search-box { align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); display: flex; - flex: 1 1 22rem; + flex: 1 1 18rem; gap: var(--gap-sm); - padding: 0 0.9rem; + padding: 0 0.8rem; + position: relative; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } -.dashcam-search input, -.dashcam-sort select { +.dashcam-search-box:focus-within { + border-color: var(--main-fg); + box-shadow: 0 0 0 2px rgba(139, 108, 197, 0.2); +} + +.dashcam-search-icon { + color: var(--text-muted); + font-size: 0.95rem; +} + +.dashcam-search-box input { background: transparent; border: 0; color: var(--text-color); font: inherit; - outline: 0; -} - -.dashcam-search input { + font-size: var(--font-size-sm); min-width: 0; - padding: 0.75rem 0; + outline: 0; + padding: 0.65rem 0; width: 100%; } +.dashcam-search-clear { + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + border-radius: 50%; + font-size: 1.1rem; + line-height: 1; + transition: color var(--transition-fast), background-color var(--transition-fast); +} + +.dashcam-search-clear:hover { + color: var(--text-color); + background: var(--sidebar-active-bg); +} + +.dashcam-filter-group { + display: inline-flex; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + padding: 2px; + gap: 2px; +} + +.dashcam-filter-pill { + background: transparent; + border: 0; + border-radius: calc(var(--border-radius-md) - 2px); + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + padding: 0.5rem 0.85rem; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.dashcam-filter-pill.active { + background: var(--sidebar-active-bg); + color: var(--text-color); + box-shadow: inset 0 0 0 1px var(--main-fg); +} + +.dashcam-filter-pill .bi-heart-fill { + color: #ef6078; + margin-right: 0.25rem; +} + .dashcam-sort { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + display: flex; gap: var(--gap-sm); - padding: 0.65rem 0.85rem; + padding: 0.5rem 0.85rem; } .dashcam-sort span { color: var(--text-muted); - font-size: var(--font-size-sm); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; } .dashcam-sort select { + background: transparent; + border: 0; + color: var(--text-color); cursor: pointer; + font: inherit; + font-size: var(--font-size-sm); + outline: 0; } -.dashcam-preserved-filter[aria-pressed="true"] { - background: rgba(230, 78, 102, 0.16); - border-color: rgba(230, 78, 102, 0.65); +.dashcam-view-toggle { + display: inline-flex; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + padding: 2px; + gap: 2px; } -.dashcam-preserved-filter .bi-heart-fill { - color: #ef6078; - margin-right: 0.35rem; +.dashcam-view-btn { + background: transparent; + border: 0; + border-radius: calc(var(--border-radius-md) - 2px); + color: var(--text-muted); + cursor: pointer; + font-size: 1.1rem; + padding: 0.4rem 0.65rem; + display: flex; + align-items: center; + justify-content: center; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.dashcam-view-btn.active { + background: var(--sidebar-active-bg); + color: var(--main-fg); + box-shadow: inset 0 0 0 1px var(--main-fg); } .dashcam-results-summary { color: var(--text-muted); - font-size: var(--font-size-sm); + font-size: var(--font-size-xs); justify-content: space-between; margin-top: var(--margin-base); min-height: 1.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; } .dashcam-date-groups, @@ -565,51 +714,332 @@ margin-top: var(--margin-lg); } -.dashcam-date-group > h2 { +.dashcam-date-group-header { + display: flex; + align-items: center; + justify-content: space-between; border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); - color: var(--text-color); - font-size: var(--font-size-lg); - margin: 0; - padding-bottom: var(--padding-sm); + padding-bottom: var(--padding-xs); + margin-bottom: var(--margin-sm); } +.dashcam-date-group-header h2 { + color: var(--text-color); + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + margin: 0; +} + +.dashcam-date-group-count { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +/* ============================================================ + Modernized List View (Data & Telemetry Focused) + ============================================================ */ +.dashcam-routes-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.dashcam-route-row { + align-items: center; + background: var(--card-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-xs); + cursor: pointer; + display: flex; + gap: var(--gap-md); + padding: 0.75rem 1rem; + position: relative; + transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.dashcam-route-row:hover { + background: rgba(18, 18, 36, 0.95); + border-color: rgba(139, 108, 197, 0.4); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.dashcam-route-row.is-preserved { + border-left: 3px solid #ef6078; +} + +/* Compact Mini Preview (Non-dominant) */ +.dashcam-mini-preview { + aspect-ratio: 16 / 9; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-sm); + flex-shrink: 0; + height: 52px; + overflow: hidden; + position: relative; + width: 92px; +} + +.dashcam-mini-fallback { + align-items: center; + color: var(--text-muted); + display: flex; + inset: 0; + justify-content: center; + position: absolute; + font-size: 1.25rem; +} + +.dashcam-mini-preview:not(.thumbnail-failed) .dashcam-mini-fallback { + visibility: hidden; +} + +.dashcam-mini-img { + background: var(--sidebar-bg); + height: 100%; + object-fit: cover; + position: absolute; + width: 100%; + z-index: 1; +} + +.dashcam-mini-play-overlay { + align-items: center; + background: rgba(10, 10, 22, 0.6); + color: white; + display: flex; + font-size: 1.2rem; + inset: 0; + justify-content: center; + opacity: 0; + position: absolute; + transition: opacity var(--transition-fast); + z-index: 2; +} + +.dashcam-route-row:hover .dashcam-mini-play-overlay { + opacity: 1; +} + +/* Route Info & Telemetry */ +.dashcam-route-info { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.dashcam-route-title-row { + align-items: center; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.dashcam-route-title { + color: var(--text-color); + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); + line-height: 1.3; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashcam-custom-badge { + align-items: center; + background: rgba(139, 108, 197, 0.15); + border: 1px solid rgba(139, 108, 197, 0.35); + border-radius: var(--border-radius-sm); + color: var(--main-fg); + display: inline-flex; + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + gap: 0.25rem; + padding: 0.1rem 0.4rem; + text-transform: uppercase; + flex-shrink: 0; +} + +.dashcam-route-subdate { + color: var(--text-muted); + font-size: var(--font-size-xs); + margin: 0; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.dashcam-route-meta-pills { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.15rem; +} + +.meta-pill { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-sm); + color: var(--text-muted); + display: inline-flex; + font-size: var(--font-size-xs); + gap: 0.3rem; + padding: 0.15rem 0.5rem; + white-space: nowrap; +} + +.meta-pill.duration-pill { + color: var(--text-color); +} + +.meta-pill.duration-pill i { + color: var(--main-fg); +} + +.meta-pill.segments-pill i { + color: var(--text-muted); +} + +.meta-pill.preserved-pill { + background: rgba(239, 96, 120, 0.12); + border-color: rgba(239, 96, 120, 0.35); + color: #ef6078; +} + +.meta-pill.id-pill { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.02em; + opacity: 0.85; +} + +/* Route Action Buttons */ +.dashcam-route-actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 0.4rem; +} + +.btn-route-action { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: inline-flex; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + gap: 0.35rem; + justify-content: center; + padding: 0.45rem 0.75rem; + transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.btn-route-action:hover { + background: var(--sidebar-active-bg); + border-color: var(--main-fg); + transform: translateY(-1px); +} + +.btn-route-action.btn-play { + background: var(--main-fg); + border-color: var(--main-fg); + color: var(--text-on-primary); +} + +.btn-route-action.btn-play:hover { + filter: brightness(1.12); +} + +.btn-route-action.btn-preserve { + padding: 0.45rem 0.6rem; + color: var(--text-muted); +} + +.btn-route-action.btn-preserve.active { + background: rgba(239, 96, 120, 0.15); + border-color: rgba(239, 96, 120, 0.45); + color: #ef6078; +} + +.btn-route-action.btn-preserve:hover { + color: #ef6078; +} + +.btn-route-action.btn-icon { + color: var(--text-muted); + padding: 0.45rem 0.6rem; +} + +.btn-route-action.btn-icon:hover { + color: var(--text-color); +} + +.btn-route-action.btn-danger-action:hover { + background: rgba(224, 85, 119, 0.15); + border-color: var(--danger-bg); + color: var(--danger-bg); +} + +/* ============================================================ + Compact Grid View Mode + ============================================================ */ .dashcam-library .screen-recordings-grid.dashcam-routes-grid { - gap: clamp(0.8rem, 1.5vw, 1.25rem); - grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); - margin-top: var(--margin-base); + gap: clamp(0.8rem, 1.5vw, 1.1rem); + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); + margin-top: var(--margin-sm); } .dashcam-library .recording-card.dashcam-route-card { + background: var(--card-bg); border: var(--border-width-thin) solid var(--sidebar-border-color); - border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-sm); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-xs); + display: flex; + flex-direction: column; overflow: hidden; position: relative; text-align: left; + transition: border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); } .dashcam-library .recording-card.dashcam-route-card:hover { - box-shadow: var(--shadow-md); + border-color: rgba(139, 108, 197, 0.45); + box-shadow: var(--shadow-sm); transform: translateY(-2px); } -.dashcam-route-card .preserved-icon { +.dashcam-card-top-bar { align-items: center; - background: rgba(10, 12, 18, 0.7); - border: 0; - border-radius: 50%; - color: white; + background: var(--secondary-bg); + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); display: flex; - height: 2.6rem; - justify-content: center; - right: 0.7rem; - top: 0.7rem; - width: 2.6rem; + justify-content: space-between; + padding: 0.35rem 0.6rem; +} + +.dashcam-card-top-bar .btn-preserve { + background: transparent; + border: 0; + padding: 0.15rem 0.3rem; } .dashcam-route-card .dashcam-preview { + aspect-ratio: 16 / 9; background: linear-gradient(135deg, var(--sidebar-bg), var(--input-bg)); + max-height: 120px; overflow: hidden; + position: relative; + width: 100%; } .dashcam-preview-fallback { @@ -624,7 +1054,7 @@ } .dashcam-preview-fallback i { - font-size: 2rem; + font-size: 1.6rem; } .dashcam-preview:not(.thumbnail-failed) .dashcam-preview-fallback { @@ -633,16 +1063,42 @@ .dashcam-preview img { background: var(--sidebar-bg); + height: 100%; + object-fit: cover; + width: 100%; z-index: 1; } +.dashcam-grid-play-overlay { + align-items: center; + background: rgba(10, 10, 22, 0.55); + color: white; + display: flex; + font-size: 1.8rem; + inset: 0; + justify-content: center; + opacity: 0; + position: absolute; + transition: opacity var(--transition-fast); + z-index: 2; +} + +.dashcam-route-card:hover .dashcam-grid-play-overlay { + opacity: 1; +} + .dashcam-card-body { - padding: var(--padding-base); + display: flex; + flex: 1; + flex-direction: column; + gap: 0.4rem; + padding: 0.75rem; } .dashcam-card-body h3 { color: var(--text-color); - font-size: var(--font-size-lg); + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); line-height: 1.3; margin: 0; overflow: hidden; @@ -650,34 +1106,26 @@ white-space: nowrap; } -.dashcam-card-date, -.dashcam-card-details, -.dashcam-preserve-status { +.dashcam-card-date { color: var(--text-muted); - font-size: var(--font-size-sm); - margin: var(--margin-xs) 0 0; -} - -.dashcam-card-details { - display: flex; - gap: var(--gap-sm); -} - -.dashcam-card-details span + span::before { - content: "·"; - margin-right: var(--gap-sm); -} - -.dashcam-preserve-status { font-size: var(--font-size-xs); - font-weight: var(--font-weight-demi-bold); - text-transform: uppercase; + margin: 0; } -.dashcam-preserve-status.preserved { - color: #ef6078; +.dashcam-card-actions { + align-items: center; + border-top: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; + gap: 0.3rem; + margin-top: auto; + padding-top: 0.5rem; } +.dashcam-card-actions .btn-play { + flex: 1; +} + +/* Empty & Loading States */ .dashcam-loading, .dashcam-empty-state { align-items: center; @@ -840,6 +1288,7 @@ margin-left: auto; } +/* Responsive Adaptations */ @media only screen and (max-width: 768px) { .screen-recordings-wrapper.dashcam-routes-wrapper { padding: 0 var(--padding-sm) var(--padding-lg); @@ -856,9 +1305,19 @@ flex-direction: column; } - .dashcam-refresh-button, - .dashcam-preserved-filter, - .dashcam-sort { + .dashcam-toolbar { + flex-direction: column; + align-items: stretch; + } + + .dashcam-search-box { + flex: 1 1 auto; + width: 100%; + } + + .dashcam-filter-group, + .dashcam-sort, + .dashcam-view-toggle { justify-content: center; } @@ -867,6 +1326,24 @@ flex: 1; } + .dashcam-route-row { + flex-direction: column; + align-items: stretch; + gap: var(--gap-sm); + } + + .dashcam-mini-preview { + width: 100%; + height: auto; + max-height: 140px; + } + + .dashcam-route-actions { + border-top: var(--border-width-thin) solid var(--sidebar-border-color); + padding-top: var(--padding-xs); + justify-content: space-between; + } + .dashcam-library .screen-recordings-grid.dashcam-routes-grid { grid-template-columns: 1fr; } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index d60554c6a..652ef4459 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -4,6 +4,7 @@ import { Modal } from "/assets/components/modal.js" import { buildRouteView, cameraVideoUrl, + computeRouteStats, formatApproxDuration, getSegmentStatus, groupRoutesByDate, @@ -19,6 +20,7 @@ const state = reactive({ searchQuery: "", sortOrder: "newest", showPreservedOnly: false, + viewMode: "list", progress: 0, total: 0, showDeleteAllModal: false, @@ -29,6 +31,7 @@ let routesAbortController = null let routesRequestToken = 0 let seenRouteNames = new Set() let overlay = null +const routeLogsCache = new Map() function routeLabel(route) { return route.displayName || route.displayDate || route.name @@ -218,7 +221,7 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) { const closeLogsDialog = () => { document.removeEventListener("keydown", handleKeydown) closeDialog(logsDialog) - logsButton.focus() + logsButton?.focus() } const handleKeydown = event => { if (event.key === "Escape") closeLogsDialog() } closeButton.onclick = closeLogsDialog @@ -240,7 +243,7 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) { ` } - const cachedLogs = getCachedLogs() + const cachedLogs = getCachedLogs?.() if (cachedLogs) { renderLogs(cachedLogs) return @@ -253,7 +256,7 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) { if (content.isConnected) content.innerHTML = `

${escapeHtml(data.error || "Could not read logs.")}

` return } - setCachedLogs(data) + setCachedLogs?.(data) if (content.isConnected) renderLogs(data) }) .catch(error => { @@ -261,6 +264,16 @@ function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) { }) } +function openLogsFromRow(route, event) { + const button = event.currentTarget + openLogsDialog( + route, + button, + () => routeLogsCache.get(route.name) || null, + value => { routeLogsCache.set(route.name, value) }, + ) +} + async function openOverlay(route) { if (overlay) return overlay = document.createElement("div") @@ -369,7 +382,6 @@ async function openOverlay(route) { setPlayerMessage("Switching camera…") video.src = cameraVideoUrl(segments[current], selectedCamera) video.load() - // Switching cameras deliberately leaves current and the status strip unchanged. }) } @@ -406,7 +418,7 @@ function closeOverlay() { } async function togglePreserved(route, event) { - event.stopPropagation() + event?.stopPropagation?.() const isPreserved = !route.is_preserved try { const response = await fetch(`/api/routes/${route.name}/preserve`, { method: isPreserved ? "POST" : "DELETE" }) @@ -456,35 +468,71 @@ export function RouteRecordings() { return html`
-
-

Local recordings

Dashcam Routes

- -
+ ${() => { + const stats = computeRouteStats(state.routes) + return html` +
+
+

Local recordings

+

Dashcam Routes

+
+ ${stats.count} ${stats.count === 1 ? "drive" : "drives"} + ${stats.formattedDuration} total + ${stats.preservedCount > 0 ? html` ${stats.preservedCount} preserved` : ""} +
+
+
+ +
+
` + }}
- + +
+ + +
- +
+ + +
${() => { const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder }) const groups = groupRoutesByDate(view.visible) + const isGrid = state.viewMode === "grid" + return html`
- ${view.matching.length} matching route${view.matching.length === 1 ? "" : "s"} - ${state.loading ? html`Loading ${state.progress} of ${state.total}` : html`${state.routes.length} total`} + ${view.matching.length} matching drive${view.matching.length === 1 ? "" : "s"} + ${state.loading ? html`Loading ${state.progress} of ${state.total}` : html`${state.routes.length} total local`}
${state.error ? html`

${state.error}

` : ""} ${state.isDeletingAll ? html`

Deleting routes…

` : ""} @@ -493,25 +541,93 @@ export function RouteRecordings() {
${groups.map(group => html`
-

${group.label}

-
- ${group.routes.map(route => html` -
- -
- Preview unavailable - -
-
-

${route.displayName}

- ${route.isCustomName ? html`

${route.displayDate}

` : ""} -

${formatApproxDuration(route.approxDurationSeconds)}${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"}

-

${route.is_preserved ? "Preserved" : "Not preserved"}

-
-
`)} +
+

${group.label}

+ ${group.routes.length} ${group.routes.length === 1 ? "drive" : "drives"}
+ ${isGrid ? html` +
+ ${group.routes.map(route => html` +
+
+ + ${route.name.split("--").slice(1).join("--") || route.name} +
+
+ Preview unavailable + + +
+
+
+

${route.displayName}

+ ${route.isCustomName ? html`` : ""} +
+ ${route.isCustomName ? html`

${route.displayDate}

` : ""} +
+ ${formatApproxDuration(route.approxDurationSeconds)} + ${route.segmentCount} seg +
+
+ + + + +
+
+
`)} +
+ ` : html` +
+ ${group.routes.map(route => html` +
+
+ + + +
+
+
+

${route.displayName}

+ ${route.isCustomName ? html` Custom` : ""} +
+ ${route.isCustomName ? html`

${route.displayDate}

` : ""} +
+ ${formatApproxDuration(route.approxDurationSeconds)} + ${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"} + ${route.is_preserved ? html` Preserved` : ""} + ${route.name.split("--").slice(1).join("--") || route.name} +
+
+
+ + + + + +
+
`)} +
`}
`)}
${view.truncated ? html`

Showing the first ${MAX_RENDERED_ROUTES} of ${view.matching.length} matching routes.

` : ""}` diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index 41372c48a..eb251b11e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -34,7 +34,55 @@ export function normalizeRoute(route, locale) { } } +export function formatTotalDuration(seconds) { + const totalMinutes = Math.max(0, Math.round(Number(seconds) / 60) || 0) + if (totalMinutes < 1) return "0 min" + if (totalMinutes < 60) return `${totalMinutes} min` + const hours = Math.floor(totalMinutes / 60) + const remaining = totalMinutes % 60 + return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h` +} + +export function computeRouteStats(routes = []) { + const list = Array.isArray(routes) ? routes : [] + let totalDurationSeconds = 0 + let preservedCount = 0 + let totalSegments = 0 + + for (const route of list) { + if (route) { + if (Number.isFinite(route.approxDurationSeconds)) { + totalDurationSeconds += Math.max(0, route.approxDurationSeconds) + } + if (route.is_preserved) { + preservedCount += 1 + } + if (Number.isFinite(route.segmentCount)) { + totalSegments += Math.max(0, route.segmentCount) + } + } + } + + return { + count: list.length, + totalDurationSeconds, + formattedDuration: formatTotalDuration(totalDurationSeconds), + preservedCount, + totalSegments, + } +} + export function sortRoutes(routes, sortOrder = "newest") { + if (sortOrder === "longest" || sortOrder === "shortest") { + const direction = sortOrder === "longest" ? -1 : 1 + return [...routes].sort((left, right) => { + const leftDur = Number.isFinite(left?.approxDurationSeconds) ? left.approxDurationSeconds : -1 + const rightDur = Number.isFinite(right?.approxDurationSeconds) ? right.approxDurationSeconds : -1 + if (leftDur !== rightDur) return (leftDur - rightDur) * direction + return String(left?.name || "").localeCompare(String(right?.name || "")) + }) + } + const direction = sortOrder === "oldest" ? 1 : -1 return [...routes].sort((left, right) => { const leftTime = left?._startedAtMs From b619371441eb3a0577414db05fb0c7fd25580f8d Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:56:58 -0700 Subject: [PATCH 07/24] Bug Fix --- .../components/recordings/dashcam_routes.js | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 652ef4459..42abb0020 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -468,26 +468,21 @@ export function RouteRecordings() { return html`
- ${() => { - const stats = computeRouteStats(state.routes) - return html` -
-
-

Local recordings

-

Dashcam Routes

-
- ${stats.count} ${stats.count === 1 ? "drive" : "drives"} - ${stats.formattedDuration} total - ${stats.preservedCount > 0 ? html` ${stats.preservedCount} preserved` : ""} -
-
-
- -
-
` - }} +
+
+

Local recordings

+

Dashcam Routes

+ ${() => { + const stats = computeRouteStats(state.routes) + return html`
${stats.count} ${stats.count === 1 ? "drive" : "drives"} ${stats.formattedDuration} total${stats.preservedCount > 0 ? html` ${stats.preservedCount} preserved` : ""}
` + }} +
+
+ +
+
- -
@@ -515,10 +510,10 @@ export function RouteRecordings() {
- -
From f85bfb277b68419de43d188c8c0e35b20d2f8694 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:25:57 -0700 Subject: [PATCH 08/24] Ahhh Controls go BRRR --- .../components/recordings/dashcam_routes.css | 40 +++- .../components/recordings/dashcam_routes.js | 209 +++++++++++++----- .../recordings/dashcam_routes_helpers.js | 30 ++- .../the_galaxy/tests/test_dashcam_routes.py | 146 +++++++++++- .../tests/test_dashcam_routes_helpers.py | 86 ++++++- starpilot/system/the_galaxy/the_galaxy.py | 134 ++++++----- starpilot/system/the_galaxy/utilities.py | 89 ++++++-- 7 files changed, 593 insertions(+), 141 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 3d3186b02..dfb94fe97 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1239,19 +1239,49 @@ } .dashcam-player-state[hidden], -.dashcam-segment-status[hidden], +.dashcam-segment-bar[hidden], .dashcam-camera-selector button[hidden] { display: none; } -.dashcam-segment-status { +.dashcam-segment-bar { + align-items: center; background: var(--sidebar-bg); border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); - color: var(--text-muted); - font-size: var(--font-size-sm); + display: flex; + gap: var(--gap-xs); padding: 0.65rem var(--padding-lg); } +.dashcam-segment-bar .segment-step { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: flex; + font-size: var(--font-size-base); + justify-content: center; + padding: 0.35rem 0.7rem; +} + +.dashcam-segment-bar .segment-step:disabled { + cursor: default; + opacity: 0.4; +} + +.dashcam-segment-bar .segment-select { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + font-size: var(--font-size-sm); + padding: 0.35rem 0.6rem; +} + + .dashcam-camera-selector { gap: var(--gap-xs); padding: var(--padding-base) var(--padding-lg) 0; @@ -1355,7 +1385,7 @@ } .dashcam-player-header, - .dashcam-segment-status, + .dashcam-segment-bar, .dashcam-camera-selector, .dashcam-player-actions { padding-left: var(--padding-base); diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 42abb0020..f61909077 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -6,8 +6,9 @@ import { cameraVideoUrl, computeRouteStats, formatApproxDuration, - getSegmentStatus, - groupRoutesByDate, + getSegmentOptions, + supportsLowQuality, + groupRoutesForView, MAX_RENDERED_ROUTES, normalizeRoute, } from "/assets/components/recordings/dashcam_routes_helpers.js" @@ -32,6 +33,10 @@ let routesRequestToken = 0 let seenRouteNames = new Set() let overlay = null const routeLogsCache = new Map() +const FULL_QUALITY_RETRIES = 3 +const FULL_QUALITY_RETRY_MS = 4000 +// Wait for the viewer to settle, so scrubbing never queues a remux per segment. +const FULL_QUALITY_SETTLE_MS = 1500 function routeLabel(route) { return route.displayName || route.displayDate || route.name @@ -119,8 +124,11 @@ function closeDialog(dialog) { } function replaceRoute(updatedRoute) { - state.routes = state.routes.map(route => route.name === updatedRoute.name ? updatedRoute : route) - if (state.selectedRoute?.name === updatedRoute.name) state.selectedRoute = updatedRoute + // Rows bind to this exact object, so replacing it would strand them on the stale one. + const existing = state.routes.find(route => route.name === updatedRoute.name) + if (existing) Object.assign(existing, updatedRoute) + const selected = state.selectedRoute + if (selected?.name === updatedRoute.name && selected !== existing) Object.assign(selected, updatedRoute) } async function deleteRoute(route) { @@ -285,10 +293,14 @@ async function openOverlay(route) {
- +
Loading route metadata…
- +
@@ -305,7 +317,10 @@ async function openOverlay(route) { const video = overlay.querySelector("video") const playerState = overlay.querySelector(".dashcam-player-state") - const statusStrip = overlay.querySelector(".dashcam-segment-status") + const segmentBar = overlay.querySelector(".dashcam-segment-bar") + const segmentSelect = overlay.querySelector(".segment-select") + const prevSegmentButton = overlay.querySelector(".action-prev-segment") + const nextSegmentButton = overlay.querySelector(".action-next-segment") const downloadButton = overlay.querySelector(".action-download") const logsButton = overlay.querySelector(".action-logs") const cameraButtons = [...overlay.querySelectorAll(".camera-button")] @@ -313,27 +328,130 @@ async function openOverlay(route) { let current = 0 let selectedCamera = null let logsData = null + let qualityToken = 0 + let warmedSegment = null + let upgradeTimer = null const setPlayerMessage = (message, isError = false) => { playerState.textContent = message playerState.hidden = !message playerState.classList.toggle("error", isError) } - const updateSegmentStatus = () => { - const status = getSegmentStatus(segments, current) - statusStrip.textContent = status - statusStrip.hidden = !status + const syncSegmentControls = () => { + segmentSelect.value = String(current) + segmentSelect.disabled = segments.length < 2 + prevSegmentButton.disabled = current <= 0 + nextSegmentButton.disabled = current >= segments.length - 1 } - const playCurrentSegment = () => { - if (!segments[current] || !selectedCamera) return - updateSegmentStatus() - setPlayerMessage("Loading video…") - video.src = cameraVideoUrl(segments[current], selectedCamera) + const buildSegmentPicker = () => { + segmentSelect.innerHTML = getSegmentOptions(segments) + .map(option => ``) + .join("") + segmentBar.hidden = !segments.length + } + const swapSource = (url, { message } = {}) => { + const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 + const shouldResume = !video.paused && !video.ended + video.addEventListener("loadedmetadata", () => { + if (playbackTime > 0) { + try { + video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) + } catch (_) {} + } + if (shouldResume) video.play().catch(() => {}) + }, { once: true }) + if (message) setPlayerMessage(message) + video.src = url video.load() - video.play().catch(() => {}) + } + + // Full-res needs a device-side remux, so wait for it behind playback rather than in front. + const requestFullQuality = (segmentUrl, camera, attempt = 0) => { + const token = ++qualityToken + const fullUrl = cameraVideoUrl(segmentUrl, camera) + const stillCurrent = () => + token === qualityToken && segments[current] === segmentUrl && selectedCamera === camera && !!overlay + fetch(fullUrl, { method: "HEAD" }) + .then(response => { + if (!stillCurrent()) return + if (response.ok) { + swapSource(fullUrl) + return + } + // 503 means the remux is queued behind another one; check back a few times. + if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) { + setTimeout(() => { + if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1) + }, FULL_QUALITY_RETRY_MS) + } + }) + .catch(() => {}) + } + + overlay._cancelUpgrade = () => { + qualityToken += 1 + clearTimeout(upgradeTimer) + } + + const upgradeToFullQuality = (segmentUrl, camera) => { + qualityToken += 1 + clearTimeout(upgradeTimer) + upgradeTimer = setTimeout(() => { + if (segments[current] === segmentUrl && selectedCamera === camera && overlay) { + requestFullQuality(segmentUrl, camera) + } + }, FULL_QUALITY_SETTLE_MS) + } + + const warmNextSegment = () => { + const nextUrl = segments[current + 1] + if (!nextUrl || !selectedCamera || !supportsLowQuality(selectedCamera)) return + if (warmedSegment === nextUrl) return + warmedSegment = nextUrl + // Only the ffmpeg-free stream is warmed; never transcode a segment nobody watches. + fetch(cameraVideoUrl(nextUrl, selectedCamera, "low"), { method: "HEAD" }).catch(() => {}) + } + + const playCurrentSegment = (autoplay = true) => { + if (!segments[current] || !selectedCamera) return + syncSegmentControls() + setPlayerMessage("Loading video…") + const segmentUrl = segments[current] + const camera = selectedCamera + const useLowFirst = supportsLowQuality(camera) + qualityToken += 1 + video.src = cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined) + video.load() + if (autoplay) video.play().catch(() => {}) + if (useLowFirst) upgradeToFullQuality(segmentUrl, camera) + warmNextSegment() + } + const goToSegment = index => { + if (!segments.length) return + const target = Math.min(Math.max(index, 0), segments.length - 1) + if (target === current) { + syncSegmentControls() + return + } + const keepPlaying = video.ended || (!video.paused && !video.error) + current = target + warmedSegment = null + playCurrentSegment(keepPlaying) } const closeOnEscape = event => { - if (event.key === "Escape" && !document.querySelector(".route-logs-dialog")) closeOverlay() + if (document.querySelector(".route-logs-dialog")) return + if (event.key === "Escape") { + closeOverlay() + return + } + if (!event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return + if (event.key === "ArrowLeft") { + event.preventDefault() + goToSegment(current - 1) + } else if (event.key === "ArrowRight") { + event.preventDefault() + goToSegment(current + 1) + } } overlay.addEventListener("click", event => { if (event.target === overlay) closeOverlay() }) @@ -358,30 +476,23 @@ async function openOverlay(route) { video.addEventListener("playing", () => setPlayerMessage("")) video.addEventListener("waiting", () => setPlayerMessage("Loading video…")) video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true)) - video.addEventListener("ended", () => { - if (current + 1 >= segments.length) return - current += 1 - playCurrentSegment() - }) + video.addEventListener("ended", () => goToSegment(current + 1)) + + prevSegmentButton.onclick = () => goToSegment(current - 1) + nextSegmentButton.onclick = () => goToSegment(current + 1) + segmentSelect.onchange = () => goToSegment(Number(segmentSelect.value)) for (const button of cameraButtons) { button.addEventListener("click", () => { if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return - const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 - const shouldResume = !video.paused && !video.ended selectedCamera = button.dataset.camera cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) - video.addEventListener("loadedmetadata", () => { - if (playbackTime > 0) { - try { - video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) - } catch (_) {} - } - if (shouldResume) video.play().catch(() => {}) - }, { once: true }) - setPlayerMessage("Switching camera…") - video.src = cameraVideoUrl(segments[current], selectedCamera) - video.load() + const segmentUrl = segments[current] + const camera = selectedCamera + const useLowFirst = supportsLowQuality(camera) + qualityToken += 1 + swapSource(cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined), { message: "Switching camera…" }) + if (useLowFirst) upgradeToFullQuality(segmentUrl, camera) }) } @@ -402,6 +513,7 @@ async function openOverlay(route) { button.classList.toggle("active", button.dataset.camera === selectedCamera) } downloadButton.disabled = false + buildSegmentPicker() playCurrentSegment() } catch (error) { cameraButtons.forEach(button => { button.disabled = true }) @@ -411,6 +523,7 @@ async function openOverlay(route) { function closeOverlay() { if (!overlay) return + overlay._cancelUpgrade?.() document.removeEventListener("keydown", overlay._closeOnEscape) overlay.remove() overlay = null @@ -521,8 +634,7 @@ export function RouteRecordings() { ${() => { const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder }) - const groups = groupRoutesByDate(view.visible) - const isGrid = state.viewMode === "grid" + const groups = groupRoutesForView(view.visible, state.sortOrder) return html`
@@ -540,13 +652,12 @@ export function RouteRecordings() {

${group.label}

${group.routes.length} ${group.routes.length === 1 ? "drive" : "drives"}
- ${isGrid ? html` -
+ ${() => state.viewMode === "grid" ? html`
${group.routes.map(route => html` -
+
- ${route.name.split("--").slice(1).join("--") || route.name}
@@ -581,11 +692,9 @@ export function RouteRecordings() {
`)} -
- ` : html` -
+
` : html`
${group.routes.map(route => html` -
+
@@ -600,7 +709,7 @@ export function RouteRecordings() {
${formatApproxDuration(route.approxDurationSeconds)} ${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"} - ${route.is_preserved ? html` Preserved` : ""} + ${() => route.is_preserved ? html` Preserved` : ""} ${route.name.split("--").slice(1).join("--") || route.name}
@@ -608,8 +717,8 @@ export function RouteRecordings() { - +
+
Loading route metadata…
- -
- - - -
-
- - - - +
+
+ + + +
+ +
+ + + +
` document.body.appendChild(overlay) @@ -328,8 +336,10 @@ async function openOverlay(route) { let current = 0 let selectedCamera = null let logsData = null + let showingPreview = false + let wantsPlayback = true let qualityToken = 0 - let warmedSegment = null + let upgradeController = null let upgradeTimer = null const setPlayerMessage = (message, isError = false) => { @@ -365,66 +375,71 @@ async function openOverlay(route) { video.load() } - // Full-res needs a device-side remux, so wait for it behind playback rather than in front. + const cancelUpgrade = () => { + qualityToken += 1 + clearTimeout(upgradeTimer) + upgradeTimer = null + upgradeController?.abort() + upgradeController = null + } + + // Prepare the real stream behind the playing preview. Only replace the media source + // after the server confirms the remux is ready, so slow device work never blanks it. const requestFullQuality = (segmentUrl, camera, attempt = 0) => { const token = ++qualityToken + upgradeController?.abort() + const controller = new AbortController() + upgradeController = controller const fullUrl = cameraVideoUrl(segmentUrl, camera) const stillCurrent = () => - token === qualityToken && segments[current] === segmentUrl && selectedCamera === camera && !!overlay - fetch(fullUrl, { method: "HEAD" }) + token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && !!overlay + + fetch(fullUrl, { method: "HEAD", signal: controller.signal }) .then(response => { if (!stillCurrent()) return + if (upgradeController === controller) upgradeController = null if (response.ok) { + showingPreview = false swapSource(fullUrl) return } - // 503 means the remux is queued behind another one; check back a few times. if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) { - setTimeout(() => { + upgradeTimer = setTimeout(() => { if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1) }, FULL_QUALITY_RETRY_MS) } }) - .catch(() => {}) + .catch(error => { + if (upgradeController === controller) upgradeController = null + if (error?.name !== "AbortError") console.error("Could not prepare full-quality route video:", error) + }) } - overlay._cancelUpgrade = () => { - qualityToken += 1 - clearTimeout(upgradeTimer) - } - - const upgradeToFullQuality = (segmentUrl, camera) => { - qualityToken += 1 - clearTimeout(upgradeTimer) + const scheduleUpgrade = (segmentUrl, camera) => { + cancelUpgrade() upgradeTimer = setTimeout(() => { - if (segments[current] === segmentUrl && selectedCamera === camera && overlay) { - requestFullQuality(segmentUrl, camera) - } + if (!showingPreview || segments[current] !== segmentUrl || selectedCamera !== camera) return + requestFullQuality(segmentUrl, camera) }, FULL_QUALITY_SETTLE_MS) } - const warmNextSegment = () => { - const nextUrl = segments[current + 1] - if (!nextUrl || !selectedCamera || !supportsLowQuality(selectedCamera)) return - if (warmedSegment === nextUrl) return - warmedSegment = nextUrl - // Only the ffmpeg-free stream is warmed; never transcode a segment nobody watches. - fetch(cameraVideoUrl(nextUrl, selectedCamera, "low"), { method: "HEAD" }).catch(() => {}) + const loadSegment = (autoplay, { message, preview } = {}) => { + const segmentUrl = segments[current] + const camera = selectedCamera + if (!segmentUrl || !camera) return + cancelUpgrade() + wantsPlayback = autoplay + showingPreview = preview === undefined ? supportsLowQuality(camera) : preview + setPlayerMessage(message || "Loading video…") + video.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) + video.load() + if (autoplay) video.play().catch(() => {}) } const playCurrentSegment = (autoplay = true) => { if (!segments[current] || !selectedCamera) return syncSegmentControls() - setPlayerMessage("Loading video…") - const segmentUrl = segments[current] - const camera = selectedCamera - const useLowFirst = supportsLowQuality(camera) - qualityToken += 1 - video.src = cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined) - video.load() - if (autoplay) video.play().catch(() => {}) - if (useLowFirst) upgradeToFullQuality(segmentUrl, camera) - warmNextSegment() + loadSegment(autoplay) } const goToSegment = index => { if (!segments.length) return @@ -435,9 +450,10 @@ async function openOverlay(route) { } const keepPlaying = video.ended || (!video.paused && !video.error) current = target - warmedSegment = null playCurrentSegment(keepPlaying) } + overlay._cancelUpgrade = cancelUpgrade + const closeOnEscape = event => { if (document.querySelector(".route-logs-dialog")) return if (event.key === "Escape") { @@ -472,10 +488,27 @@ async function openOverlay(route) { link.remove() } + video.addEventListener("loadedmetadata", () => { + if (!showingPreview) return + if (shouldUpgradeFromHeight(video.videoHeight)) { + scheduleUpgrade(segments[current], selectedCamera) + } else { + showingPreview = false + } + }) video.addEventListener("loadeddata", () => setPlayerMessage("")) video.addEventListener("playing", () => setPlayerMessage("")) video.addEventListener("waiting", () => setPlayerMessage("Loading video…")) - video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true)) + video.addEventListener("error", () => { + // A dead preview drops through to the real stream rather than showing an error. + if (showingPreview) { + showingPreview = false + cancelUpgrade() + loadSegment(wantsPlayback, { preview: false }) + return + } + setPlayerMessage("This segment could not be played.", true) + }) video.addEventListener("ended", () => goToSegment(current + 1)) prevSegmentButton.onclick = () => goToSegment(current - 1) @@ -487,12 +520,16 @@ async function openOverlay(route) { if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return selectedCamera = button.dataset.camera cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) - const segmentUrl = segments[current] - const camera = selectedCamera - const useLowFirst = supportsLowQuality(camera) - qualityToken += 1 - swapSource(cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined), { message: "Switching camera…" }) - if (useLowFirst) upgradeToFullQuality(segmentUrl, camera) + const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 + const shouldResume = !video.paused && !video.ended + video.addEventListener("loadedmetadata", () => { + if (playbackTime > 0) { + try { + video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) + } catch (_) {} + } + }, { once: true }) + loadSegment(shouldResume, { message: "Switching camera…" }) }) } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index f84c9415b..6262ad80f 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -195,7 +195,13 @@ export function cameraVideoUrl(segmentUrl, camera, quality) { return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url } -// qcamera.ts only exists for the road camera, so the instant-start tier is forward-only. +// loggerd only writes qcamera.ts alongside the road camera. export function supportsLowQuality(camera) { return camera === "forward" } + +// qcamera is 526x330. Only a positively taller frame proves the real stream is already +// playing; an unknown height upgrades rather than stranding the viewer on the preview. +export function shouldUpgradeFromHeight(height) { + return !(Number.isFinite(height) && height > 400) +} diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py index 77228c0a9..3c025d381 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone import io import os from pathlib import Path +import subprocess import threading import time @@ -480,35 +481,162 @@ def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path assert metadata.get_json()["total_duration"] == 180 -def test_low_quality_serves_qcamera_without_touching_ffmpeg(monkeypatch, tmp_path): +def test_low_quality_serves_the_wrapped_qcamera_preview(monkeypatch, tmp_path): + """qcamera.ts is tiny, but it still needs the mp4 wrap - MPEG-TS will not play in a
` document.body.appendChild(overlay) - const video = overlay.querySelector("video") - const transitionFrame = overlay.querySelector(".dashcam-transition-frame") + const [videoA, videoB] = overlay.querySelectorAll(".dashcam-video") + let activeVideo = videoA + let stagingVideo = videoB const playerState = overlay.querySelector(".dashcam-player-state") const segmentBar = overlay.querySelector(".dashcam-segment-bar") const segmentSelect = overlay.querySelector(".segment-select") @@ -343,7 +344,7 @@ async function openOverlay(route) { let qualityToken = 0 let upgradeController = null let upgradeTimer = null - let transitionActive = false + let isUpgrading = false const setPlayerMessage = (message, isError = false) => { playerState.textContent = message @@ -363,59 +364,138 @@ async function openOverlay(route) { segmentBar.hidden = !segments.length } - const finishTransition = () => { - transitionActive = false - transitionFrame.hidden = true - } - const holdCurrentFrame = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || !video.videoWidth || !video.videoHeight) return false - const context = transitionFrame.getContext("2d") - if (!context) return false - transitionFrame.width = video.videoWidth - transitionFrame.height = video.videoHeight - try { - context.drawImage(video, 0, 0, transitionFrame.width, transitionFrame.height) - } catch (_) { - return false - } - transitionActive = true - transitionFrame.hidden = false - setPlayerMessage("") - return true - } - const swapSource = (url, { message, seamless = false } = {}) => { - const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 - const shouldResume = !video.paused && !video.ended - const heldFrame = seamless && holdCurrentFrame() - // Never trade a working preview for a black frame. The prepared full stream can - // be attempted again later, while the low-resolution video keeps playing. - if (seamless && !heldFrame) return false - if (heldFrame) video.addEventListener("canplay", finishTransition, { once: true }) - video.addEventListener("loadedmetadata", () => { - if (playbackTime > 0) { - try { - video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) - } catch (_) {} - } - if (shouldResume) video.play().catch(() => {}) - }, { once: true }) - if (!heldFrame) finishTransition() - if (message) setPlayerMessage(message) - video.src = url - video.load() - return true - } - const cancelUpgrade = () => { qualityToken += 1 + isUpgrading = false clearTimeout(upgradeTimer) upgradeTimer = null upgradeController?.abort() upgradeController = null + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.load() } - // Prepare the real stream behind the playing preview. Only replace the media source - // after the server confirms the remux is ready, so slow device work never blanks it. + const performSeamlessUpgrade = (fullUrl, token) => { + if (token !== qualityToken || !showingPreview || !overlay) return + isUpgrading = true + + stagingVideo.muted = activeVideo.muted + stagingVideo.volume = activeVideo.volume + stagingVideo.playbackRate = activeVideo.playbackRate + stagingVideo.src = fullUrl + stagingVideo.preload = "auto" + stagingVideo.load() + + const cleanupStaging = () => { + stagingVideo.removeEventListener("loadedmetadata", onMetadata) + stagingVideo.removeEventListener("error", onStagingError) + } + + const onStagingError = () => { + cleanupStaging() + if (token !== qualityToken) return + isUpgrading = false + showingPreview = false + } + + const onMetadata = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + + const syncAndSwap = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + + const applySwap = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + cleanupStaging() + + const targetTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + const isPlaying = !activeVideo.paused && !activeVideo.ended + + stagingVideo.muted = activeVideo.muted + stagingVideo.volume = activeVideo.volume + stagingVideo.playbackRate = activeVideo.playbackRate + + if (Math.abs(stagingVideo.currentTime - targetTime) > 0.15) { + try { + stagingVideo.currentTime = targetTime + } catch (_) {} + } + + if (isPlaying && stagingVideo.paused) { + stagingVideo.play().catch(() => {}) + } else if (!isPlaying && !stagingVideo.paused) { + stagingVideo.pause() + } + + activeVideo.classList.remove("active") + activeVideo.classList.add("staging") + activeVideo.controls = false + + stagingVideo.classList.remove("staging") + stagingVideo.classList.add("active") + stagingVideo.controls = true + + const oldActive = activeVideo + activeVideo = stagingVideo + stagingVideo = oldActive + + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.load() + + showingPreview = false + isUpgrading = false + setPlayerMessage("") + } + + const isPlaying = !activeVideo.paused && !activeVideo.ended + if (isPlaying) { + stagingVideo.play().then(() => { + if ("requestVideoFrameCallback" in stagingVideo) { + stagingVideo.requestVideoFrameCallback(() => applySwap()) + } else { + stagingVideo.addEventListener("timeupdate", applySwap, { once: true }) + } + }).catch(() => { + applySwap() + }) + } else { + if ("requestVideoFrameCallback" in stagingVideo) { + stagingVideo.requestVideoFrameCallback(() => applySwap()) + } else { + applySwap() + } + } + } + + const playbackTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + if (playbackTime > 0) { + stagingVideo.addEventListener("seeked", syncAndSwap, { once: true }) + try { + stagingVideo.currentTime = Math.min(playbackTime, Number.isFinite(stagingVideo.duration) ? stagingVideo.duration : playbackTime) + } catch (_) { + syncAndSwap() + } + } else { + syncAndSwap() + } + } + + stagingVideo.addEventListener("loadedmetadata", onMetadata, { once: true }) + stagingVideo.addEventListener("error", onStagingError, { once: true }) + } + + // Request the real stream behind the playing preview without interrupting playback. const requestFullQuality = (segmentUrl, camera, attempt = 0) => { const token = ++qualityToken upgradeController?.abort() @@ -423,24 +503,14 @@ async function openOverlay(route) { upgradeController = controller const fullUrl = cameraVideoUrl(segmentUrl, camera) const stillCurrent = () => - token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && !!overlay - const swapPreparedStream = (attempt = 0) => { - if (!stillCurrent()) return - if (swapSource(fullUrl, { seamless: true })) { - showingPreview = false - return - } - if (attempt < 5) { - upgradeTimer = setTimeout(() => swapPreparedStream(attempt + 1), 100) - } - } + token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && Boolean(overlay) fetch(fullUrl, { method: "HEAD", signal: controller.signal }) .then(response => { if (!stillCurrent()) return if (upgradeController === controller) upgradeController = null if (response.ok) { - swapPreparedStream() + performSeamlessUpgrade(fullUrl, token) return } if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) { @@ -468,13 +538,22 @@ async function openOverlay(route) { const camera = selectedCamera if (!segmentUrl || !camera) return cancelUpgrade() - finishTransition() wantsPlayback = autoplay showingPreview = preview === undefined ? supportsLowQuality(camera) : preview setPlayerMessage(message || "Loading video…") - video.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) - video.load() - if (autoplay) video.play().catch(() => {}) + + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.classList.remove("active") + stagingVideo.classList.add("staging") + stagingVideo.controls = false + + activeVideo.classList.remove("staging") + activeVideo.classList.add("active") + activeVideo.controls = true + activeVideo.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) + activeVideo.load() + if (autoplay) activeVideo.play().catch(() => {}) } const playCurrentSegment = (autoplay = true) => { @@ -489,7 +568,7 @@ async function openOverlay(route) { syncSegmentControls() return } - const keepPlaying = video.ended || (!video.paused && !video.error) + const keepPlaying = activeVideo.ended || (!activeVideo.paused && !activeVideo.error) current = target playCurrentSegment(keepPlaying) } @@ -529,21 +608,26 @@ async function openOverlay(route) { link.remove() } - video.addEventListener("loadedmetadata", () => { + const handleLoadedMetadata = event => { + if (event.target !== activeVideo) return if (!showingPreview) return - if (shouldUpgradeFromHeight(video.videoHeight)) { + if (shouldUpgradeFromHeight(activeVideo.videoHeight)) { scheduleUpgrade(segments[current], selectedCamera) } else { showingPreview = false } - }) - video.addEventListener("loadeddata", () => setPlayerMessage("")) - video.addEventListener("playing", () => setPlayerMessage("")) - video.addEventListener("waiting", () => { - if (!transitionActive) setPlayerMessage("Loading video…") - }) - video.addEventListener("error", () => { - finishTransition() + } + const handleLoadedData = event => { + if (event.target === activeVideo) setPlayerMessage("") + } + const handlePlaying = event => { + if (event.target === activeVideo) setPlayerMessage("") + } + const handleWaiting = event => { + if (event.target === activeVideo && !isUpgrading) setPlayerMessage("Loading video…") + } + const handleError = event => { + if (event.target !== activeVideo) return // A dead preview drops through to the real stream rather than showing an error. if (showingPreview) { showingPreview = false @@ -552,8 +636,42 @@ async function openOverlay(route) { return } setPlayerMessage("This segment could not be played.", true) - }) - video.addEventListener("ended", () => goToSegment(current + 1)) + } + const handleEnded = event => { + if (event.target === activeVideo) goToSegment(current + 1) + } + const handleSeeking = event => { + if (event.target !== activeVideo) return + if (isUpgrading && stagingVideo.readyState >= 1) { + try { + stagingVideo.currentTime = activeVideo.currentTime + } catch (_) {} + } + } + const handlePause = event => { + if (event.target !== activeVideo) return + if (isUpgrading && !stagingVideo.paused) stagingVideo.pause() + } + const handlePlay = event => { + if (event.target !== activeVideo) return + if (isUpgrading && stagingVideo.paused && stagingVideo.readyState >= 3) { + stagingVideo.play().catch(() => {}) + } + } + + const bindVideoEvents = el => { + el.addEventListener("loadedmetadata", handleLoadedMetadata) + el.addEventListener("loadeddata", handleLoadedData) + el.addEventListener("playing", handlePlaying) + el.addEventListener("waiting", handleWaiting) + el.addEventListener("error", handleError) + el.addEventListener("ended", handleEnded) + el.addEventListener("seeking", handleSeeking) + el.addEventListener("pause", handlePause) + el.addEventListener("play", handlePlay) + } + bindVideoEvents(videoA) + bindVideoEvents(videoB) prevSegmentButton.onclick = () => goToSegment(current - 1) nextSegmentButton.onclick = () => goToSegment(current + 1) @@ -564,12 +682,12 @@ async function openOverlay(route) { if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return selectedCamera = button.dataset.camera cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) - const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 - const shouldResume = !video.paused && !video.ended - video.addEventListener("loadedmetadata", () => { + const playbackTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + const shouldResume = !activeVideo.paused && !activeVideo.ended + activeVideo.addEventListener("loadedmetadata", () => { if (playbackTime > 0) { try { - video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) + activeVideo.currentTime = Math.min(playbackTime, Number.isFinite(activeVideo.duration) ? activeVideo.duration : playbackTime) } catch (_) {} } }, { once: true }) @@ -606,6 +724,12 @@ function closeOverlay() { if (!overlay) return overlay._cancelUpgrade?.() document.removeEventListener("keydown", overlay._closeOnEscape) + const videos = overlay.querySelectorAll("video") + videos.forEach(v => { + v.pause() + v.removeAttribute("src") + v.load() + }) overlay.remove() overlay = null state.selectedRoute = null From c0b8f1cb011d54224c7301aaa4dd09330448e623 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:58:57 -0700 Subject: [PATCH 12/24] Try again --- .../assets/components/recordings/dashcam_routes.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index fbb68f093..10f532df7 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -540,7 +540,7 @@ async function openOverlay(route) { cancelUpgrade() wantsPlayback = autoplay showingPreview = preview === undefined ? supportsLowQuality(camera) : preview - setPlayerMessage(message || "Loading video…") + if (message) setPlayerMessage(message) stagingVideo.pause() stagingVideo.removeAttribute("src") @@ -623,9 +623,7 @@ async function openOverlay(route) { const handlePlaying = event => { if (event.target === activeVideo) setPlayerMessage("") } - const handleWaiting = event => { - if (event.target === activeVideo && !isUpgrading) setPlayerMessage("Loading video…") - } + const handleWaiting = () => {} const handleError = event => { if (event.target !== activeVideo) return // A dead preview drops through to the real stream rather than showing an error. From 2d93c29890e801029e1247c7e83af9ff157f1376 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:49:24 -0700 Subject: [PATCH 13/24] Allow Unpreserved Deletes Only --- .../components/recordings/dashcam_routes.css | 29 ++++++++++ .../components/recordings/dashcam_routes.js | 44 ++++++++------ .../the_galaxy/tests/test_dashboard_stats.py | 22 +++++++ .../the_galaxy/tests/test_dashcam_routes.py | 57 +++++++++++++++++++ starpilot/system/the_galaxy/the_galaxy.py | 44 ++++++++++++-- starpilot/system/the_galaxy/utilities.py | 28 +++++++-- 6 files changed, 197 insertions(+), 27 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 6bce9771a..2791db18d 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1168,10 +1168,34 @@ .dashcam-danger-zone > div { display: flex; +} + +.dashcam-danger-copy { flex-direction: column; gap: var(--gap-xs); } +.dashcam-danger-actions { + align-items: center; + flex-wrap: wrap; + gap: var(--gap-sm); + justify-content: flex-end; +} + +.dashcam-danger-actions .delete-all-button { + margin: 0; +} + +.delete-all-button.delete-non-preserved-button { + background: transparent; + border: var(--border-width-thin) solid var(--danger-bg); + color: var(--danger-bg); +} + +.delete-all-button.delete-non-preserved-button:hover { + background: rgba(224, 85, 119, 0.15); +} + .dashcam-danger-zone span { color: var(--text-muted); font-size: var(--font-size-sm); @@ -1396,6 +1420,11 @@ flex-direction: column; } + .dashcam-danger-actions { + align-items: stretch; + flex-direction: column; + } + .dashcam-toolbar { flex-direction: column; align-items: stretch; diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 10f532df7..8f426608c 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -25,7 +25,7 @@ const state = reactive({ viewMode: "list", progress: 0, total: 0, - showDeleteAllModal: false, + deleteMode: null, isDeletingAll: false, }) @@ -129,7 +129,11 @@ function replaceRoute(updatedRoute) { const existing = state.routes.find(route => route.name === updatedRoute.name) if (existing) Object.assign(existing, updatedRoute) const selected = state.selectedRoute - if (selected?.name === updatedRoute.name && selected !== existing) Object.assign(selected, updatedRoute) + if (selected?.name === updatedRoute.name && selected !== existing) { + Object.assign(selected, updatedRoute) + } + // Reassigning state.routes notifies ArrowJS to re-render views that depend on the routes array + state.routes = [...state.routes] } async function deleteRoute(route) { @@ -749,16 +753,17 @@ async function togglePreserved(route, event) { } } -async function deleteAllRoutes() { - state.showDeleteAllModal = false +async function deleteAllRoutes(includePreserved) { + state.deleteMode = null state.isDeletingAll = true try { - const response = await fetch("/api/routes/delete_all", { method: "DELETE" }) - if (!response.ok) throw new Error() + const response = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(payload.error || "Route deletion failed") await refresh() - showSnackbar("All routes deleted!") - } catch (_) { - showSnackbar("An error occurred while deleting all routes...", "error") + showSnackbar(payload.message || "Routes deleted!") + } catch (error) { + showSnackbar(error?.message || "An error occurred while deleting routes...", "error") } finally { state.isDeletingAll = false } @@ -942,16 +947,21 @@ export function RouteRecordings() { ${() => state.routes.length ? html`
-
Delete all local routesPreserved routes are included.
- +
Delete local routesKeep preserved routes, or remove everything.
+
+ + +
` : ""} - ${() => state.showDeleteAllModal ? Modal({ - title: "Confirm Delete All", - message: "Are you sure you want to delete all routes? This action cannot be undone...", - onConfirm: deleteAllRoutes, - onCancel: () => { state.showDeleteAllModal = false }, - confirmText: "Delete All", + ${() => state.deleteMode ? Modal({ + title: state.deleteMode === "all" ? "Delete All Routes, Including Preserved?" : "Delete All Non-Preserved Routes?", + message: state.deleteMode === "all" + ? "This permanently deletes every local route, including preserved routes. This action cannot be undone." + : "This permanently deletes every non-preserved local route. Preserved routes will be kept.", + onConfirm: () => deleteAllRoutes(state.deleteMode === "all"), + onCancel: () => { state.deleteMode = null }, + confirmText: state.deleteMode === "all" ? "Delete Everything" : "Delete Non-Preserved", }) : ""}
` } diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index 2f50f7ab8..4f5d21c3d 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -1091,6 +1091,28 @@ def test_clear_dashboard_route_history_keeps_durable_records(tmp_path, monkeypat assert stats["modelUsage"]["orion"]["drives"] == 3 +def test_clear_dashboard_route_history_can_retain_preserved_routes(tmp_path, monkeypatch): + monkeypatch.setattr(utilities, "DASHBOARD_PARAMS_DIR", tmp_path) + params = FakeParams({ + utilities.DASHBOARD_PERSISTENT_STATS_PARAM: { + "routes": { + "0000006a--9f0a7bdf9c": {"date": "2026-06-15T08:00:00"}, + "0000006b--9f0a7bdf9d": {"date": "2026-06-16T08:00:00"}, + }, + "ignoredRoutes": ["0000006a--9f0a7bdf9c", "0000006b--9f0a7bdf9d"], + "personalRecords": {"cleanDriveStreak": {"drives": 4}}, + }, + }) + + removed = utilities.clear_dashboard_route_history(params, retained_route_names={"0000006a--9f0a7bdf9c"}) + + assert removed == 1 + stats = utilities._load_dashboard_persistent_stats(params) + assert list(stats["routes"]) == ["0000006a--9f0a7bdf9c"] + assert stats["ignoredRoutes"] == ["0000006a--9f0a7bdf9c"] + assert stats["personalRecords"]["cleanDriveStreak"]["drives"] == 4 + + def test_lightweight_routes_surface_recent_drives_without_log_analysis(monkeypatch): utilities._invalidate_dashboard_cache() now = utilities.datetime.now().replace(hour=12, minute=0, second=0, microsecond=0) diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py index 3c025d381..cb5d9e51d 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -393,6 +393,63 @@ def test_preserve_limit_counts_routes_not_segments(monkeypatch, tmp_path): assert client.post("/api/routes/00000099--9f0a7bdf9c/preserve").status_code == 400 +def test_delete_all_non_preserved_keeps_entire_preserved_route_across_roots(monkeypatch, tmp_path): + standard = tmp_path / "standard" + high_resolution = tmp_path / "high_resolution" + preserved_route = ROUTE_NAME + ordinary_route = "0000006b--9f0a7bdf9d" + preserved_marker = _make_segment(standard, preserved_route, 3) + preserved_other_root = _make_segment(high_resolution, preserved_route, 4) + ordinary_standard = _make_segment(standard, ordinary_route, 0) + ordinary_other_root = _make_segment(high_resolution, ordinary_route, 1) + unrelated = high_resolution / "video_cache" + unrelated.mkdir() + + client = _make_client(monkeypatch, standard) + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(standard), str(high_resolution)]) + monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: path == str(preserved_marker)) + monkeypatch.setattr(utilities, "stop_dashboard_background_analysis", lambda: None) + monkeypatch.setattr(the_galaxy, "delete_file", lambda path: Path(path).rmdir()) + history_calls = [] + monkeypatch.setattr(utilities, "clear_dashboard_route_history", lambda params, retained_route_names=None: history_calls.append(retained_route_names) or 1) + factory_delete_calls = [] + monkeypatch.setattr(the_galaxy, "_run_factory_reset_delete", factory_delete_calls.append) + + response = client.delete("/api/routes/delete_all?include_preserved=false") + + assert response.status_code == 200 + assert response.get_json()["deletedRoutes"] == 1 + assert response.get_json()["preservedRoutes"] == 1 + assert preserved_marker.is_dir() + assert preserved_other_root.is_dir() + assert not ordinary_standard.exists() + assert not ordinary_other_root.exists() + assert unrelated.is_dir() + assert history_calls == [{preserved_route}] + assert factory_delete_calls == [] + + +def test_delete_all_including_preserved_keeps_existing_full_wipe_behavior(monkeypatch, tmp_path): + first_root = tmp_path / "standard" + second_root = tmp_path / "high_resolution" + _make_segment(first_root) + _make_segment(second_root) + client = _make_client(monkeypatch, first_root) + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(first_root) + "/", str(second_root), str(first_root)]) + monkeypatch.setattr(utilities, "stop_dashboard_background_analysis", lambda: None) + history_calls = [] + monkeypatch.setattr(utilities, "clear_dashboard_route_history", lambda params, retained_route_names=None: history_calls.append(retained_route_names) or 2) + factory_delete_calls = [] + monkeypatch.setattr(the_galaxy, "_run_factory_reset_delete", factory_delete_calls.append) + + response = client.delete("/api/routes/delete_all?include_preserved=true") + + assert response.status_code == 200 + assert factory_delete_calls == [str(first_root), str(second_root)] + assert history_calls == [None] + assert "including preserved routes" in response.get_json()["message"] + + def test_video_cache_evicts_oldest_instead_of_wiping_everything(monkeypatch, tmp_path): """A tight disk used to delete every cached mp4, so playback re-muxed on every request.""" cache = tmp_path / "video_cache" diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 92d3e73fe..6ce9b2171 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -6366,6 +6366,7 @@ def setup(app): try: utilities.stop_dashboard_background_analysis() + include_preserved = request.args.get("include_preserved", "true").strip().lower() not in ("0", "false", "no", "off") route_paths = [] seen_paths = set() @@ -6375,18 +6376,51 @@ def setup(app): seen_paths.add(path) route_paths.append(path) - for route_path in route_paths: - _run_factory_reset_delete(route_path) + preserved_route_names = set() + deleted_route_names = set() + if include_preserved: + for route_path in route_paths: + _run_factory_reset_delete(route_path) + else: + # The preserve xattr lives on one segment, but preservation applies to the + # whole route in every footage root. + for route_path in route_paths: + if not os.path.isdir(route_path): + continue + for segment in os.listdir(route_path): + if utilities.SEGMENT_RE.fullmatch(segment) and utilities.has_preserve_attr(os.path.join(route_path, segment)): + preserved_route_names.add(segment.rsplit("--", 1)[0]) - persisted_route_count = utilities.clear_dashboard_route_history(params) + for route_path in route_paths: + if not os.path.isdir(route_path): + continue + for segment in os.listdir(route_path): + if not utilities.SEGMENT_RE.fullmatch(segment): + continue + route_name = segment.rsplit("--", 1)[0] + if route_name in preserved_route_names: + continue + delete_file(os.path.join(route_path, segment)) + deleted_route_names.add(route_name) + + persisted_route_count = utilities.clear_dashboard_route_history( + params, + retained_route_names=preserved_route_names if not include_preserved else None, + ) _STATS_RESPONSE_CACHE.update({ "updated_at": 0.0, "payload": None, }) return jsonify({ "success": True, - "message": "All local driving routes deleted. Saved personal records were kept.", - "deletedPaths": len(route_paths), + "message": ( + "All local driving routes deleted, including preserved routes. Saved personal records were kept." + if include_preserved else + "All non-preserved local driving routes deleted. Preserved routes were kept." + ), + "deletedPaths": len(route_paths) if include_preserved else 0, + "deletedRoutes": len(deleted_route_names) if not include_preserved else None, + "preservedRoutes": len(preserved_route_names) if not include_preserved else 0, "clearedDashboardRoutes": persisted_route_count, }), 200 except Exception as exception: diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py index 1de1ea21a..7cd952daa 100644 --- a/starpilot/system/the_galaxy/utilities.py +++ b/starpilot/system/the_galaxy/utilities.py @@ -1773,12 +1773,30 @@ def _invalidate_dashboard_cache(): }) -def clear_dashboard_route_history(params_obj): - """Remove route-backed dashboard history while keeping durable records.""" +def clear_dashboard_route_history(params_obj, retained_route_names=None): + """Remove route-backed dashboard history while keeping durable records and optional retained routes.""" stats = _load_dashboard_persistent_stats(params_obj) - route_count = len(stats.get("routes", {})) - stats["routes"] = {} - stats["ignoredRoutes"] = [] + routes = stats.get("routes", {}) + retained_routes = None if retained_route_names is None else { + str(route_name or "").strip() + for route_name in retained_route_names + if ROUTE_RE.fullmatch(str(route_name or "").strip()) + } + if retained_routes is None: + stats["routes"] = {} + stats["ignoredRoutes"] = [] + else: + stats["routes"] = { + route_name: entry + for route_name, entry in routes.items() + if route_name in retained_routes + } + stats["ignoredRoutes"] = [ + route_name + for route_name in stats.get("ignoredRoutes", []) + if route_name in retained_routes + ] + route_count = len(routes) - len(stats["routes"]) serialized = json.dumps(stats, separators=(",", ":")) persisted_to_params = False From f5ba6362cea8214eaf7ee36a17cab21db3775859 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:05:45 -0700 Subject: [PATCH 14/24] Title updates and fix the ugly close button --- .../components/recordings/dashcam_routes.css | 24 +++++++++++++++++-- .../components/recordings/dashcam_routes.js | 22 +++++++++-------- .../the_galaxy/tests/test_dashcam_routes.py | 1 + .../tests/test_dashcam_routes_helpers.py | 9 +++++++ starpilot/system/the_galaxy/the_galaxy.py | 2 +- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 2791db18d..318faa6ed 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1225,12 +1225,32 @@ line-height: 1.25; } +.dashcam-player-overlay .dashcam-player-close, .dashcam-player-close { + align-items: center; background: transparent; border: 0; - color: var(--text-muted); + box-shadow: none; + color: var(--danger-fg, #e05577); cursor: pointer; - font-size: 2rem; + display: inline-flex; + font-size: 2.75rem; + font-weight: 300; + justify-content: center; + line-height: 0.8; + margin: 0; + padding: 0 0.25rem; + transition: color var(--transition-fast, 0.15s ease), transform var(--transition-fast, 0.15s ease), opacity var(--transition-fast, 0.15s ease); +} + +.dashcam-player-overlay .dashcam-player-close:hover, +.dashcam-player-overlay .dashcam-player-close:focus-visible, +.dashcam-player-close:hover, +.dashcam-player-close:focus-visible { + background: transparent; + color: var(--danger-hover-bg, #ff6b8b); + outline: none; + transform: scale(1.15); } .dashcam-video-shell { diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 8f426608c..246a8fe11 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -205,11 +205,13 @@ async function renameRoute(route) { return } - const updatedRoute = normalizeRoute({ ...route, timestamp: newName, isCustomName: true }) + const payload = await response.json().catch(() => ({})) + const savedName = payload.name || newName + const updatedRoute = normalizeRoute({ ...route, timestamp: savedName, isCustomName: true }) replaceRoute(updatedRoute) closeDialog(dialog) const title = overlay?.querySelector(".media-player-title-text") - if (title) title.textContent = newName + if (title) title.textContent = savedName showSnackbar("Route renamed!") } } @@ -301,7 +303,7 @@ async function openOverlay(route) {
- +
@@ -597,7 +599,7 @@ async function openOverlay(route) { overlay.addEventListener("click", event => { if (event.target === overlay) closeOverlay() }) document.addEventListener("keydown", closeOnEscape) overlay._closeOnEscape = closeOnEscape - overlay.querySelector(".action-close").onclick = closeOverlay + overlay.querySelector(".dashcam-player-close").onclick = closeOverlay overlay.querySelector(".action-delete").onclick = () => deleteRoute(state.selectedRoute || route) overlay.querySelector(".action-rename").onclick = () => renameRoute(state.selectedRoute || route) @@ -876,10 +878,10 @@ export function RouteRecordings() {
-

${route.displayName}

- ${route.isCustomName ? html`` : ""} +

${() => route.displayName}

+ ${() => route.isCustomName ? html`` : ""}
- ${route.isCustomName ? html`

${route.displayDate}

` : ""} + ${() => route.isCustomName ? html`

${route.displayDate}

` : ""}
${formatApproxDuration(route.approxDurationSeconds)} ${route.segmentCount} seg @@ -910,10 +912,10 @@ export function RouteRecordings() {
-

${route.displayName}

- ${route.isCustomName ? html` Custom` : ""} +

${() => route.displayName}

+ ${() => route.isCustomName ? html` Custom` : ""}
- ${route.isCustomName ? html`

${route.displayDate}

` : ""} + ${() => route.isCustomName ? html`

${route.displayDate}

` : ""}
${formatApproxDuration(route.approxDurationSeconds)} ${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"} diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py index cb5d9e51d..2f8d4e86f 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -326,6 +326,7 @@ def test_rename_and_reset_keep_logs_and_use_both_reset_urls(monkeypatch, tmp_pat renamed = client.post("/api/routes/rename", json={"old": ROUTE_NAME, "new": "New name"}) assert renamed.status_code == 200 + assert renamed.get_json()["name"] == "New_name" assert all((segment / "New_name").exists() for segment in segments) assert all((segment / "qlog.zst").read_bytes() == b"log" for segment in segments) diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index a64cdfcd3..2c7007570 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -14,6 +14,7 @@ import subprocess import pytest HELPERS_PATH = Path(__file__).resolve().parent.parent / "assets" / "components" / "recordings" / "dashcam_routes_helpers.js" +COMPONENT_PATH = HELPERS_PATH.with_name("dashcam_routes.js") # node infers ESM from `export` syntax in a bare .js file from 22.7 on, so the helpers # need no package.json and stay a normal asset next to the component that imports them. @@ -69,6 +70,14 @@ def test_helpers_module_is_a_plain_js_asset(): assert not list(HELPERS_PATH.parent.glob("*.mjs")) +def test_route_titles_and_custom_name_badges_are_reactive(): + source = COMPONENT_PATH.read_text(encoding="utf-8") + + # Both grid and row views must subscribe directly to the renamed route fields. + assert source.count('${() => route.displayName}') >= 4 + assert source.count('${() => route.isCustomName ? html`') >= 4 + + def test_groups_routes_into_today_yesterday_dates_and_unknown(): groups = evaluate(''' const routes = [ diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 6ce9b2171..92771d6a6 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -6644,7 +6644,7 @@ def setup(app): return jsonify({"error": f"Error creating new name file: {e}"}), 500 if renamed: - return jsonify({"message": "Route renamed successfully!"}), 200 + return jsonify({"message": "Route renamed successfully!", "name": new_name}), 200 else: return jsonify({"error": "Route not found"}), 404 From 3c852bdbb03a058a9e4c93c09f2c06c9348cbba1 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:23:32 -0700 Subject: [PATCH 15/24] Fix Search, no jumpy video --- .../components/recordings/dashcam_routes.css | 3 +- .../recordings/dashcam_routes_helpers.js | 46 ++++++++++++++++--- .../tests/test_dashcam_routes_helpers.py | 34 ++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 318faa6ed..324d36a3d 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1254,7 +1254,8 @@ } .dashcam-video-shell { - aspect-ratio: 16 / 9; + /* Match qcamera.ts so the visible frame does not widen during the full-quality swap. */ + aspect-ratio: 526 / 330; background: #07080b; overflow: hidden; position: relative; diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index 6262ad80f..e52272a9a 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -6,6 +6,16 @@ function validDate(value) { return Number.isNaN(date.getTime()) ? null : date } +export function normalizeRouteSearchText(value) { + return String(value || "") + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLocaleLowerCase() + .replace(/(\d+)(?:st|nd|rd|th)\b/g, "$1") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim() +} + export function formatRouteDate(value, locale) { const date = validDate(value) if (!date) return "Unknown date" @@ -19,8 +29,17 @@ export function normalizeRoute(route, locale) { const timestamp = route?.timestamp == null ? null : String(route.timestamp) const startedAtDate = validDate(route?.startedAt) const timestampDate = validDate(timestamp) - const displayDate = formatRouteDate(startedAtDate || timestampDate, locale) + const routeDate = startedAtDate || timestampDate + const displayDate = formatRouteDate(routeDate, locale) const isCustomName = Boolean(route?.isCustomName) || Boolean(timestamp && !timestampDate) + const displayName = isCustomName ? timestamp : displayDate + const dateAliases = routeDate ? [ + new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate), + new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric" }).format(routeDate), + new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(routeDate), + `${routeDate.getMonth() + 1}/${routeDate.getDate()}/${routeDate.getFullYear()}`, + `${routeDate.getFullYear()}-${routeDate.getMonth() + 1}-${routeDate.getDate()}`, + ] : [] return { ...route, @@ -29,8 +48,13 @@ export function normalizeRoute(route, locale) { startedAt: route?.startedAt || null, isCustomName, displayDate, - displayName: isCustomName ? timestamp : displayDate, + displayName, _startedAtMs: startedAtDate?.getTime() ?? timestampDate?.getTime() ?? null, + _searchValues: [ + normalizeRouteSearchText(route?.name), + normalizeRouteSearchText(isCustomName ? displayName : ""), + ...dateAliases.map(normalizeRouteSearchText), + ].filter(Boolean), } } @@ -96,11 +120,19 @@ export function sortRoutes(routes, sortOrder = "newest") { } export function routeMatchesSearch(route, searchQuery) { - const query = String(searchQuery || "").trim().toLocaleLowerCase() - if (!query) return true - return [route?.name, route?.timestamp, route?.displayName, route?.displayDate] - .filter(Boolean) - .some(value => String(value).toLocaleLowerCase().includes(query)) + const queryTokens = normalizeRouteSearchText(searchQuery).split(" ").filter(Boolean) + if (!queryTokens.length) return true + + const searchValues = Array.isArray(route?._searchValues) ? route._searchValues : [ + route?.name, + route?.timestamp, + route?.displayName, + route?.displayDate, + ].map(normalizeRouteSearchText).filter(Boolean) + return searchValues.some(value => { + const searchTokens = value.split(" ").filter(Boolean) + return queryTokens.every(queryToken => searchTokens.some(searchToken => searchToken.includes(queryToken))) + }) } export function buildRouteView(routes, options = {}) { diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index 2c7007570..a52963950 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -15,6 +15,7 @@ import pytest HELPERS_PATH = Path(__file__).resolve().parent.parent / "assets" / "components" / "recordings" / "dashcam_routes_helpers.js" COMPONENT_PATH = HELPERS_PATH.with_name("dashcam_routes.js") +COMPONENT_CSS_PATH = HELPERS_PATH.with_name("dashcam_routes.css") # node infers ESM from `export` syntax in a bare .js file from 22.7 on, so the helpers # need no package.json and stay a normal asset next to the component that imports them. @@ -78,6 +79,12 @@ def test_route_titles_and_custom_name_badges_are_reactive(): assert source.count('${() => route.isCustomName ? html`') >= 4 +def test_player_shell_matches_low_quality_video_aspect_ratio(): + source = COMPONENT_CSS_PATH.read_text(encoding="utf-8") + + assert "aspect-ratio: 526 / 330;" in source + + def test_groups_routes_into_today_yesterday_dates_and_unknown(): groups = evaluate(''' const routes = [ @@ -129,6 +136,33 @@ def test_searches_custom_names_displayed_dates_and_route_ids(): assert matches == [1, 1, 1, 0] +def test_search_matches_partial_title_tokens_and_friendly_dates_as_the_user_types(): + result = evaluate(''' + const routes = [ + route("0000006a--9f0a7bdf9c", "2026-08-27T08:00:00Z", { timestamp: "Test_31", isCustomName: true }), + route("0000006b--9f0a7bdf9d", "2026-08-28T08:00:00Z", { timestamp: "Morning drive", isCustomName: true }), + route("0000006c--9f0a7bdf9e", "2026-09-27T08:00:00Z", { timestamp: "Test_4", isCustomName: true }), + ] + return ["test", "test 3", "aug", "aug 2", "aug 27th", "8/27"] + .map(searchQuery => buildRouteView(routes, { searchQuery }).matching.map(item => item.name)) + ''') + + assert result == [ + ["0000006c--9f0a7bdf9e", "0000006a--9f0a7bdf9c"], + ["0000006a--9f0a7bdf9c"], + ["0000006b--9f0a7bdf9d", "0000006a--9f0a7bdf9c"], + ["0000006b--9f0a7bdf9d", "0000006a--9f0a7bdf9c"], + ["0000006a--9f0a7bdf9c"], + ["0000006a--9f0a7bdf9c"], + ] + + +def test_route_search_input_updates_on_every_keystroke(): + source = COMPONENT_PATH.read_text(encoding="utf-8") + + assert '@input="${event => { state.searchQuery = event.target.value }}"' in source + + def test_filters_preserved_routes_before_applying_the_render_limit(): view = evaluate(''' const routes = Array.from({ length: MAX_RENDERED_ROUTES + 25 }, (_, index) => route( From a0aa48f82a45ae5e64065bfa9e98aff3faa7cfd0 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:36:07 -0700 Subject: [PATCH 16/24] Fix search and sort --- .../components/recordings/dashcam_routes.js | 18 ++++++------- .../recordings/dashcam_routes_helpers.js | 12 +++++++-- .../tests/test_dashcam_routes_helpers.py | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 246a8fe11..b89ae66c7 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -825,11 +825,11 @@ export function RouteRecordings() {
@@ -862,7 +862,7 @@ export function RouteRecordings() {

${group.label}

${group.routes.length} ${group.routes.length === 1 ? "drive" : "drives"}
- ${() => state.viewMode === "grid" ? html`
+ ${state.viewMode === "grid" ? html`
${group.routes.map(route => html`
@@ -901,7 +901,7 @@ export function RouteRecordings() {
- `)} + `.key(route.name))}
` : html`
${group.routes.map(route => html`
@@ -940,9 +940,9 @@ export function RouteRecordings() {
- `)} + `.key(route.name))}
`} - `)} + `.key(group.key))}
${view.truncated ? html`

Showing the first ${MAX_RENDERED_ROUTES} of ${view.matching.length} matching routes.

` : ""}` }} diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index e52272a9a..364631a1e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -40,6 +40,11 @@ export function normalizeRoute(route, locale) { `${routeDate.getMonth() + 1}/${routeDate.getDate()}/${routeDate.getFullYear()}`, `${routeDate.getFullYear()}-${routeDate.getMonth() + 1}-${routeDate.getDate()}`, ] : [] + const timeAliases = routeDate ? [ + new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(routeDate), + new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true }).format(routeDate), + `${String(routeDate.getHours()).padStart(2, "0")}:${String(routeDate.getMinutes()).padStart(2, "0")}`, + ] : [] return { ...route, @@ -54,6 +59,7 @@ export function normalizeRoute(route, locale) { normalizeRouteSearchText(route?.name), normalizeRouteSearchText(isCustomName ? displayName : ""), ...dateAliases.map(normalizeRouteSearchText), + ...timeAliases.map(normalizeRouteSearchText), ].filter(Boolean), } } @@ -120,7 +126,8 @@ export function sortRoutes(routes, sortOrder = "newest") { } export function routeMatchesSearch(route, searchQuery) { - const queryTokens = normalizeRouteSearchText(searchQuery).split(" ").filter(Boolean) + const normalizedQuery = normalizeRouteSearchText(searchQuery) + const queryTokens = normalizedQuery.split(" ").filter(Boolean) if (!queryTokens.length) return true const searchValues = Array.isArray(route?._searchValues) ? route._searchValues : [ @@ -130,8 +137,9 @@ export function routeMatchesSearch(route, searchQuery) { route?.displayDate, ].map(normalizeRouteSearchText).filter(Boolean) return searchValues.some(value => { + if (value.includes(normalizedQuery)) return true const searchTokens = value.split(" ").filter(Boolean) - return queryTokens.every(queryToken => searchTokens.some(searchToken => searchToken.includes(queryToken))) + return queryTokens.every(queryToken => searchTokens.some(searchToken => searchToken.startsWith(queryToken))) }) } diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index a52963950..fb9b5a4c4 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -79,6 +79,14 @@ def test_route_titles_and_custom_name_badges_are_reactive(): assert source.count('${() => route.isCustomName ? html`') >= 4 +def test_sort_order_select_and_route_items_are_keyed_and_reactive(): + source = COMPONENT_PATH.read_text(encoding="utf-8") + + assert ' + ' in source + assert '' in source - assert "state.routes = [...state.routes]" in source + assert 'data-view-key="${renderKey}"' in source assert ".key(group.key)" in source assert ".key(route.name)" in source @@ -264,6 +264,19 @@ def test_date_sorts_still_group_by_day(): assert labels == ["August 22, 2026", "August 20, 2026"] +def test_route_view_render_key_changes_with_displayed_order_and_mode(): + result = evaluate(''' + const routes = [{ name: "a" }, { name: "b" }] + return [ + routeViewRenderKey(routes, "newest", "list"), + routeViewRenderKey([...routes].reverse(), "oldest", "list"), + routeViewRenderKey(routes, "newest", "grid"), + ] + ''') + + assert result == ["list:newest:a,b", "list:oldest:b,a", "grid:newest:a,b"] + + def test_grouping_an_empty_list_yields_no_groups(): assert evaluate(""" return [ From 695573c057cec0dfa70268abda555f179b42c7fd Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:01:49 -0700 Subject: [PATCH 20/24] Stop the video jumping --- .../components/recordings/dashcam_routes.css | 11 ++++-- .../components/recordings/dashcam_routes.js | 35 ++++++++++++++++--- .../tests/test_dashcam_routes_helpers.py | 6 ++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index ec6b95bfb..b36d0ef6a 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1257,6 +1257,7 @@ /* Match qcamera.ts so the visible frame does not widen during the full-quality swap. */ aspect-ratio: 526 / 330; background: #07080b; + contain: layout paint; overflow: hidden; position: relative; width: 100%; @@ -1264,12 +1265,16 @@ .dashcam-player-overlay .dashcam-player .dashcam-video-shell video { border-radius: 0; - height: 100%; + display: block; + height: 100% !important; inset: 0; - /* Keep the visible frame size fixed when qcamera swaps to a wider full stream. */ + max-height: none; + max-width: none; + /* Crop both encodes into the same viewport instead of letting intrinsic media sizing win. */ object-fit: cover; + object-position: center center; position: absolute; - width: 100%; + width: 100% !important; } .dashcam-player-overlay .dashcam-player .dashcam-video-shell video.active { diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 7ac402262..ceab760af 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -359,6 +359,30 @@ async function openOverlay(route) { let upgradeTimer = null let isUpgrading = false + const clearDeferredNativeControls = video => { + if (typeof video._dashcamControlsCleanup === "function") video._dashcamControlsCleanup() + } + const setNativeControls = (video, enabled) => { + clearDeferredNativeControls(video) + video.controls = enabled + } + const deferNativeControlsUntilInteraction = video => { + clearDeferredNativeControls(video) + video.controls = false + + const restore = () => { + if (video !== activeVideo || !overlay) return + setNativeControls(video, true) + } + const events = ["pointermove", "pointerdown", "focus"] + const cleanup = () => { + events.forEach(eventName => video.removeEventListener(eventName, restore)) + delete video._dashcamControlsCleanup + } + video._dashcamControlsCleanup = cleanup + events.forEach(eventName => video.addEventListener(eventName, restore)) + } + const setPlayerMessage = (message, isError = false) => { playerState.textContent = message playerState.hidden = !message @@ -386,6 +410,7 @@ async function openOverlay(route) { upgradeController = null stagingVideo.pause() stagingVideo.removeAttribute("src") + setNativeControls(stagingVideo, false) stagingVideo.load() } @@ -452,11 +477,12 @@ async function openOverlay(route) { activeVideo.classList.remove("active") activeVideo.classList.add("staging") - activeVideo.controls = false + setNativeControls(activeVideo, false) stagingVideo.classList.remove("staging") stagingVideo.classList.add("active") - stagingVideo.controls = true + if (isPlaying) deferNativeControlsUntilInteraction(stagingVideo) + else setNativeControls(stagingVideo, true) const oldActive = activeVideo activeVideo = stagingVideo @@ -559,11 +585,11 @@ async function openOverlay(route) { stagingVideo.removeAttribute("src") stagingVideo.classList.remove("active") stagingVideo.classList.add("staging") - stagingVideo.controls = false + setNativeControls(stagingVideo, false) activeVideo.classList.remove("staging") activeVideo.classList.add("active") - activeVideo.controls = true + setNativeControls(activeVideo, true) activeVideo.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) activeVideo.load() if (autoplay) activeVideo.play().catch(() => {}) @@ -737,6 +763,7 @@ function closeOverlay() { document.removeEventListener("keydown", overlay._closeOnEscape) const videos = overlay.querySelectorAll("video") videos.forEach(v => { + v._dashcamControlsCleanup?.() v.pause() v.removeAttribute("src") v.load() diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index 1e1a5c4c9..75a3dae8a 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -90,9 +90,15 @@ def test_sort_order_select_and_route_items_are_keyed_and_reactive(): def test_player_shell_keeps_both_quality_levels_at_one_fixed_size(): source = COMPONENT_CSS_PATH.read_text(encoding="utf-8") + component = COMPONENT_PATH.read_text(encoding="utf-8") assert "aspect-ratio: 526 / 330;" in source + assert "contain: layout paint;" in source + assert "height: 100% !important;" in source + assert "width: 100% !important;" in source assert "object-fit: cover;" in source + assert "deferNativeControlsUntilInteraction(stagingVideo)" in component + assert "stagingVideo.controls = true" not in component def test_groups_routes_into_today_yesterday_dates_and_unknown(): From c02bb05113cb9077268d70bd68bef2172cdb43e9 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:11:03 -0700 Subject: [PATCH 21/24] Search is still hard --- .../recordings/dashcam_routes_helpers.js | 22 ++++++++++--------- .../tests/test_dashcam_routes_helpers.py | 18 +++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index fc8600882..2cd9ecf48 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -151,18 +151,20 @@ export function routeMatchesSearch(route, searchQuery) { compactQuery.length >= 8 || (compactQuery.length >= 4 && /\d/.test(compactQuery) && /[a-f]/.test(compactQuery)) )) const valuesFor = key => Array.isArray(searchIndex[key]) ? searchIndex[key] : [] - const searchValues = [ - ...valuesFor("titles"), - ...(isDateQuery ? valuesFor("dates") : []), - ...(isTimeQuery ? valuesFor("times") : []), - ...(isIdQuery ? valuesFor("ids") : []), - ] - - return searchValues.some(value => { + const matchesValue = (value, dateValue = false) => { if (value.includes(normalizedQuery)) return true const searchTokens = value.split(" ").filter(Boolean) - return queryTokens.every(queryToken => searchTokens.some(searchToken => searchToken.startsWith(queryToken))) - }) + return queryTokens.every(queryToken => searchTokens.some(searchToken => { + // In a date query, "20" is a day prefix, not a match for the year "2026". + if (dateValue && /^\d{1,2}$/.test(queryToken) && /^\d{4}$/.test(searchToken)) return false + return searchToken.startsWith(queryToken) + })) + } + + return valuesFor("titles").some(value => matchesValue(value)) + || (isDateQuery && valuesFor("dates").some(value => matchesValue(value, true))) + || (isTimeQuery && valuesFor("times").some(value => matchesValue(value))) + || (isIdQuery && valuesFor("ids").some(value => matchesValue(value))) } export function buildRouteView(routes, options = {}) { diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index 75a3dae8a..b9ea6f616 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -173,6 +173,24 @@ def test_search_matches_partial_title_tokens_and_friendly_dates_as_the_user_type ] +def test_partial_day_does_not_match_the_four_digit_year(): + result = evaluate(''' + const routes = Array.from({ length: 9 }, (_, offset) => { + const day = 19 + offset + return route(`aug-${day}`, `2026-08-${day}T12:00:00Z`) + }) + return { + partial: buildRouteView(routes, { searchQuery: "aug 2" }).matching.map(item => item.name), + complete: buildRouteView(routes, { searchQuery: "aug 20" }).matching.map(item => item.name), + } + ''') + + assert result == { + "partial": ["aug-27", "aug-26", "aug-25", "aug-24", "aug-23", "aug-22", "aug-21", "aug-20"], + "complete": ["aug-20"], + } + + def test_route_search_input_updates_on_every_keystroke(): source = COMPONENT_PATH.read_text(encoding="utf-8") From 386a6f92160b1cbc06dfdf978a35822cd12ef466 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:12:25 -0700 Subject: [PATCH 22/24] Matching the field of view - pretty please --- .../assets/components/recordings/dashcam_routes.css | 5 +++++ .../assets/components/recordings/dashcam_routes.js | 4 ++++ .../system/the_galaxy/tests/test_dashcam_routes_helpers.py | 3 +++ 3 files changed, 12 insertions(+) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index b36d0ef6a..e5656fd84 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1277,6 +1277,11 @@ width: 100% !important; } +.dashcam-player-overlay .dashcam-player .dashcam-video-shell.qcamera-framing video { + /* qcamera.ts is a full-frame, non-uniform scale; mirror it to prevent a crop/FOV jump. */ + object-fit: fill; +} + .dashcam-player-overlay .dashcam-player .dashcam-video-shell video.active { opacity: 1; pointer-events: auto; diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index ceab760af..4dad08354 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -340,6 +340,7 @@ async function openOverlay(route) { const [videoA, videoB] = overlay.querySelectorAll(".dashcam-video") let activeVideo = videoA let stagingVideo = videoB + const videoShell = overlay.querySelector(".dashcam-video-shell") const playerState = overlay.querySelector(".dashcam-player-state") const segmentBar = overlay.querySelector(".dashcam-segment-bar") const segmentSelect = overlay.querySelector(".segment-select") @@ -579,6 +580,9 @@ async function openOverlay(route) { cancelUpgrade() wantsPlayback = autoplay showingPreview = preview === undefined ? supportsLowQuality(camera) : preview + // qcamera.ts scales the complete road frame to 526x330. Keep that same mapping + // after the full stream arrives so its slightly wider aspect ratio is not cropped. + videoShell.classList.toggle("qcamera-framing", showingPreview) if (message) setPlayerMessage(message) stagingVideo.pause() diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index b9ea6f616..af26f4569 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -97,6 +97,9 @@ def test_player_shell_keeps_both_quality_levels_at_one_fixed_size(): assert "height: 100% !important;" in source assert "width: 100% !important;" in source assert "object-fit: cover;" in source + assert ".dashcam-video-shell.qcamera-framing video" in source + assert "object-fit: fill;" in source + assert 'videoShell.classList.toggle("qcamera-framing", showingPreview)' in component assert "deferNativeControlsUntilInteraction(stagingVideo)" in component assert "stagingVideo.controls = true" not in component From 6c1ec6798f393bf245e3a06692b1c5fbb0038868 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:25:51 -0700 Subject: [PATCH 23/24] Better Messaging --- .../components/recordings/dashcam_routes.js | 16 ++++++----- .../recordings/dashcam_routes_helpers.js | 8 ++++++ .../tests/test_dashcam_routes_helpers.py | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 4dad08354..749697c7e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -12,6 +12,7 @@ import { groupRoutesForView, MAX_RENDERED_ROUTES, normalizeRoute, + routeMetadataErrorMessage, routeViewRenderKey, } from "/assets/components/recordings/dashcam_routes_helpers.js" @@ -738,8 +739,8 @@ async function openOverlay(route) { try { const response = await fetch(`/api/routes/${route.name}`) - if (!response.ok) throw new Error(`Route metadata request failed (${response.status})`) - const data = await response.json() + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(routeMetadataErrorMessage(response.status, data?.error)) segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter(url => typeof url === "string") : [] const availableCameras = ["forward", "wide", "driver"].filter(camera => data.available_cameras?.includes(camera)) if (!segments.length) throw new Error("No video segments are stored for this route") @@ -884,12 +885,15 @@ export function RouteRecordings() { const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder }) const groups = groupRoutesForView(view.visible, state.sortOrder) const renderKey = routeViewRenderKey(view.visible, state.sortOrder, state.viewMode) + const hasActiveSearch = Boolean(state.searchQuery.trim()) return html` -
- ${view.matching.length} matching drive${view.matching.length === 1 ? "" : "s"} - ${state.loading ? html`Loading ${state.progress} of ${state.total}` : html`${state.routes.length} total local`} -
+ ${state.loading || hasActiveSearch ? html` +
+ ${hasActiveSearch ? html`${view.matching.length} matching drive${view.matching.length === 1 ? "" : "s"}` : ""} + ${state.loading ? html`Loading routes` : ""} +
+ ` : ""} ${state.error ? html`

${state.error}

` : ""} ${state.isDeletingAll ? html`

Deleting routes…

` : ""} ${!view.visible.length && state.loading ? html`

Finding local routes…

` : ""} diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index 2cd9ecf48..3ea1a613e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -264,6 +264,14 @@ export function cameraVideoUrl(segmentUrl, camera, quality) { return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url } +export function routeMetadataErrorMessage(status, serverError) { + if (status === 404) { + return "This route is no longer available on this device. Its local video segments may have been deleted or moved." + } + const detail = String(serverError || "").trim() + return detail || `Could not load route details (${status}).` +} + // loggerd only writes qcamera.ts alongside the road camera. export function supportsLowQuality(camera) { return camera === "forward" diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py index af26f4569..631463f6e 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes_helpers.py @@ -200,6 +200,17 @@ def test_route_search_input_updates_on_every_keystroke(): assert '@input="${event => { state.searchQuery = event.target.value }}"' in source +def test_route_summary_only_shows_loading_or_active_search_status(): + source = COMPONENT_PATH.read_text(encoding="utf-8") + + assert "const hasActiveSearch = Boolean(state.searchQuery.trim())" in source + assert '${state.loading || hasActiveSearch ? html`' in source + assert '${hasActiveSearch ? html`${view.matching.length} matching drive' in source + assert '${state.loading ? html`Loading routes` : ""}' in source + assert "Loading ${state.progress} of ${state.total}" not in source + assert "total local" not in source + + def test_search_indexes_the_displayed_time_for_every_route(): result = evaluate(''' const routes = [ @@ -368,6 +379,22 @@ def test_camera_video_url_carries_an_optional_quality_tier(): assert result["low"] == "/video/0000006a--9f0a7bdf9c--7?camera=forward&quality=low" +def test_route_metadata_errors_explain_missing_local_segments(): + result = evaluate(''' + return { + missing: routeMetadataErrorMessage(404, "Route not found"), + backend: routeMetadataErrorMessage(400, "Invalid route name"), + fallback: routeMetadataErrorMessage(503), + } + ''') + + assert result == { + "missing": "This route is no longer available on this device. Its local video segments may have been deleted or moved.", + "backend": "Invalid route name", + "fallback": "Could not load route details (503).", + } + + def test_only_the_road_camera_has_a_preview(): """loggerd writes qcamera.ts alongside the road camera only.""" assert evaluate('return ["forward", "wide", "driver"].map(supportsLowQuality)') == [True, False, False] From 4d281c22d7560c13dc91a9c03d9325d6fb0f4837 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:38:47 -0700 Subject: [PATCH 24/24] Stream Mp4 downloads --- .../the_galaxy/tests/test_dashcam_routes.py | 43 +++++++++++++++++- starpilot/system/the_galaxy/the_galaxy.py | 6 ++- starpilot/system/the_galaxy/utilities.py | 45 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py index 2f8d4e86f..50f02839f 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -487,6 +487,46 @@ def test_video_cache_never_evicts_the_entry_being_written(monkeypatch, tmp_path) assert keep.exists() +def test_combined_video_streams_fragmented_mp4_without_a_full_cache_file(monkeypatch, tmp_path): + cache = tmp_path / "video_cache" + first = tmp_path / "first.hevc" + second = tmp_path / "second.hevc" + first.write_bytes(b"first") + second.write_bytes(b"second") + monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache) + captured = {} + + class FakeProcess: + def __init__(self): + self.stdout = io.BytesIO(b"streamed-video") + self.returncode = None + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def poll(self): + return self.returncode + + def popen(command, **kwargs): + captured["command"] = command + list_path = Path(command[command.index("-i") + 1]) + captured["list_path"] = list_path + captured["list_contents"] = list_path.read_text() + return FakeProcess() + + monkeypatch.setattr(utilities.subprocess, "Popen", popen) + + payload = b"".join(utilities.ffmpeg_stream_concatenated_mp4([first, second], chunk_size=4)) + + assert payload == b"streamed-video" + assert "frag_keyframe+empty_moov+default_base_moof" in captured["command"] + assert captured["command"][-1] == "pipe:1" + assert captured["list_contents"] == f"file '{first}'\nfile '{second}'\n" + assert not captured["list_path"].exists() + assert not list(cache.glob("*.mp4")) + + def _stub_remux(monkeypatch, tmp_path, payload=b"wrapped-video"): """Stand in for the ffmpeg remux, returning a real file so send_file can stream it.""" wrapped = tmp_path / "wrapped.mp4" @@ -501,7 +541,7 @@ def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path): (segment / "fcamera.hevc").write_bytes(b"hevc") monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) _stub_remux(monkeypatch, tmp_path) - monkeypatch.setattr(utilities, "ffmpeg_concat_segments_to_mp4", lambda paths, cache_key=None: io.BytesIO(b"combined-video")) + monkeypatch.setattr(utilities, "ffmpeg_stream_concatenated_mp4", lambda paths: iter((b"combined-", b"video"))) client = _make_client(monkeypatch, tmp_path) metadata = client.get(f"/api/routes/{ROUTE_NAME}") @@ -519,6 +559,7 @@ def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path): assert combined_video.status_code == 200 assert combined_video.mimetype == "video/mp4" assert combined_video.data == b"combined-video" + assert combined_video.headers["X-Accel-Buffering"] == "no" def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path): diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 92771d6a6..1d781f67a 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -6482,8 +6482,10 @@ def setup(app): if not input_files: return {"error": "No video files found"}, 404 - mp4_file = utilities.ffmpeg_concat_segments_to_mp4(input_files, cache_key=f"{name}-{camera}") - return send_file(mp4_file, mimetype="video/mp4") + response = Response(utilities.ffmpeg_stream_concatenated_mp4(input_files), mimetype="video/mp4") + response.headers["Cache-Control"] = "no-store" + response.headers["X-Accel-Buffering"] = "no" + return response return {"error": "Route not found"}, 404 diff --git a/starpilot/system/the_galaxy/utilities.py b/starpilot/system/the_galaxy/utilities.py index 7cd952daa..5525eba5e 100644 --- a/starpilot/system/the_galaxy/utilities.py +++ b/starpilot/system/the_galaxy/utilities.py @@ -12,6 +12,7 @@ import signal import socket import subprocess import sys +import tempfile import threading import time @@ -749,6 +750,50 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None): return open(cache_path, "rb") + +def ffmpeg_stream_concatenated_mp4(input_files, chunk_size=256 * 1024): + """Stream-copy camera segments as fragmented MP4 without building a full cache file.""" + if not input_files: + raise ValueError("No input files provided for concatenation") + + VIDEO_CACHE_PATH.mkdir(exist_ok=True) + with tempfile.NamedTemporaryFile("w", suffix=".txt", prefix="route-download-", dir=VIDEO_CACHE_PATH, delete=False) as list_file: + list_path = Path(list_file.name) + for segment in input_files: + list_file.write(f"file '{Path(segment)}'\n") + + process = None + try: + process = subprocess.Popen( + [FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", + "-i", str(list_path), "-c", "copy", "-movflags", "frag_keyframe+empty_moov+default_base_moof", + "-f", "mp4", "pipe:1"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + while True: + chunk = process.stdout.read(chunk_size) + if not chunk: + break + yield chunk + if process.wait() != 0: + raise ValueError("Could not stream the combined route video") + finally: + if process is not None: + if process.stdout is not None: + process.stdout.close() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + try: + list_path.unlink() + except OSError: + pass + def ffmpeg_mp4_wrap_to_path(filename): """Remux one raw .hevc segment to mp4 and return the cache path.