Routes Page - Cleanup

This commit is contained in:
dirwin31
2026-08-27 10:59:42 -07:00
parent b3c417ce05
commit 328f2d9ff7
9 changed files with 1882 additions and 564 deletions
@@ -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">&times;</button>
</header>
<div class="route-logs-content" aria-live="polite"><p class="route-logs-message">Looking for full logs&hellip;</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)} &middot; ${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">&times;</button>
</header>
<div class="dashcam-video-shell">
<video controls muted playsinline></video>
<div class="dashcam-player-state" role="status">Loading route metadata&hellip;</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">&times;</button>
</header>
<div class="route-logs-content" aria-live="polite">
<p class="route-logs-message">Looking for full logs&hellip;</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)} &middot; ${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&hellip;</p>` : ""}
${!view.visible.length && state.loading ? html`<div class="dashcam-loading"><span></span><p>Finding local routes&hellip;</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
/**
* 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
+194 -47
View File
@@ -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):
+42 -22
View File
@@ -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))