heldar_kernel/services/
snapshot.rs1use std::process::Stdio;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use tokio::process::Command;
9
10use crate::camera_url;
11use crate::error::{AppError, AppResult};
12use crate::models::{Camera, Segment};
13use crate::state::AppState;
14
15pub async fn snapshot_at(
17 state: &AppState,
18 camera_id: &str,
19 at: DateTime<Utc>,
20) -> AppResult<Vec<u8>> {
21 let seg: Option<Segment> = sqlx::query_as::<_, Segment>(
22 "SELECT * FROM segments
23 WHERE camera_id = ? AND start_time <= ? AND end_time >= ?
24 ORDER BY start_time DESC LIMIT 1",
25 )
26 .bind(camera_id)
27 .bind(at)
28 .bind(at)
29 .fetch_optional(&state.pool)
30 .await?;
31 let seg = seg.ok_or_else(|| AppError::NotFound("no footage at that timestamp".into()))?;
32
33 let seg_ids = vec![seg.id.clone()];
35 crate::repo::set_segments_locked(&state.pool, &seg_ids, true).await;
36
37 let outcome: AppResult<Vec<u8>> = async {
38 let offset = ((at - seg.start_time).num_milliseconds() as f64 / 1000.0).max(0.0);
39 let mut cmd = Command::new(&state.cfg.ffmpeg_bin);
40 cmd.kill_on_drop(true)
41 .args(["-hide_banner", "-loglevel", "error"])
42 .args(["-ss", &format!("{offset:.3}")])
43 .arg("-i")
44 .arg(&seg.path)
45 .args([
46 "-frames:v",
47 "1",
48 "-q:v",
49 "3",
50 "-f",
51 "image2",
52 "-c:v",
53 "mjpeg",
54 "pipe:1",
55 ])
56 .stdin(Stdio::null())
57 .stdout(Stdio::piped())
58 .stderr(Stdio::piped());
59
60 let out = tokio::time::timeout(Duration::from_secs(20), cmd.output())
61 .await
62 .map_err(|_| AppError::Other(anyhow::anyhow!("snapshot timed out")))?
63 .map_err(|e| AppError::Other(e.into()))?;
64
65 if !out.status.success() || out.stdout.is_empty() {
66 return Err(AppError::Other(anyhow::anyhow!(
67 "snapshot failed: {}",
68 String::from_utf8_lossy(&out.stderr).trim()
69 )));
70 }
71 Ok(out.stdout)
72 }
73 .await;
74
75 crate::repo::set_segments_locked(&state.pool, &seg_ids, false).await;
76 outcome
77}
78
79pub async fn snapshot_live(state: &AppState, camera_id: &str) -> AppResult<Vec<u8>> {
81 let cam: Option<Camera> = sqlx::query_as::<_, Camera>("SELECT * FROM cameras WHERE id = ?")
82 .bind(camera_id)
83 .fetch_optional(&state.pool)
84 .await?;
85 let cam = cam.ok_or_else(|| AppError::NotFound(format!("camera {camera_id} not found")))?;
86 let url = camera_url::stream_url(&cam, "sub")
87 .or_else(|| camera_url::record_url(&cam))
88 .ok_or_else(|| AppError::BadRequest("camera has no stream URL".into()))?;
89
90 let mut cmd = Command::new(&state.cfg.ffmpeg_bin);
91 cmd.kill_on_drop(true)
92 .args([
93 "-hide_banner",
94 "-loglevel",
95 "error",
96 "-rtsp_transport",
97 "tcp",
98 "-timeout",
99 "10000000",
100 ])
101 .arg("-i")
102 .arg(&url)
103 .args([
104 "-frames:v",
105 "1",
106 "-q:v",
107 "3",
108 "-f",
109 "image2",
110 "-c:v",
111 "mjpeg",
112 "pipe:1",
113 ])
114 .stdin(Stdio::null())
115 .stdout(Stdio::piped())
116 .stderr(Stdio::piped());
117
118 let out = tokio::time::timeout(Duration::from_secs(20), cmd.output())
119 .await
120 .map_err(|_| AppError::Other(anyhow::anyhow!("live snapshot timed out")))?
121 .map_err(|e| AppError::Other(e.into()))?;
122
123 if !out.status.success() || out.stdout.is_empty() {
124 let stderr = String::from_utf8_lossy(&out.stderr);
126 return Err(AppError::Other(anyhow::anyhow!(
127 "live snapshot failed: {}",
128 camera_url::mask_url(stderr.trim())
129 )));
130 }
131 Ok(out.stdout)
132}