1use crate::api::{ApiError, ZoomClient};
2use crate::output::{self, OutputConfig};
3
4fn sanitize_path_component(s: &str) -> String {
9 s.chars()
10 .map(|c| {
11 if c.is_alphanumeric() || c == '-' || c == '_' {
12 c.to_ascii_lowercase()
13 } else {
14 '_'
15 }
16 })
17 .collect()
18}
19
20pub async fn list(
21 client: &mut ZoomClient,
22 out: &OutputConfig,
23 user: &str,
24 from: Option<&str>,
25 to: Option<&str>,
26) -> Result<(), ApiError> {
27 let result = client.list_recordings(user, from, to).await?;
28 let recordings = result.recordings.as_deref().unwrap_or_default();
29
30 if out.json {
31 out.print_data(&serde_json::to_string_pretty(&result).expect("serialize"));
32 } else {
33 if recordings.is_empty() {
34 out.print_message("No recordings found.");
35 return Ok(());
36 }
37 let rows: Vec<Vec<String>> = recordings
38 .iter()
39 .map(|r| {
40 vec![
41 r.id.to_string(),
42 r.topic.clone(),
43 output::format_timestamp(&r.start_time),
44 r.duration
45 .map(|d| format!("{d} min"))
46 .unwrap_or_else(|| "-".into()),
47 r.recording_files
48 .as_ref()
49 .map(|f| f.len().to_string())
50 .unwrap_or_else(|| "0".into()),
51 ]
52 })
53 .collect();
54 out.print_data(&output::table(
55 &["ID", "TOPIC", "START TIME", "DURATION", "FILES"],
56 &rows,
57 ));
58 if let Some(total) = result.total_records {
59 out.print_message(&format!("{total} recording(s) total"));
60 }
61 }
62 Ok(())
63}
64
65pub async fn get(
66 client: &mut ZoomClient,
67 out: &OutputConfig,
68 meeting_id: &str,
69) -> Result<(), ApiError> {
70 let recording = client.get_recording(meeting_id).await?;
71
72 if out.json {
73 out.print_data(&serde_json::to_string_pretty(&recording).expect("serialize"));
74 } else {
75 out.print_data(&output::kv_block(&[
76 ("id", recording.id.to_string()),
77 ("topic", recording.topic.clone()),
78 (
79 "start_time",
80 output::format_timestamp(&recording.start_time),
81 ),
82 (
83 "duration",
84 recording
85 .duration
86 .map(|d| format!("{d} min"))
87 .unwrap_or_else(|| "-".into()),
88 ),
89 (
90 "files",
91 recording
92 .recording_files
93 .as_ref()
94 .map(|f| f.len().to_string())
95 .unwrap_or_else(|| "0".into()),
96 ),
97 ]));
98 if let Some(files) = &recording.recording_files
99 && !files.is_empty()
100 {
101 out.print_data("\nFiles:");
102 for f in files {
103 let file_type = f.file_type.clone().unwrap_or_else(|| "unknown".into());
104 let size = f
105 .file_size
106 .map(|s| format!("{:.1} MB", s as f64 / 1_048_576.0))
107 .unwrap_or_else(|| "-".into());
108 out.print_data(&format!(
109 " {} {} {}",
110 file_type,
111 size,
112 f.download_url.as_deref().unwrap_or("-")
113 ));
114 }
115 }
116 }
117 Ok(())
118}
119
120pub async fn download(
121 client: &mut ZoomClient,
122 out: &OutputConfig,
123 meeting_id: &str,
124 out_dir: &str,
125) -> Result<(), ApiError> {
126 let recording = client.get_recording(meeting_id).await?;
127 let files = recording.recording_files.unwrap_or_default();
128
129 if files.is_empty() {
130 out.print_message("No recording files found for this meeting.");
131 return Ok(());
132 }
133
134 let dir = std::path::Path::new(out_dir);
135 if !dir.exists() {
136 std::fs::create_dir_all(dir)
137 .map_err(|e| ApiError::Other(format!("Cannot create output directory: {e}")))?;
138 }
139
140 let safe_topic = sanitize_path_component(&recording.topic);
141 let mut downloaded = 0usize;
142 for file in &files {
143 let download_url = match &file.download_url {
144 Some(u) => u,
145 None => continue,
146 };
147
148 let file_type = file.file_type.as_deref().unwrap_or("unknown");
149 let ext = match file_type {
150 "MP4" => "mp4",
151 "M4A" => "m4a",
152 "CHAT" => "txt",
153 "TRANSCRIPT" => "vtt",
154 "TIMELINE" => "json",
155 _ => "bin",
156 };
157 let discriminator =
162 sanitize_path_component(file.recording_type.as_deref().unwrap_or(file_type));
163 let filename = format!(
164 "{}_{}_{}.{}",
165 safe_topic,
166 recording.start_time.replace(':', "-").replace('T', "_"),
167 discriminator,
168 ext
169 );
170 let dest = dir.join(&filename);
171
172 out.print_message(&format!("Downloading {} → {}", file_type, dest.display()));
173
174 let bytes = client.download_recording_file(download_url, &dest).await?;
175 out.print_message(&format!(" {:.1} MB written", bytes as f64 / 1_048_576.0));
176 downloaded += 1;
177 }
178
179 out.print_result(
180 &serde_json::json!({"downloaded": downloaded, "meeting_id": meeting_id, "out_dir": out_dir}),
181 &format!("{downloaded} file(s) downloaded to {out_dir}"),
182 );
183 Ok(())
184}
185
186pub async fn transcript(
187 client: &mut ZoomClient,
188 out: &OutputConfig,
189 meeting_id: &str,
190 out_dir: &str,
191) -> Result<(), ApiError> {
192 let recording = client.get_recording(meeting_id).await?;
193 let files = recording.recording_files.unwrap_or_default();
194
195 let transcript_files: Vec<_> = files
196 .iter()
197 .filter(|f| matches!(f.file_type.as_deref(), Some("TRANSCRIPT") | Some("CHAT")))
198 .collect();
199
200 if transcript_files.is_empty() {
201 out.print_message("No transcript files found for this meeting.");
202 return Ok(());
203 }
204
205 let dir = std::path::Path::new(out_dir);
206 if !dir.exists() {
207 std::fs::create_dir_all(dir)
208 .map_err(|e| ApiError::Other(format!("Cannot create output directory: {e}")))?;
209 }
210
211 let mut paths: Vec<String> = Vec::new();
212
213 for file in transcript_files {
214 let download_url = match &file.download_url {
215 Some(u) => u,
216 None => continue,
217 };
218
219 let file_type = file.file_type.as_deref().unwrap_or("unknown");
220 let ext = file
221 .file_extension
222 .as_deref()
223 .map(|e| e.to_ascii_lowercase())
224 .unwrap_or_else(|| file_type.to_ascii_lowercase());
225
226 let safe_id = sanitize_path_component(meeting_id);
229 let discriminator =
230 sanitize_path_component(file.recording_type.as_deref().unwrap_or(file_type));
231
232 let filename = format!("{safe_id}_{discriminator}.{ext}");
233 let dest = dir.join(&filename);
234
235 out.print_message(&format!("Downloading {} → {}", file_type, dest.display()));
236
237 let bytes = client.download_recording_file(download_url, &dest).await?;
238 out.print_message(&format!(" {:.1} MB written", bytes as f64 / 1_048_576.0));
239 paths.push(dest.display().to_string());
240 }
241
242 out.print_result(
243 &serde_json::json!({"files_downloaded": paths.len(), "paths": paths}),
244 &paths
245 .iter()
246 .map(|p| format!("Downloaded: {p}"))
247 .collect::<Vec<_>>()
248 .join("\n"),
249 );
250
251 Ok(())
252}
253
254pub async fn delete(
255 client: &mut ZoomClient,
256 out: &OutputConfig,
257 meeting_id: &str,
258 trash: bool,
259) -> Result<(), ApiError> {
260 client.delete_recording(meeting_id, trash).await?;
261 let disposition = if trash {
262 "moved to trash"
263 } else {
264 "permanently deleted"
265 };
266 out.print_result(
267 &serde_json::json!({"deleted": true, "meeting_id": meeting_id, "trash": trash}),
268 &format!("Recordings for meeting {meeting_id} {disposition}."),
269 );
270 Ok(())
271}
272
273pub async fn control(
274 client: &mut ZoomClient,
275 out: &OutputConfig,
276 meeting_id: u64,
277 action: &str,
278) -> Result<(), ApiError> {
279 client.control_recording(meeting_id, action).await?;
280 out.print_result(
281 &serde_json::json!({"action": action, "meeting_id": meeting_id}),
282 &format!("Recording {action}ed for meeting {meeting_id}."),
283 );
284 Ok(())
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::api::ZoomClient;
291 use wiremock::matchers::{method, path};
292 use wiremock::{Mock, MockServer, ResponseTemplate};
293
294 fn test_out() -> OutputConfig {
295 OutputConfig::for_test()
296 }
297
298 #[tokio::test]
299 async fn recordings_list_empty_is_ok() {
300 let server = MockServer::start().await;
301 Mock::given(method("GET"))
302 .and(path("/v2/users/me/recordings"))
303 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
304 "meetings": [],
305 "total_records": 0
306 })))
307 .mount(&server)
308 .await;
309
310 let mut client =
311 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
312 list(&mut client, &test_out(), "me", None, None)
313 .await
314 .unwrap();
315 }
316
317 #[tokio::test]
318 async fn recordings_get_returns_recording_with_files() {
319 let server = MockServer::start().await;
320 Mock::given(method("GET"))
321 .and(path("/v2/meetings/abc123/recordings"))
322 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
323 "id": 123456789,
324 "topic": "Design Review",
325 "start_time": "2026-04-01T10:00:00Z",
326 "duration": 45,
327 "recording_files": [
328 {
329 "id": "rf-001",
330 "file_type": "MP4",
331 "file_size": 52428800,
332 "download_url": "https://zoom.us/rec/download/abc123.mp4",
333 "status": "completed"
334 }
335 ]
336 })))
337 .mount(&server)
338 .await;
339
340 let mut client =
341 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
342 get(&mut client, &test_out(), "abc123").await.unwrap();
343 }
344
345 #[tokio::test]
346 async fn recordings_control_start_sends_patch() {
347 let server = MockServer::start().await;
348 Mock::given(method("PATCH"))
349 .and(path("/v2/live_meetings/999888777/recordings"))
350 .respond_with(ResponseTemplate::new(204))
351 .mount(&server)
352 .await;
353
354 let mut client =
355 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
356 control(&mut client, &test_out(), 999888777, "start")
357 .await
358 .unwrap();
359 }
360
361 #[tokio::test]
362 async fn recordings_control_invalid_meeting_returns_error() {
363 let server = MockServer::start().await;
364 Mock::given(method("PATCH"))
365 .and(path("/v2/live_meetings/111/recordings"))
366 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
367 "code": 3001,
368 "message": "Meeting does not exist"
369 })))
370 .mount(&server)
371 .await;
372
373 let mut client =
374 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
375 let err = control(&mut client, &test_out(), 111, "start")
376 .await
377 .unwrap_err();
378 assert!(matches!(err, ApiError::Api { .. }));
379 }
380
381 #[tokio::test]
382 async fn recordings_delete_moves_to_trash_by_default() {
383 let server = MockServer::start().await;
384 Mock::given(method("DELETE"))
385 .and(path("/v2/meetings/abc123/recordings"))
386 .respond_with(ResponseTemplate::new(204))
387 .mount(&server)
388 .await;
389
390 let mut client =
391 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
392 delete(&mut client, &test_out(), "abc123", true)
393 .await
394 .unwrap();
395 }
396
397 #[tokio::test]
398 async fn recordings_delete_permanent_on_no_trash() {
399 let server = MockServer::start().await;
400 Mock::given(method("DELETE"))
401 .and(path("/v2/meetings/abc123/recordings"))
402 .respond_with(ResponseTemplate::new(204))
403 .mount(&server)
404 .await;
405
406 let mut client =
407 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
408 delete(&mut client, &test_out(), "abc123", false)
409 .await
410 .unwrap();
411 }
412
413 #[tokio::test]
414 async fn recordings_delete_not_found_propagates() {
415 let server = MockServer::start().await;
416 Mock::given(method("DELETE"))
417 .and(path("/v2/meetings/nope/recordings"))
418 .respond_with(ResponseTemplate::new(404).set_body_string("Meeting not found"))
419 .mount(&server)
420 .await;
421
422 let mut client =
423 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
424 let err = delete(&mut client, &test_out(), "nope", true)
425 .await
426 .unwrap_err();
427 assert!(matches!(err, ApiError::NotFound(_)));
428 }
429
430 #[tokio::test]
431 async fn recordings_transcript_downloads_vtt_file() {
432 let server = MockServer::start().await;
433
434 Mock::given(method("GET"))
435 .and(path("/v2/meetings/mtg-abc/recordings"))
436 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
437 "id": 111222333,
438 "topic": "Team Sync",
439 "start_time": "2026-04-01T10:00:00Z",
440 "duration": 30,
441 "recording_files": [
442 {
443 "id": "rf-tr-001",
444 "file_type": "TRANSCRIPT",
445 "file_extension": "VTT",
446 "recording_type": "audio_transcript",
447 "download_url": format!("{}/download/test-transcript.vtt", server.uri()),
448 "status": "completed"
449 }
450 ]
451 })))
452 .mount(&server)
453 .await;
454
455 Mock::given(method("GET"))
456 .and(path("/download/test-transcript.vtt"))
457 .respond_with(
458 ResponseTemplate::new(200)
459 .set_body_string("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello world\n"),
460 )
461 .mount(&server)
462 .await;
463
464 let tmp = tempfile::tempdir().unwrap();
465 let mut client =
466 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
467 transcript(
468 &mut client,
469 &test_out(),
470 "mtg-abc",
471 tmp.path().to_str().unwrap(),
472 )
473 .await
474 .unwrap();
475
476 let dest = tmp.path().join("mtg-abc_audio_transcript.vtt");
478 assert!(dest.exists(), "transcript file must be written to disk");
479 }
480
481 #[tokio::test]
482 async fn recordings_get_not_found_propagates() {
483 let server = MockServer::start().await;
484 Mock::given(method("GET"))
485 .and(path("/v2/meetings/nope/recordings"))
486 .respond_with(ResponseTemplate::new(404))
487 .mount(&server)
488 .await;
489
490 let mut client =
491 ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
492 let err = get(&mut client, &test_out(), "nope").await.unwrap_err();
493 assert!(matches!(err, ApiError::NotFound(_)));
494 }
495}