Skip to main content

heldar_kernel/routes/
recordings.rs

1use axum::extract::{Path, Query, State};
2use axum::routing::get;
3use axum::{Json, Router};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::auth::Principal;
8use crate::error::{AppError, AppResult};
9use crate::models::Segment;
10use crate::routes::cameras::load_camera;
11use crate::state::AppState;
12use crate::util;
13
14pub fn router() -> Router<AppState> {
15    Router::new()
16        .route("/api/v1/cameras/{id}/segments", get(list_segments))
17        .route("/api/v1/cameras/{id}/timeline", get(timeline))
18        .route("/api/v1/cameras/{id}/gaps", get(gaps))
19}
20
21#[derive(Debug, Deserialize)]
22struct RangeQuery {
23    from: Option<String>,
24    to: Option<String>,
25    limit: Option<i64>,
26}
27
28/// A segment row plus its browser-playable media URL. Flattens the full [`Segment`] (so new model
29/// fields like `evidence_locked` flow through automatically). Reused by the incidents API.
30#[derive(Debug, Serialize)]
31pub struct SegmentView {
32    #[serde(flatten)]
33    seg: Segment,
34    /// Browser-playable URL for the segment file.
35    url: String,
36}
37
38impl SegmentView {
39    /// Build a view from a segment row, deriving its media URL from the stored path.
40    pub fn new(seg: Segment) -> Self {
41        let url = segment_url(&seg.camera_id, &seg.path);
42        SegmentView { seg, url }
43    }
44}
45
46fn segment_url(camera_id: &str, path: &str) -> String {
47    let file = std::path::Path::new(path)
48        .file_name()
49        .and_then(|s| s.to_str())
50        .unwrap_or("");
51    format!("/media/recordings/{camera_id}/{file}")
52}
53
54type OptTimeRange = (Option<DateTime<Utc>>, Option<DateTime<Utc>>);
55
56fn parse_range(q: &RangeQuery) -> AppResult<OptTimeRange> {
57    let parse = |s: &Option<String>, field: &str| -> AppResult<Option<DateTime<Utc>>> {
58        match s {
59            Some(v) => util::parse_rfc3339(v)
60                .map(Some)
61                .ok_or_else(|| AppError::BadRequest(format!("invalid `{field}` timestamp"))),
62            None => Ok(None),
63        }
64    };
65    let from = parse(&q.from, "from")?;
66    let to = parse(&q.to, "to")?;
67    if let (Some(f), Some(t)) = (from, to) {
68        if f > t {
69            return Err(AppError::BadRequest("`from` must be <= `to`".into()));
70        }
71    }
72    Ok((from, to))
73}
74
75async fn list_segments(
76    State(st): State<AppState>,
77    principal: Principal,
78    Path(id): Path<String>,
79    Query(q): Query<RangeQuery>,
80) -> AppResult<Json<Vec<SegmentView>>> {
81    principal.require(principal.can_view(), "list recording segments")?;
82    let _ = load_camera(&st.pool, &id).await?;
83    let (from, to) = parse_range(&q)?;
84    let limit = q.limit.unwrap_or(500).clamp(1, 5000);
85
86    let segments: Vec<Segment> = if from.is_none() && to.is_none() {
87        // No range: return the most recent N segments (ascending for display).
88        let mut rows = sqlx::query_as::<_, Segment>(
89            "SELECT * FROM segments WHERE camera_id = ? ORDER BY start_time DESC LIMIT ?",
90        )
91        .bind(&id)
92        .bind(limit)
93        .fetch_all(&st.pool)
94        .await?;
95        rows.reverse();
96        rows
97    } else {
98        // Honor either or both bounds (open-ended when one side is absent).
99        sqlx::query_as::<_, Segment>(
100            "SELECT * FROM segments
101             WHERE camera_id = ?
102               AND (? IS NULL OR start_time < ?)
103               AND (? IS NULL OR end_time > ?)
104             ORDER BY start_time ASC LIMIT ?",
105        )
106        .bind(&id)
107        .bind(to)
108        .bind(to)
109        .bind(from)
110        .bind(from)
111        .bind(limit)
112        .fetch_all(&st.pool)
113        .await?
114    };
115
116    let views = segments.into_iter().map(SegmentView::new).collect();
117    Ok(Json(views))
118}
119
120#[derive(Debug, Serialize)]
121struct TimelineRange {
122    start: DateTime<Utc>,
123    end: DateTime<Utc>,
124    seconds: f64,
125}
126
127#[derive(Debug, Serialize)]
128struct Timeline {
129    camera_id: String,
130    from: Option<DateTime<Utc>>,
131    to: Option<DateTime<Utc>>,
132    ranges: Vec<TimelineRange>,
133    recorded_seconds: f64,
134    segment_count: usize,
135}
136
137/// Gaps below this many seconds between segments are treated as contiguous.
138const GAP_TOLERANCE_S: i64 = 2;
139
140/// Page size for the keyset-paginated range scan. Each query is bounded (flat memory) but the loop
141/// fetches the WHOLE range, so a long timeline is never silently truncated at the tail.
142const SEGMENT_PAGE: i64 = 5000;
143
144/// Fetch ALL of a camera's segments in the range (either/both bounds optional), keyset-paginated by
145/// `(start_time, id)`. A prior version used a single `LIMIT 5000 ORDER BY start_time ASC`, which on a
146/// range with more than 5000 segments silently dropped the NEWEST ones — making timeline/gaps report
147/// a false trailing hole. Paging keeps per-query memory bounded while returning the complete range;
148/// per-camera segment counts are bounded by retention, so it terminates quickly in practice.
149async fn fetch_segments_in_range(
150    pool: &sqlx::SqlitePool,
151    id: &str,
152    from: Option<DateTime<Utc>>,
153    to: Option<DateTime<Utc>>,
154) -> AppResult<Vec<Segment>> {
155    let mut out: Vec<Segment> = Vec::new();
156    loop {
157        let (cur_start, cur_id) = match out.last() {
158            Some(s) => (Some(s.start_time), Some(s.id.clone())),
159            None => (None, None),
160        };
161        let page = sqlx::query_as::<_, Segment>(
162            "SELECT * FROM segments
163             WHERE camera_id = ?
164               AND (? IS NULL OR start_time < ?)
165               AND (? IS NULL OR end_time > ?)
166               AND (? IS NULL OR start_time > ? OR (start_time = ? AND id > ?))
167             ORDER BY start_time ASC, id ASC
168             LIMIT ?",
169        )
170        .bind(id)
171        .bind(to)
172        .bind(to)
173        .bind(from)
174        .bind(from)
175        .bind(cur_start)
176        .bind(cur_start)
177        .bind(cur_start)
178        .bind(&cur_id)
179        .bind(SEGMENT_PAGE)
180        .fetch_all(pool)
181        .await?;
182        let n = page.len() as i64;
183        out.extend(page);
184        if n < SEGMENT_PAGE {
185            break;
186        }
187    }
188    Ok(out)
189}
190
191/// Coalesce contiguous segments into availability ranges (gaps > tolerance split a range).
192fn coalesce(segments: &[Segment]) -> Vec<TimelineRange> {
193    let mut ranges: Vec<TimelineRange> = Vec::new();
194    for s in segments {
195        if let Some(last) = ranges.last_mut() {
196            if (s.start_time - last.end).num_seconds() <= GAP_TOLERANCE_S {
197                if s.end_time > last.end {
198                    last.end = s.end_time;
199                    last.seconds = (last.end - last.start).num_milliseconds() as f64 / 1000.0;
200                }
201                continue;
202            }
203        }
204        ranges.push(TimelineRange {
205            start: s.start_time,
206            end: s.end_time,
207            seconds: (s.end_time - s.start_time).num_milliseconds() as f64 / 1000.0,
208        });
209    }
210    ranges
211}
212
213async fn timeline(
214    State(st): State<AppState>,
215    principal: Principal,
216    Path(id): Path<String>,
217    Query(q): Query<RangeQuery>,
218) -> AppResult<Json<Timeline>> {
219    principal.require(principal.can_view(), "view recording timeline")?;
220    let _ = load_camera(&st.pool, &id).await?;
221    let (from, to) = parse_range(&q)?;
222    let segments = fetch_segments_in_range(&st.pool, &id, from, to).await?;
223    let segment_count = segments.len();
224    let ranges = coalesce(&segments);
225    let recorded_seconds = ranges.iter().map(|r| r.seconds).sum();
226    Ok(Json(Timeline {
227        camera_id: id,
228        from,
229        to,
230        ranges,
231        recorded_seconds,
232        segment_count,
233    }))
234}
235
236#[derive(Debug, Serialize)]
237struct Gaps {
238    camera_id: String,
239    from: Option<DateTime<Utc>>,
240    to: Option<DateTime<Utc>>,
241    gaps: Vec<TimelineRange>,
242    gap_count: usize,
243    total_gap_seconds: f64,
244}
245
246/// Report holes in recording coverage (the spans between coalesced availability ranges).
247async fn gaps(
248    State(st): State<AppState>,
249    principal: Principal,
250    Path(id): Path<String>,
251    Query(q): Query<RangeQuery>,
252) -> AppResult<Json<Gaps>> {
253    principal.require(principal.can_view(), "view recording gaps")?;
254    let _ = load_camera(&st.pool, &id).await?;
255    let (from, to) = parse_range(&q)?;
256    let segments = fetch_segments_in_range(&st.pool, &id, from, to).await?;
257    let ranges = coalesce(&segments);
258
259    let mk = |start: DateTime<Utc>, end: DateTime<Utc>| -> Option<TimelineRange> {
260        let seconds = (end - start).num_milliseconds() as f64 / 1000.0;
261        (seconds > GAP_TOLERANCE_S as f64).then_some(TimelineRange {
262            start,
263            end,
264            seconds,
265        })
266    };
267
268    let mut gaps = Vec::new();
269    // Leading edge: a hole between the requested window start and the first coverage (or the whole
270    // window when there is no coverage at all).
271    if let Some(f) = from {
272        match ranges.first() {
273            None => gaps.extend(to.and_then(|t| mk(f, t))),
274            Some(first) => gaps.extend(mk(f, first.start)),
275        }
276    }
277    // Interior holes between coalesced coverage ranges.
278    for w in ranges.windows(2) {
279        gaps.extend(mk(w[0].end, w[1].start));
280    }
281    // Trailing edge: a hole between the last coverage and the requested window end.
282    if let (Some(t), Some(last)) = (to, ranges.last()) {
283        gaps.extend(mk(last.end, t));
284    }
285    let total_gap_seconds = gaps.iter().map(|g| g.seconds).sum();
286    let gap_count = gaps.len();
287    Ok(Json(Gaps {
288        camera_id: id,
289        from,
290        to,
291        gaps,
292        gap_count,
293        total_gap_seconds,
294    }))
295}