1use crate::api::{ApiError, ZoomClient};
2use crate::output::{self, OutputConfig};
3
4pub async fn list(
5 client: &mut ZoomClient,
6 out: &OutputConfig,
7 user: &str,
8 from: Option<&str>,
9 to: Option<&str>,
10) -> Result<(), ApiError> {
11 let result = client.list_recordings(user, from, to).await?;
12 let recordings = result.recordings.clone().unwrap_or_default();
13
14 if out.json {
15 out.print_data(&serde_json::to_string_pretty(&result).expect("serialize"));
16 } else {
17 if recordings.is_empty() {
18 out.print_message("No recordings found.");
19 return Ok(());
20 }
21 let rows: Vec<Vec<String>> = recordings
22 .iter()
23 .map(|r| {
24 vec![
25 r.id.to_string(),
26 r.topic.clone(),
27 output::format_timestamp(&r.start_time),
28 r.duration
29 .map(|d| format!("{d} min"))
30 .unwrap_or_else(|| "-".into()),
31 r.recording_files
32 .as_ref()
33 .map(|f| f.len().to_string())
34 .unwrap_or_else(|| "0".into()),
35 ]
36 })
37 .collect();
38 out.print_data(&output::table(
39 &["ID", "TOPIC", "START TIME", "DURATION", "FILES"],
40 &rows,
41 ));
42 if let Some(total) = result.total_records {
43 out.print_message(&format!("{total} recording(s) total"));
44 }
45 }
46 Ok(())
47}
48
49pub async fn get(
50 client: &mut ZoomClient,
51 out: &OutputConfig,
52 meeting_id: &str,
53) -> Result<(), ApiError> {
54 let recording = client.get_recording(meeting_id).await?;
55
56 if out.json {
57 out.print_data(&serde_json::to_string_pretty(&recording).expect("serialize"));
58 } else {
59 out.print_data(&output::kv_block(&[
60 ("id", recording.id.to_string()),
61 ("topic", recording.topic.clone()),
62 ("start_time", output::format_timestamp(&recording.start_time)),
63 (
64 "duration",
65 recording
66 .duration
67 .map(|d| format!("{d} min"))
68 .unwrap_or_else(|| "-".into()),
69 ),
70 (
71 "files",
72 recording
73 .recording_files
74 .as_ref()
75 .map(|f| f.len().to_string())
76 .unwrap_or_else(|| "0".into()),
77 ),
78 ]));
79 if let Some(files) = &recording.recording_files
80 && !files.is_empty()
81 {
82 out.print_data("\nFiles:");
83 for f in files {
84 let file_type = f.file_type.clone().unwrap_or_else(|| "unknown".into());
85 let size = f
86 .file_size
87 .map(|s| format!("{:.1} MB", s as f64 / 1_048_576.0))
88 .unwrap_or_else(|| "-".into());
89 out.print_data(&format!(
90 " {} {} {}",
91 file_type,
92 size,
93 f.download_url.as_deref().unwrap_or("-")
94 ));
95 }
96 }
97 }
98 Ok(())
99}
100
101pub async fn download(
102 client: &mut ZoomClient,
103 out: &OutputConfig,
104 meeting_id: &str,
105 out_dir: &str,
106) -> Result<(), ApiError> {
107 let recording = client.get_recording(meeting_id).await?;
108 let files = recording.recording_files.unwrap_or_default();
109
110 if files.is_empty() {
111 out.print_message("No recording files found for this meeting.");
112 return Ok(());
113 }
114
115 let dir = std::path::Path::new(out_dir);
116 if !dir.exists() {
117 std::fs::create_dir_all(dir)
118 .map_err(|e| ApiError::Other(format!("Cannot create output directory: {e}")))?;
119 }
120
121 let mut downloaded = 0usize;
122 for file in &files {
123 let download_url = match &file.download_url {
124 Some(u) => u,
125 None => continue,
126 };
127
128 let file_type = file.file_type.as_deref().unwrap_or("unknown");
129 let ext = match file_type {
130 "MP4" => "mp4",
131 "M4A" => "m4a",
132 "CHAT" => "txt",
133 "TRANSCRIPT" => "vtt",
134 "TIMELINE" => "json",
135 _ => "bin",
136 };
137
138 let safe_topic: String = recording
139 .topic
140 .chars()
141 .map(|c| {
142 if c.is_alphanumeric() || c == '-' || c == '_' {
143 c
144 } else {
145 '_'
146 }
147 })
148 .collect();
149
150 let discriminator = file
155 .recording_type
156 .as_deref()
157 .unwrap_or(file_type)
158 .to_lowercase()
159 .replace(' ', "_");
160 let filename = format!(
161 "{}_{}_{}.{}",
162 safe_topic,
163 recording.start_time.replace(':', "-").replace('T', "_"),
164 discriminator,
165 ext
166 );
167 let dest = dir.join(&filename);
168
169 out.print_message(&format!("Downloading {} → {}", file_type, dest.display()));
170
171 let bytes = client.download_recording_file(download_url, &dest).await?;
172 out.print_message(&format!(" {:.1} MB written", bytes as f64 / 1_048_576.0));
173 downloaded += 1;
174 }
175
176 out.print_result(
177 &serde_json::json!({"downloaded": downloaded, "meeting_id": meeting_id, "out_dir": out_dir}),
178 &format!("{downloaded} file(s) downloaded to {out_dir}"),
179 );
180 Ok(())
181}
182
183pub async fn control(
184 client: &mut ZoomClient,
185 out: &OutputConfig,
186 meeting_id: u64,
187 action: &str,
188) -> Result<(), ApiError> {
189 client.control_recording(meeting_id, action).await?;
190 out.print_result(
191 &serde_json::json!({"action": action, "meeting_id": meeting_id}),
192 &format!("Recording {action}ed for meeting {meeting_id}."),
193 );
194 Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::api::ZoomClient;
201 use wiremock::matchers::{method, path};
202 use wiremock::{Mock, MockServer, ResponseTemplate};
203
204 fn test_out() -> OutputConfig {
205 OutputConfig {
206 json: true,
207 quiet: true,
208 }
209 }
210
211 #[tokio::test]
212 async fn recordings_list_empty_is_ok() {
213 let server = MockServer::start().await;
214 Mock::given(method("GET"))
215 .and(path("/v2/users/me/recordings"))
216 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
217 "meetings": [],
218 "total_records": 0
219 })))
220 .mount(&server)
221 .await;
222
223 let mut client =
224 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
225 list(&mut client, &test_out(), "me", None, None)
226 .await
227 .unwrap();
228 }
229
230 #[tokio::test]
231 async fn recordings_get_returns_recording_with_files() {
232 let server = MockServer::start().await;
233 Mock::given(method("GET"))
234 .and(path("/v2/meetings/abc123/recordings"))
235 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
236 "id": 123456789,
237 "topic": "Design Review",
238 "start_time": "2026-04-01T10:00:00Z",
239 "duration": 45,
240 "recording_files": [
241 {
242 "id": "rf-001",
243 "file_type": "MP4",
244 "file_size": 52428800,
245 "download_url": "https://zoom.us/rec/download/abc123.mp4",
246 "status": "completed"
247 }
248 ]
249 })))
250 .mount(&server)
251 .await;
252
253 let mut client =
254 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
255 get(&mut client, &test_out(), "abc123").await.unwrap();
256 }
257
258 #[tokio::test]
259 async fn recordings_control_start_sends_patch() {
260 let server = MockServer::start().await;
261 Mock::given(method("PATCH"))
262 .and(path("/v2/live_meetings/999888777/recordings"))
263 .respond_with(ResponseTemplate::new(204))
264 .mount(&server)
265 .await;
266
267 let mut client =
268 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
269 control(&mut client, &test_out(), 999888777, "start")
270 .await
271 .unwrap();
272 }
273
274 #[tokio::test]
275 async fn recordings_control_invalid_meeting_returns_error() {
276 let server = MockServer::start().await;
277 Mock::given(method("PATCH"))
278 .and(path("/v2/live_meetings/111/recordings"))
279 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
280 "code": 3001,
281 "message": "Meeting does not exist"
282 })))
283 .mount(&server)
284 .await;
285
286 let mut client =
287 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
288 let err = control(&mut client, &test_out(), 111, "start")
289 .await
290 .unwrap_err();
291 assert!(matches!(err, ApiError::Api { .. }));
292 }
293
294 #[tokio::test]
295 async fn recordings_get_not_found_propagates() {
296 let server = MockServer::start().await;
297 Mock::given(method("GET"))
298 .and(path("/v2/meetings/nope/recordings"))
299 .respond_with(ResponseTemplate::new(404))
300 .mount(&server)
301 .await;
302
303 let mut client =
304 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
305 let err = get(&mut client, &test_out(), "nope").await.unwrap_err();
306 assert!(matches!(err, ApiError::NotFound(_)));
307 }
308}