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(`
-

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 +
+ ` + } + + 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 - -
- `; - }; - - 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))