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/// Fetch a camera's segments, honoring either or both optional bounds (open-ended otherwise).
141async fn fetch_segments_in_range(
142    pool: &sqlx::SqlitePool,
143    id: &str,
144    from: Option<DateTime<Utc>>,
145    to: Option<DateTime<Utc>>,
146) -> AppResult<Vec<Segment>> {
147    let segments = sqlx::query_as::<_, Segment>(
148        "SELECT * FROM segments
149         WHERE camera_id = ?
150           AND (? IS NULL OR start_time < ?)
151           AND (? IS NULL OR end_time > ?)
152         ORDER BY start_time ASC",
153    )
154    .bind(id)
155    .bind(to)
156    .bind(to)
157    .bind(from)
158    .bind(from)
159    .fetch_all(pool)
160    .await?;
161    Ok(segments)
162}
163
164/// Coalesce contiguous segments into availability ranges (gaps > tolerance split a range).
165fn coalesce(segments: &[Segment]) -> Vec<TimelineRange> {
166    let mut ranges: Vec<TimelineRange> = Vec::new();
167    for s in segments {
168        if let Some(last) = ranges.last_mut() {
169            if (s.start_time - last.end).num_seconds() <= GAP_TOLERANCE_S {
170                if s.end_time > last.end {
171                    last.end = s.end_time;
172                    last.seconds = (last.end - last.start).num_milliseconds() as f64 / 1000.0;
173                }
174                continue;
175            }
176        }
177        ranges.push(TimelineRange {
178            start: s.start_time,
179            end: s.end_time,
180            seconds: (s.end_time - s.start_time).num_milliseconds() as f64 / 1000.0,
181        });
182    }
183    ranges
184}
185
186async fn timeline(
187    State(st): State<AppState>,
188    principal: Principal,
189    Path(id): Path<String>,
190    Query(q): Query<RangeQuery>,
191) -> AppResult<Json<Timeline>> {
192    principal.require(principal.can_view(), "view recording timeline")?;
193    let _ = load_camera(&st.pool, &id).await?;
194    let (from, to) = parse_range(&q)?;
195    let segments = fetch_segments_in_range(&st.pool, &id, from, to).await?;
196    let segment_count = segments.len();
197    let ranges = coalesce(&segments);
198    let recorded_seconds = ranges.iter().map(|r| r.seconds).sum();
199    Ok(Json(Timeline {
200        camera_id: id,
201        from,
202        to,
203        ranges,
204        recorded_seconds,
205        segment_count,
206    }))
207}
208
209#[derive(Debug, Serialize)]
210struct Gaps {
211    camera_id: String,
212    from: Option<DateTime<Utc>>,
213    to: Option<DateTime<Utc>>,
214    gaps: Vec<TimelineRange>,
215    gap_count: usize,
216    total_gap_seconds: f64,
217}
218
219/// Report holes in recording coverage (the spans between coalesced availability ranges).
220async fn gaps(
221    State(st): State<AppState>,
222    principal: Principal,
223    Path(id): Path<String>,
224    Query(q): Query<RangeQuery>,
225) -> AppResult<Json<Gaps>> {
226    principal.require(principal.can_view(), "view recording gaps")?;
227    let _ = load_camera(&st.pool, &id).await?;
228    let (from, to) = parse_range(&q)?;
229    let segments = fetch_segments_in_range(&st.pool, &id, from, to).await?;
230    let ranges = coalesce(&segments);
231
232    let mk = |start: DateTime<Utc>, end: DateTime<Utc>| -> Option<TimelineRange> {
233        let seconds = (end - start).num_milliseconds() as f64 / 1000.0;
234        (seconds > GAP_TOLERANCE_S as f64).then_some(TimelineRange {
235            start,
236            end,
237            seconds,
238        })
239    };
240
241    let mut gaps = Vec::new();
242    // Leading edge: a hole between the requested window start and the first coverage (or the whole
243    // window when there is no coverage at all).
244    if let Some(f) = from {
245        match ranges.first() {
246            None => gaps.extend(to.and_then(|t| mk(f, t))),
247            Some(first) => gaps.extend(mk(f, first.start)),
248        }
249    }
250    // Interior holes between coalesced coverage ranges.
251    for w in ranges.windows(2) {
252        gaps.extend(mk(w[0].end, w[1].start));
253    }
254    // Trailing edge: a hole between the last coverage and the requested window end.
255    if let (Some(t), Some(last)) = (to, ranges.last()) {
256        gaps.extend(mk(last.end, t));
257    }
258    let total_gap_seconds = gaps.iter().map(|g| g.seconds).sum();
259    let gap_count = gaps.len();
260    Ok(Json(Gaps {
261        camera_id: id,
262        from,
263        to,
264        gaps,
265        gap_count,
266        total_gap_seconds,
267    }))
268}