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#[derive(Debug, Serialize)]
31pub struct SegmentView {
32 #[serde(flatten)]
33 seg: Segment,
34 url: String,
36}
37
38impl SegmentView {
39 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 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 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
137const GAP_TOLERANCE_S: i64 = 2;
139
140const SEGMENT_PAGE: i64 = 5000;
143
144async 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
191fn 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
246async 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 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 for w in ranges.windows(2) {
279 gaps.extend(mk(w[0].end, w[1].start));
280 }
281 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}