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