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#[derive(Debug, Serialize)]
30pub struct SegmentView {
31 #[serde(flatten)]
32 seg: Segment,
33 url: String,
35}
36
37impl SegmentView {
38 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 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 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
134const GAP_TOLERANCE_S: i64 = 2;
136
137async 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
161fn 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
214async 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 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 for w in ranges.windows(2) {
245 gaps.extend(mk(w[0].end, w[1].start));
246 }
247 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}