Skip to main content

zoom_cli/commands/
recordings.rs

1use crate::api::{ApiError, ZoomClient};
2use crate::output::{self, OutputConfig};
3
4/// Sanitize a string for use as a filename component.
5///
6/// Passes through alphanumeric characters, `-`, and `_` unchanged (lowercased).
7/// All other characters are replaced with `_`.
8fn 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        // Use recording_type (e.g. "shared_screen_with_speaker_view") as the
194        // filename discriminator; multiple files of the same file_type (e.g.
195        // two MP4 tracks) each have a distinct recording_type and won't
196        // overwrite each other.
197        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        // Sanitize the meeting_id so API-sourced values cannot introduce path
263        // traversal components.
264        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        // Verify the file was written to disk
533        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}