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] 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(`
-
`
}
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))