Skip to main content

zoom_cli/commands/
recordings.rs

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        // Use recording_type (e.g. "shared_screen_with_speaker_view") as the
154        // filename discriminator; multiple files of the same file_type (e.g.
155        // two MP4 tracks) each have a distinct recording_type and won't
156        // overwrite each other. Sanitize to alphanumeric plus safe punctuation
157        // so API-sourced values cannot introduce path traversal components.
158        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}