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