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.as_deref().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 (
63 "start_time",
64 output::format_timestamp(&recording.start_time),
65 ),
66 (
67 "duration",
68 recording
69 .duration
70 .map(|d| format!("{d} min"))
71 .unwrap_or_else(|| "-".into()),
72 ),
73 (
74 "files",
75 recording
76 .recording_files
77 .as_ref()
78 .map(|f| f.len().to_string())
79 .unwrap_or_else(|| "0".into()),
80 ),
81 ]));
82 if let Some(files) = &recording.recording_files
83 && !files.is_empty()
84 {
85 out.print_data("\nFiles:");
86 for f in files {
87 let file_type = f.file_type.clone().unwrap_or_else(|| "unknown".into());
88 let size = f
89 .file_size
90 .map(|s| format!("{:.1} MB", s as f64 / 1_048_576.0))
91 .unwrap_or_else(|| "-".into());
92 out.print_data(&format!(
93 " {} {} {}",
94 file_type,
95 size,
96 f.download_url.as_deref().unwrap_or("-")
97 ));
98 }
99 }
100 }
101 Ok(())
102}
103
104pub async fn download(
105 client: &mut ZoomClient,
106 out: &OutputConfig,
107 meeting_id: &str,
108 out_dir: &str,
109) -> Result<(), ApiError> {
110 let recording = client.get_recording(meeting_id).await?;
111 let files = recording.recording_files.unwrap_or_default();
112
113 if files.is_empty() {
114 out.print_message("No recording files found for this meeting.");
115 return Ok(());
116 }
117
118 let dir = std::path::Path::new(out_dir);
119 if !dir.exists() {
120 std::fs::create_dir_all(dir)
121 .map_err(|e| ApiError::Other(format!("Cannot create output directory: {e}")))?;
122 }
123
124 let mut downloaded = 0usize;
125 for file in &files {
126 let download_url = match &file.download_url {
127 Some(u) => u,
128 None => continue,
129 };
130
131 let file_type = file.file_type.as_deref().unwrap_or("unknown");
132 let ext = match file_type {
133 "MP4" => "mp4",
134 "M4A" => "m4a",
135 "CHAT" => "txt",
136 "TRANSCRIPT" => "vtt",
137 "TIMELINE" => "json",
138 _ => "bin",
139 };
140
141 let safe_topic: String = recording
142 .topic
143 .chars()
144 .map(|c| {
145 if c.is_alphanumeric() || c == '-' || c == '_' {
146 c
147 } else {
148 '_'
149 }
150 })
151 .collect();
152
153 let discriminator: String = file
159 .recording_type
160 .as_deref()
161 .unwrap_or(file_type)
162 .chars()
163 .map(|c| {
164 if c.is_alphanumeric() || c == '-' || c == '_' {
165 c.to_ascii_lowercase()
166 } else {
167 '_'
168 }
169 })
170 .collect();
171 let filename = format!(
172 "{}_{}_{}.{}",
173 safe_topic,
174 recording.start_time.replace(':', "-").replace('T', "_"),
175 discriminator,
176 ext
177 );
178 let dest = dir.join(&filename);
179
180 out.print_message(&format!("Downloading {} → {}", file_type, dest.display()));
181
182 let bytes = client.download_recording_file(download_url, &dest).await?;
183 out.print_message(&format!(" {:.1} MB written", bytes as f64 / 1_048_576.0));
184 downloaded += 1;
185 }
186
187 out.print_result(
188 &serde_json::json!({"downloaded": downloaded, "meeting_id": meeting_id, "out_dir": out_dir}),
189 &format!("{downloaded} file(s) downloaded to {out_dir}"),
190 );
191 Ok(())
192}
193
194pub async fn delete(
195 client: &mut ZoomClient,
196 out: &OutputConfig,
197 meeting_id: &str,
198 trash: bool,
199) -> Result<(), ApiError> {
200 client.delete_recording(meeting_id, trash).await?;
201 let disposition = if trash {
202 "moved to trash"
203 } else {
204 "permanently deleted"
205 };
206 out.print_result(
207 &serde_json::json!({"deleted": true, "meeting_id": meeting_id, "trash": trash}),
208 &format!("Recordings for meeting {meeting_id} {disposition}."),
209 );
210 Ok(())
211}
212
213pub async fn control(
214 client: &mut ZoomClient,
215 out: &OutputConfig,
216 meeting_id: u64,
217 action: &str,
218) -> Result<(), ApiError> {
219 client.control_recording(meeting_id, action).await?;
220 out.print_result(
221 &serde_json::json!({"action": action, "meeting_id": meeting_id}),
222 &format!("Recording {action}ed for meeting {meeting_id}."),
223 );
224 Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::api::ZoomClient;
231 use wiremock::matchers::{method, path};
232 use wiremock::{Mock, MockServer, ResponseTemplate};
233
234 fn test_out() -> OutputConfig {
235 OutputConfig {
236 json: true,
237 quiet: true,
238 }
239 }
240
241 #[tokio::test]
242 async fn recordings_list_empty_is_ok() {
243 let server = MockServer::start().await;
244 Mock::given(method("GET"))
245 .and(path("/v2/users/me/recordings"))
246 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
247 "meetings": [],
248 "total_records": 0
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 list(&mut client, &test_out(), "me", None, None)
256 .await
257 .unwrap();
258 }
259
260 #[tokio::test]
261 async fn recordings_get_returns_recording_with_files() {
262 let server = MockServer::start().await;
263 Mock::given(method("GET"))
264 .and(path("/v2/meetings/abc123/recordings"))
265 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
266 "id": 123456789,
267 "topic": "Design Review",
268 "start_time": "2026-04-01T10:00:00Z",
269 "duration": 45,
270 "recording_files": [
271 {
272 "id": "rf-001",
273 "file_type": "MP4",
274 "file_size": 52428800,
275 "download_url": "https://zoom.us/rec/download/abc123.mp4",
276 "status": "completed"
277 }
278 ]
279 })))
280 .mount(&server)
281 .await;
282
283 let mut client =
284 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
285 get(&mut client, &test_out(), "abc123").await.unwrap();
286 }
287
288 #[tokio::test]
289 async fn recordings_control_start_sends_patch() {
290 let server = MockServer::start().await;
291 Mock::given(method("PATCH"))
292 .and(path("/v2/live_meetings/999888777/recordings"))
293 .respond_with(ResponseTemplate::new(204))
294 .mount(&server)
295 .await;
296
297 let mut client =
298 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
299 control(&mut client, &test_out(), 999888777, "start")
300 .await
301 .unwrap();
302 }
303
304 #[tokio::test]
305 async fn recordings_control_invalid_meeting_returns_error() {
306 let server = MockServer::start().await;
307 Mock::given(method("PATCH"))
308 .and(path("/v2/live_meetings/111/recordings"))
309 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
310 "code": 3001,
311 "message": "Meeting does not exist"
312 })))
313 .mount(&server)
314 .await;
315
316 let mut client =
317 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
318 let err = control(&mut client, &test_out(), 111, "start")
319 .await
320 .unwrap_err();
321 assert!(matches!(err, ApiError::Api { .. }));
322 }
323
324 #[tokio::test]
325 async fn recordings_delete_moves_to_trash_by_default() {
326 let server = MockServer::start().await;
327 Mock::given(method("DELETE"))
328 .and(path("/v2/meetings/abc123/recordings"))
329 .respond_with(ResponseTemplate::new(204))
330 .mount(&server)
331 .await;
332
333 let mut client =
334 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
335 delete(&mut client, &test_out(), "abc123", true)
336 .await
337 .unwrap();
338 }
339
340 #[tokio::test]
341 async fn recordings_delete_permanent_on_no_trash() {
342 let server = MockServer::start().await;
343 Mock::given(method("DELETE"))
344 .and(path("/v2/meetings/abc123/recordings"))
345 .respond_with(ResponseTemplate::new(204))
346 .mount(&server)
347 .await;
348
349 let mut client =
350 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
351 delete(&mut client, &test_out(), "abc123", false)
352 .await
353 .unwrap();
354 }
355
356 #[tokio::test]
357 async fn recordings_delete_not_found_propagates() {
358 let server = MockServer::start().await;
359 Mock::given(method("DELETE"))
360 .and(path("/v2/meetings/nope/recordings"))
361 .respond_with(ResponseTemplate::new(404).set_body_string("Meeting not found"))
362 .mount(&server)
363 .await;
364
365 let mut client =
366 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
367 let err = delete(&mut client, &test_out(), "nope", true)
368 .await
369 .unwrap_err();
370 assert!(matches!(err, ApiError::NotFound(_)));
371 }
372
373 #[tokio::test]
374 async fn recordings_get_not_found_propagates() {
375 let server = MockServer::start().await;
376 Mock::given(method("GET"))
377 .and(path("/v2/meetings/nope/recordings"))
378 .respond_with(ResponseTemplate::new(404))
379 .mount(&server)
380 .await;
381
382 let mut client =
383 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
384 let err = get(&mut client, &test_out(), "nope").await.unwrap_err();
385 assert!(matches!(err, ApiError::NotFound(_)));
386 }
387}