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
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        // Use recording_type (e.g. "shared_screen_with_speaker_view") as the
158        // filename discriminator; multiple files of the same file_type (e.g.
159        // two MP4 tracks) each have a distinct recording_type and won't
160        // overwrite each other.
161        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        // Sanitize the meeting_id so API-sourced values cannot introduce path
227        // traversal components.
228        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        // Verify the file was written to disk
477        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}