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
140async 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
164fn 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
219async 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 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 for w in ranges.windows(2) {
252 gaps.extend(mk(w[0].end, w[1].start));
253 }
254 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}