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.clone().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            ("start_time", output::format_timestamp(&recording.start_time)),
63            (
64                "duration",
65                recording
66                    .duration
67                    .map(|d| format!("{d} min"))
68                    .unwrap_or_else(|| "-".into()),
69            ),
70            (
71                "files",
72                recording
73                    .recording_files
74                    .as_ref()
75                    .map(|f| f.len().to_string())
76                    .unwrap_or_else(|| "0".into()),
77            ),
78        ]));
79        if let Some(files) = &recording.recording_files
80            && !files.is_empty()
81        {
82            out.print_data("\nFiles:");
83            for f in files {
84                let file_type = f.file_type.clone().unwrap_or_else(|| "unknown".into());
85                let size = f
86                    .file_size
87                    .map(|s| format!("{:.1} MB", s as f64 / 1_048_576.0))
88                    .unwrap_or_else(|| "-".into());
89                out.print_data(&format!(
90                    "  {} {} {}",
91                    file_type,
92                    size,
93                    f.download_url.as_deref().unwrap_or("-")
94                ));
95            }
96        }
97    }
98    Ok(())
99}
100
101pub async fn download(
102    client: &mut ZoomClient,
103    out: &OutputConfig,
104    meeting_id: &str,
105    out_dir: &str,
106) -> Result<(), ApiError> {
107    let recording = client.get_recording(meeting_id).await?;
108    let files = recording.recording_files.unwrap_or_default();
109
110    if files.is_empty() {
111        out.print_message("No recording files found for this meeting.");
112        return Ok(());
113    }
114
115    let dir = std::path::Path::new(out_dir);
116    if !dir.exists() {
117        std::fs::create_dir_all(dir)
118            .map_err(|e| ApiError::Other(format!("Cannot create output directory: {e}")))?;
119    }
120
121    let mut downloaded = 0usize;
122    for file in &files {
123        let download_url = match &file.download_url {
124            Some(u) => u,
125            None => continue,
126        };
127
128        let file_type = file.file_type.as_deref().unwrap_or("unknown");
129        let ext = match file_type {
130            "MP4" => "mp4",
131            "M4A" => "m4a",
132            "CHAT" => "txt",
133            "TRANSCRIPT" => "vtt",
134            "TIMELINE" => "json",
135            _ => "bin",
136        };
137
138        let safe_topic: String = recording
139            .topic
140            .chars()
141            .map(|c| {
142                if c.is_alphanumeric() || c == '-' || c == '_' {
143                    c
144                } else {
145                    '_'
146                }
147            })
148            .collect();
149
150        // Use recording_type (e.g. "shared_screen_with_speaker_view") as the
151        // filename discriminator; multiple files of the same file_type (e.g.
152        // two MP4 tracks) each have a distinct recording_type and won't
153        // overwrite each other.
154        let discriminator = file
155            .recording_type
156            .as_deref()
157            .unwrap_or(file_type)
158            .to_lowercase()
159            .replace(' ', "_");
160        let filename = format!(
161            "{}_{}_{}.{}",
162            safe_topic,
163            recording.start_time.replace(':', "-").replace('T', "_"),
164            discriminator,
165            ext
166        );
167        let dest = dir.join(&filename);
168
169        out.print_message(&format!("Downloading {} → {}", file_type, dest.display()));
170
171        let bytes = client.download_recording_file(download_url, &dest).await?;
172        out.print_message(&format!("  {:.1} MB written", bytes as f64 / 1_048_576.0));
173        downloaded += 1;
174    }
175
176    out.print_result(
177        &serde_json::json!({"downloaded": downloaded, "meeting_id": meeting_id, "out_dir": out_dir}),
178        &format!("{downloaded} file(s) downloaded to {out_dir}"),
179    );
180    Ok(())
181}
182
183pub async fn control(
184    client: &mut ZoomClient,
185    out: &OutputConfig,
186    meeting_id: u64,
187    action: &str,
188) -> Result<(), ApiError> {
189    client.control_recording(meeting_id, action).await?;
190    out.print_result(
191        &serde_json::json!({"action": action, "meeting_id": meeting_id}),
192        &format!("Recording {action}ed for meeting {meeting_id}."),
193    );
194    Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::api::ZoomClient;
201    use wiremock::matchers::{method, path};
202    use wiremock::{Mock, MockServer, ResponseTemplate};
203
204    fn test_out() -> OutputConfig {
205        OutputConfig {
206            json: true,
207            quiet: true,
208        }
209    }
210
211    #[tokio::test]
212    async fn recordings_list_empty_is_ok() {
213        let server = MockServer::start().await;
214        Mock::given(method("GET"))
215            .and(path("/v2/users/me/recordings"))
216            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
217                "meetings": [],
218                "total_records": 0
219            })))
220            .mount(&server)
221            .await;
222
223        let mut client =
224            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
225        list(&mut client, &test_out(), "me", None, None)
226            .await
227            .unwrap();
228    }
229
230    #[tokio::test]
231    async fn recordings_get_returns_recording_with_files() {
232        let server = MockServer::start().await;
233        Mock::given(method("GET"))
234            .and(path("/v2/meetings/abc123/recordings"))
235            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
236                "id": 123456789,
237                "topic": "Design Review",
238                "start_time": "2026-04-01T10:00:00Z",
239                "duration": 45,
240                "recording_files": [
241                    {
242                        "id": "rf-001",
243                        "file_type": "MP4",
244                        "file_size": 52428800,
245                        "download_url": "https://zoom.us/rec/download/abc123.mp4",
246                        "status": "completed"
247                    }
248                ]
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        get(&mut client, &test_out(), "abc123").await.unwrap();
256    }
257
258    #[tokio::test]
259    async fn recordings_control_start_sends_patch() {
260        let server = MockServer::start().await;
261        Mock::given(method("PATCH"))
262            .and(path("/v2/live_meetings/999888777/recordings"))
263            .respond_with(ResponseTemplate::new(204))
264            .mount(&server)
265            .await;
266
267        let mut client =
268            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
269        control(&mut client, &test_out(), 999888777, "start")
270            .await
271            .unwrap();
272    }
273
274    #[tokio::test]
275    async fn recordings_control_invalid_meeting_returns_error() {
276        let server = MockServer::start().await;
277        Mock::given(method("PATCH"))
278            .and(path("/v2/live_meetings/111/recordings"))
279            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
280                "code": 3001,
281                "message": "Meeting does not exist"
282            })))
283            .mount(&server)
284            .await;
285
286        let mut client =
287            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
288        let err = control(&mut client, &test_out(), 111, "start")
289            .await
290            .unwrap_err();
291        assert!(matches!(err, ApiError::Api { .. }));
292    }
293
294    #[tokio::test]
295    async fn recordings_get_not_found_propagates() {
296        let server = MockServer::start().await;
297        Mock::given(method("GET"))
298            .and(path("/v2/meetings/nope/recordings"))
299            .respond_with(ResponseTemplate::new(404))
300            .mount(&server)
301            .await;
302
303        let mut client =
304            ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
305        let err = get(&mut client, &test_out(), "nope").await.unwrap_err();
306        assert!(matches!(err, ApiError::NotFound(_)));
307    }
308}