mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-30 12:43:46 +08:00
Routes Page - Cleanup
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
<div class="dialog-box">
|
||||
<p>Delete “${route.timestamp}”?</p>
|
||||
<p>Delete “${escapeHtml(routeLabel(route))}”?</p>
|
||||
<div class="dialog-buttons">
|
||||
<button class="btn btn-cancel" ...>Cancel</button>
|
||||
<button class="btn btn-danger btn-del" ...>Delete</button>
|
||||
<button class="btn-cancel" type="button">Cancel</button>
|
||||
<button class="btn-del" type="button">Delete</button>
|
||||
</div>
|
||||
</div>`)
|
||||
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(`
|
||||
<div class="dialog-box">
|
||||
<p>Rename "${route.timestamp}"</p>
|
||||
<input class="rn-input" value="${route.timestamp}" />
|
||||
<p>Rename “${escapeHtml(routeLabel(route))}”</p>
|
||||
<input class="rn-input" value="${escapeHtml(routeLabel(route))}" aria-label="New route name">
|
||||
<div class="dialog-buttons">
|
||||
<button class="btn-cancel">Cancel</button>
|
||||
<button class="btn-reset">Reset</button>
|
||||
<button class="btn-save">Save</button>
|
||||
<button class="btn-cancel" type="button">Cancel</button>
|
||||
<button class="btn-reset" type="button">Reset</button>
|
||||
<button class="btn-save" type="button">Save</button>
|
||||
</div>
|
||||
</div>`);
|
||||
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`, {
|
||||
</div>`)
|
||||
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(`
|
||||
<section class="dialog-box route-logs-dialog" role="dialog" aria-modal="true" aria-labelledby="route-logs-title">
|
||||
<header class="route-logs-toolbar">
|
||||
<div><p class="route-logs-eyebrow">Route data</p><h2 id="route-logs-title">Full logs</h2></div>
|
||||
<button class="route-logs-close" type="button" aria-label="Close full logs">×</button>
|
||||
</header>
|
||||
<div class="route-logs-content" aria-live="polite"><p class="route-logs-message">Looking for full logs…</p></div>
|
||||
</section>`)
|
||||
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 = `
|
||||
<div class="route-logs-summary">
|
||||
<div><strong>${data.segments.length} segment${data.segments.length === 1 ? "" : "s"}</strong><span>${formatBytes(data.totalBytes)} total download</span></div>
|
||||
<a class="route-logs-download-all" href="/api/routes/${route.name}/logs/download" download>Download all <span>.tar</span></a>
|
||||
</div>
|
||||
<ul class="route-logs-list">
|
||||
${data.segments.map(segment => `<li>
|
||||
<div class="route-log-details"><strong>Segment ${Number(segment.segmentNum)}</strong><span>${formatBytes(segment.bytes)} · ${escapeHtml(segment.filename)}</span></div>
|
||||
<a class="route-log-download" href="${escapeHtml(segment.url)}" download>Download</a>
|
||||
</li>`).join("")}
|
||||
</ul>`
|
||||
}
|
||||
|
||||
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 = `<p class="route-logs-message route-logs-error">${escapeHtml(data.error || "Could not read logs.")}</p>`
|
||||
return
|
||||
}
|
||||
setCachedLogs(data)
|
||||
if (content.isConnected) renderLogs(data)
|
||||
})
|
||||
.catch(error => {
|
||||
if (content.isConnected) content.innerHTML = `<p class="route-logs-message route-logs-error">Could not reach the device: ${escapeHtml(error.message)}</p>`
|
||||
})
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="media-player-content">
|
||||
<div class="media-player-title">
|
||||
<span>${route.timestamp}</span>
|
||||
<i class="bi bi-pencil-fill action-rename-icon"></i>
|
||||
<section class="media-player-content dashcam-player" role="dialog" aria-modal="true" aria-label="Route player">
|
||||
<header class="dashcam-player-header">
|
||||
<div><p class="dashcam-player-eyebrow">Dashcam route</p><h2 class="media-player-title-text">${escapeHtml(routeLabel(route))}</h2></div>
|
||||
<button class="dashcam-player-close action-close" type="button" aria-label="Close player">×</button>
|
||||
</header>
|
||||
<div class="dashcam-video-shell">
|
||||
<video controls muted playsinline></video>
|
||||
<div class="dashcam-player-state" role="status">Loading route metadata…</div>
|
||||
</div>
|
||||
<video controls autoplay muted>
|
||||
<source src="/thumbnails/${route.name}--0/preview.png" type="video/mp4">
|
||||
</video>
|
||||
<div class="button-row">
|
||||
<button class="close-button action-close">Close</button>
|
||||
<button class="close-button camera-button active" data-camera="forward">Forward</button>
|
||||
<button class="close-button camera-button" data-camera="wide">Wide</button>
|
||||
<button class="close-button camera-button" data-camera="driver">Driver</button>
|
||||
<button class="close-button action-download">Download</button>
|
||||
<button class="close-button action-logs">Logs</button>
|
||||
<button class="close-button action-delete">Delete</button>
|
||||
<div class="dashcam-segment-status" aria-live="polite" hidden></div>
|
||||
<div class="dashcam-camera-selector" aria-label="Camera selector">
|
||||
<button class="camera-button" data-camera="forward" type="button" disabled hidden>Forward</button>
|
||||
<button class="camera-button" data-camera="wide" type="button" disabled hidden>Wide</button>
|
||||
<button class="camera-button" data-camera="driver" type="button" disabled hidden>Driver</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
<div class="dashcam-player-actions">
|
||||
<button class="action-download" type="button" disabled><i class="bi bi-download"></i> Download</button>
|
||||
<button class="action-logs" type="button"><i class="bi bi-file-earmark-arrow-down"></i> Logs</button>
|
||||
<button class="action-rename" type="button"><i class="bi bi-pencil"></i> Rename</button>
|
||||
<button class="action-delete" type="button"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
</section>`
|
||||
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(`
|
||||
<section class="dialog-box route-logs-dialog" role="dialog" aria-modal="true" aria-labelledby="route-logs-title">
|
||||
<header class="route-logs-toolbar">
|
||||
<div>
|
||||
<p class="route-logs-eyebrow">Route data</p>
|
||||
<h2 id="route-logs-title">Full logs</h2>
|
||||
</div>
|
||||
<button class="route-logs-close" type="button" aria-label="Close full logs">×</button>
|
||||
</header>
|
||||
<div class="route-logs-content" aria-live="polite">
|
||||
<p class="route-logs-message">Looking for full logs…</p>
|
||||
</div>
|
||||
</section>`);
|
||||
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 = `
|
||||
<div class="route-logs-summary">
|
||||
<div>
|
||||
<strong>${data.segments.length} segment${data.segments.length === 1 ? "" : "s"}</strong>
|
||||
<span>${formatBytes(data.totalBytes)} total download</span>
|
||||
</div>
|
||||
<a class="route-logs-download-all" href="/api/routes/${route.name}/logs/download" download>
|
||||
Download all <span>.tar</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="route-logs-list">
|
||||
${data.segments.map(segment => `
|
||||
<li>
|
||||
<div class="route-log-details">
|
||||
<strong>Segment ${segment.segmentNum}</strong>
|
||||
<span>${formatBytes(segment.bytes)} · ${segment.filename}</span>
|
||||
</div>
|
||||
<a class="route-log-download" href="${segment.url}" download>Download</a>
|
||||
</li>`).join("")}
|
||||
</ul>`;
|
||||
};
|
||||
|
||||
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 = `<p class="route-logs-message route-logs-error">${data.error || "Could not read logs."}</p>`;
|
||||
}
|
||||
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 = `<p class="route-logs-message route-logs-error">Could not reach the device: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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() {
|
||||
<div class="tunnel-notice-icon">🛰️</div>
|
||||
<h3 class="tunnel-notice-title">Dashcam Routes Unavailable via Galaxy</h3>
|
||||
<p class="tunnel-notice-body">Loading dashcam routes requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
|
||||
</div>
|
||||
`;
|
||||
</div>`
|
||||
}
|
||||
|
||||
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute);
|
||||
if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute)
|
||||
|
||||
return html`
|
||||
<div class="screen-recordings-wrapper">
|
||||
<div class="screen-recordings-widget">
|
||||
<div class="screen-recordings-title">Dashcam Routes</div>
|
||||
<button
|
||||
class="show-preserved-button"
|
||||
@click="${() => (state.showPreservedOnly = !state.showPreservedOnly)}"
|
||||
disabled="${() => (state.loading && state.routes.length === 0) || false}"
|
||||
> ${() => (state.showPreservedOnly ? "Show All" : "Show Only Preserved Routes")}
|
||||
</button>
|
||||
<div class="screen-recordings-wrapper dashcam-routes-wrapper">
|
||||
<section class="screen-recordings-widget dashcam-library">
|
||||
<header class="dashcam-library-header">
|
||||
<div><p class="dashcam-library-eyebrow">Local recordings</p><h1>Dashcam Routes</h1></div>
|
||||
<button class="dashcam-refresh-button" type="button" @click="${refresh}" disabled="${() => state.loading || false}"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
|
||||
</header>
|
||||
|
||||
<div class="dashcam-toolbar">
|
||||
<label class="dashcam-search">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="search" placeholder="Search names, dates, or route IDs" aria-label="Search routes" value="${() => state.searchQuery}" @input="${event => { state.searchQuery = event.target.value }}">
|
||||
</label>
|
||||
<label class="dashcam-sort">
|
||||
<span>Sort</span>
|
||||
<select @change="${event => { state.sortOrder = event.target.value }}">
|
||||
<option value="newest" selected="${() => state.sortOrder === "newest" || false}">Newest</option>
|
||||
<option value="oldest" selected="${() => state.sortOrder === "oldest" || false}">Oldest</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="dashcam-preserved-filter" type="button" aria-pressed="${() => String(state.showPreservedOnly)}" @click="${() => { state.showPreservedOnly = !state.showPreservedOnly }}">
|
||||
<i class="bi bi-heart-fill"></i> ${() => state.showPreservedOnly ? "Preserved only" : "All routes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${() => {
|
||||
const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved);
|
||||
|
||||
if (routesToShow.length === 0) {
|
||||
if (state.loading && state.total > 0) {
|
||||
return html`<p class="screen-recordings-message">Processing Routes: ${state.progress} of ${state.total}</p>`;
|
||||
}
|
||||
if (state.loading && !state.isDeletingAll) {
|
||||
return html`<p class="screen-recordings-message">Loading...</p>`;
|
||||
}
|
||||
if (state.isDeletingAll) {
|
||||
return html`<p class="screen-recordings-message">Deleting routes...</p>`;
|
||||
}
|
||||
if (state.showPreservedOnly) {
|
||||
return html`<p class="screen-recordings-message">No preserved routes...</p>`;
|
||||
}
|
||||
if (state.error) {
|
||||
return html`<p class="screen-recordings-message">${state.error}</p>`;
|
||||
}
|
||||
return html`<p class="screen-recordings-message">No routes found...</p>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="screen-recordings-grid">
|
||||
${routesToShow.map(
|
||||
route => html`
|
||||
<div
|
||||
class="recording-card"
|
||||
@click="${() => {
|
||||
state.selectedRoute = route;
|
||||
}}"
|
||||
>
|
||||
<div class="preserved-icon" @click="${e => togglePreserved(route, e)}">
|
||||
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
|
||||
</div>
|
||||
<div class="recording-preview-container">
|
||||
<img
|
||||
src="${route.png}"
|
||||
class="recording-preview recording-preview-png"
|
||||
style="display:block;"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<p class="recording-filename">${route.timestamp}</p>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder })
|
||||
const groups = groupRoutesByDate(view.visible)
|
||||
return html`
|
||||
<div class="dashcam-results-summary" aria-live="polite">
|
||||
<span>${view.matching.length} matching route${view.matching.length === 1 ? "" : "s"}</span>
|
||||
${state.loading ? html`<span>Loading ${state.progress} of ${state.total}</span>` : html`<span>${state.routes.length} total</span>`}
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
${() => state.truncated ? html`
|
||||
<p class="screen-recordings-message">Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.</p>
|
||||
` : ""}
|
||||
${() => {
|
||||
if (state.routes.length > 0) {
|
||||
return html`
|
||||
<button
|
||||
class="delete-all-button"
|
||||
@click="${() => (state.showDeleteAllModal = true)}"
|
||||
disabled="${() => state.isDeletingAll || false}"
|
||||
> ${() => (state.isDeletingAll ? "Deleting..." : "Delete All Routes")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}}
|
||||
</div>
|
||||
${state.error ? html`<p class="screen-recordings-message dashcam-error">${state.error}</p>` : ""}
|
||||
${state.isDeletingAll ? html`<p class="screen-recordings-message">Deleting routes…</p>` : ""}
|
||||
${!view.visible.length && state.loading ? html`<div class="dashcam-loading"><span></span><p>Finding local routes…</p></div>` : ""}
|
||||
${!view.visible.length && !state.loading && !state.isDeletingAll ? html`<div class="dashcam-empty-state"><i class="bi bi-camera-reels"></i><p>${state.routes.length ? "No routes match these filters." : "No routes found."}</p></div>` : ""}
|
||||
<div class="dashcam-date-groups">
|
||||
${groups.map(group => html`
|
||||
<section class="dashcam-date-group">
|
||||
<h2>${group.label}</h2>
|
||||
<div class="screen-recordings-grid dashcam-routes-grid">
|
||||
${group.routes.map(route => html`
|
||||
<article class="recording-card dashcam-route-card" @click="${() => { state.selectedRoute = route }}">
|
||||
<button class="preserved-icon" type="button" aria-label="${route.is_preserved ? "Remove preservation" : "Preserve route"}" title="${route.is_preserved ? "Preserved" : "Not preserved"}" @click="${event => togglePreserved(route, event)}">
|
||||
${() => html`<i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i>`}
|
||||
</button>
|
||||
<div class="recording-preview-container dashcam-preview">
|
||||
<span class="dashcam-preview-fallback"><i class="bi bi-camera-video"></i><small>Preview unavailable</small></span>
|
||||
<img src="${route.png}" class="recording-preview" loading="lazy" alt="" @error="${thumbnailFailed}">
|
||||
</div>
|
||||
<div class="dashcam-card-body">
|
||||
<h3>${route.displayName}</h3>
|
||||
${route.isCustomName ? html`<p class="dashcam-card-date">${route.displayDate}</p>` : ""}
|
||||
<p class="dashcam-card-details"><span>${formatApproxDuration(route.approxDurationSeconds)}</span><span>${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"}</span></p>
|
||||
<p class="dashcam-preserve-status ${route.is_preserved ? "preserved" : ""}"><i class="bi ${route.is_preserved ? "bi-heart-fill" : "bi-heart"}"></i> ${route.is_preserved ? "Preserved" : "Not preserved"}</p>
|
||||
</div>
|
||||
</article>`)}
|
||||
</div>
|
||||
</section>`)}
|
||||
</div>
|
||||
${view.truncated ? html`<p class="screen-recordings-message">Showing the first ${MAX_RENDERED_ROUTES} of ${view.matching.length} matching routes.</p>` : ""}`
|
||||
}}
|
||||
|
||||
${() => state.routes.length ? html`
|
||||
<footer class="dashcam-danger-zone">
|
||||
<div><strong>Delete all local routes</strong><span>Preserved routes are included.</span></div>
|
||||
<button class="delete-all-button" type="button" @click="${() => { state.showDeleteAllModal = true }}" disabled="${() => state.isDeletingAll || false}">${() => state.isDeletingAll ? "Deleting…" : "Delete All"}</button>
|
||||
</footer>` : ""}
|
||||
</section>
|
||||
${() => 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"
|
||||
}) : ""}
|
||||
</div>
|
||||
`;
|
||||
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",
|
||||
}) : ""}
|
||||
</div>`
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 <segment>/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/<name>", 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/<name>/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/<name>/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/<name>", 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/<path:file_path>", 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/<path>", methods=["GET"])
|
||||
def get_video(path):
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user