Skip to main content

codetether_agent/tool/
youtube.rs

1//! YouTube Tool - Publish podcast episodes and videos to YouTube.
2//!
3//! Connects to the Voice API's YouTube endpoints to:
4//! - Publish podcast episodes as YouTube videos (auto-generates waveform video)
5//! - Upload existing video files to YouTube
6//! - Check YouTube channel connectivity and status
7
8use super::{Tool, ToolResult};
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use serde::Deserialize;
12use serde_json::{Value, json};
13use std::time::Duration;
14
15const REQUEST_TIMEOUT: Duration = Duration::from_secs(600); // Video generation + upload can be slow
16
17fn voice_api_url() -> String {
18    std::env::var("CODETETHER_VOICE_API_URL")
19        .unwrap_or_else(|_| "https://voice.quantum-forge.io".to_string())
20}
21
22pub struct YouTubeTool {
23    client: reqwest::Client,
24}
25
26impl Default for YouTubeTool {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl YouTubeTool {
33    pub fn new() -> Self {
34        let client = reqwest::Client::builder()
35            .timeout(REQUEST_TIMEOUT)
36            .user_agent("CodeTether-Agent/1.0")
37            .build()
38            .expect("Failed to build HTTP client");
39        Self { client }
40    }
41
42    async fn publish_episode(&self, params: &PublishEpisodeParams) -> Result<ToolResult> {
43        let base = voice_api_url();
44        let url = format!("{base}/youtube/publish-episode");
45
46        let privacy = params.privacy_status.as_deref().unwrap_or("unlisted");
47
48        let mut form = reqwest::multipart::Form::new()
49            .text("podcast_id", params.podcast_id.clone())
50            .text("episode_id", params.episode_id.clone())
51            .text("privacy_status", privacy.to_string());
52
53        if let Some(ref title) = params.custom_title {
54            form = form.text("custom_title", title.clone());
55        }
56        if let Some(ref desc) = params.custom_description {
57            form = form.text("custom_description", desc.clone());
58        }
59        if let Some(ref tags) = params.tags {
60            form = form.text("tags", tags.clone());
61        }
62
63        let resp = self
64            .client
65            .post(&url)
66            .multipart(form)
67            .send()
68            .await
69            .map_err(|e| anyhow::anyhow!("YouTube publish request failed: {e}"))?;
70
71        if !resp.status().is_success() {
72            let status = resp.status();
73            let body = resp.text().await.unwrap_or_default();
74            return Ok(ToolResult::error(format!(
75                "YouTube publish failed ({status}): {body}"
76            )));
77        }
78
79        let body: Value = resp.json().await.context("Failed to parse response")?;
80        let video_id = body["video_id"].as_str().unwrap_or("unknown");
81        let video_url = body["url"].as_str().unwrap_or("unknown");
82        let title = body["title"].as_str().unwrap_or("unknown");
83        let channel = body["channel_title"].as_str().unwrap_or("unknown");
84
85        Ok(ToolResult::success(format!(
86            "Published to YouTube!\n\
87             Video: {video_url}\n\
88             Title: {title}\n\
89             Channel: {channel}\n\
90             Video ID: {video_id}"
91        ))
92        .with_metadata("video_id", json!(video_id))
93        .with_metadata("url", json!(video_url))
94        .with_metadata("title", json!(title)))
95    }
96
97    async fn upload_video(&self, params: &UploadVideoParams) -> Result<ToolResult> {
98        let base = voice_api_url();
99        let url = format!("{base}/youtube/upload");
100
101        let file_path = std::path::Path::new(&params.file_path);
102        if !file_path.exists() {
103            return Ok(ToolResult::error(format!(
104                "Video file not found: {}",
105                params.file_path
106            )));
107        }
108
109        let file_bytes = tokio::fs::read(file_path)
110            .await
111            .context("Failed to read video file")?;
112
113        let file_name = file_path
114            .file_name()
115            .unwrap_or_default()
116            .to_string_lossy()
117            .to_string();
118
119        let part = reqwest::multipart::Part::bytes(file_bytes)
120            .file_name(file_name)
121            .mime_str("video/mp4")?;
122
123        let privacy = params.privacy_status.as_deref().unwrap_or("unlisted");
124
125        let mut form = reqwest::multipart::Form::new()
126            .part("video", part)
127            .text("title", params.title.clone())
128            .text("privacy_status", privacy.to_string());
129
130        if let Some(ref desc) = params.description {
131            form = form.text("description", desc.clone());
132        }
133        if let Some(ref tags) = params.tags {
134            form = form.text("tags", tags.clone());
135        }
136        if let Some(ref category) = params.category_id {
137            form = form.text("category_id", category.clone());
138        }
139
140        let resp = self
141            .client
142            .post(&url)
143            .multipart(form)
144            .send()
145            .await
146            .map_err(|e| anyhow::anyhow!("YouTube upload request failed: {e}"))?;
147
148        if !resp.status().is_success() {
149            let status = resp.status();
150            let body = resp.text().await.unwrap_or_default();
151            return Ok(ToolResult::error(format!(
152                "YouTube upload failed ({status}): {body}"
153            )));
154        }
155
156        let body: Value = resp.json().await.context("Failed to parse response")?;
157        let video_id = body["video_id"].as_str().unwrap_or("unknown");
158        let video_url = body["url"].as_str().unwrap_or("unknown");
159
160        Ok(ToolResult::success(format!(
161            "Uploaded to YouTube!\n\
162             Video: {video_url}\n\
163             Video ID: {video_id}"
164        ))
165        .with_metadata("video_id", json!(video_id))
166        .with_metadata("url", json!(video_url)))
167    }
168
169    async fn status(&self) -> Result<ToolResult> {
170        let base = voice_api_url();
171        let url = format!("{base}/youtube/status");
172
173        let resp = self
174            .client
175            .get(&url)
176            .timeout(Duration::from_secs(10))
177            .send()
178            .await
179            .map_err(|e| anyhow::anyhow!("YouTube status check failed: {e}"))?;
180
181        if !resp.status().is_success() {
182            let status = resp.status();
183            let body = resp.text().await.unwrap_or_default();
184            return Ok(ToolResult::error(format!(
185                "YouTube status check failed ({status}): {body}"
186            )));
187        }
188
189        let body: Value = resp.json().await.context("Failed to parse response")?;
190        let status = body["status"].as_str().unwrap_or("unknown");
191        let channel_title = body["channel_title"].as_str().unwrap_or("unknown");
192        let channel_id = body["channel_id"].as_str().unwrap_or("unknown");
193        let subs = body["subscriber_count"].as_str().unwrap_or("0");
194        let videos = body["video_count"].as_str().unwrap_or("0");
195
196        Ok(ToolResult::success(format!(
197            "YouTube API Status: {status}\n\
198             Channel: {channel_title}\n\
199             Channel ID: {channel_id}\n\
200             Subscribers: {subs}\n\
201             Videos: {videos}"
202        ))
203        .with_metadata("channel_id", json!(channel_id))
204        .with_metadata("channel_title", json!(channel_title)))
205    }
206}
207
208#[derive(Deserialize)]
209struct Params {
210    action: String,
211    #[serde(default)]
212    podcast_id: Option<String>,
213    #[serde(default)]
214    episode_id: Option<String>,
215    #[serde(default)]
216    privacy_status: Option<String>,
217    #[serde(default)]
218    custom_title: Option<String>,
219    #[serde(default)]
220    custom_description: Option<String>,
221    #[serde(default)]
222    tags: Option<String>,
223    #[serde(default)]
224    file_path: Option<String>,
225    #[serde(default)]
226    title: Option<String>,
227    #[serde(default)]
228    description: Option<String>,
229    #[serde(default)]
230    category_id: Option<String>,
231}
232
233#[derive(Deserialize)]
234struct PublishEpisodeParams {
235    podcast_id: String,
236    episode_id: String,
237    privacy_status: Option<String>,
238    custom_title: Option<String>,
239    custom_description: Option<String>,
240    tags: Option<String>,
241}
242
243#[derive(Deserialize)]
244struct UploadVideoParams {
245    file_path: String,
246    title: String,
247    description: Option<String>,
248    tags: Option<String>,
249    privacy_status: Option<String>,
250    category_id: Option<String>,
251}
252
253#[async_trait]
254impl Tool for YouTubeTool {
255    fn id(&self) -> &str {
256        "youtube"
257    }
258    fn name(&self) -> &str {
259        "YouTube"
260    }
261    fn description(&self) -> &str {
262        "Publish content to YouTube. Actions: publish_episode (convert podcast episode to video and upload), \
263         upload_video (upload an existing video file), status (check YouTube channel connectivity). \
264         Uses the Voice API's YouTube integration with OAuth2 authentication."
265    }
266    fn parameters(&self) -> Value {
267        json!({
268            "type": "object",
269            "properties": {
270                "action": {
271                    "type": "string",
272                    "enum": ["publish_episode", "upload_video", "status"],
273                    "description": "Action to perform"
274                },
275                "podcast_id": {
276                    "type": "string",
277                    "description": "Podcast ID (required for publish_episode)"
278                },
279                "episode_id": {
280                    "type": "string",
281                    "description": "Episode ID (required for publish_episode)"
282                },
283                "privacy_status": {
284                    "type": "string",
285                    "enum": ["public", "unlisted", "private"],
286                    "description": "Video privacy (default: unlisted)",
287                    "default": "unlisted"
288                },
289                "custom_title": {
290                    "type": "string",
291                    "description": "Override the episode title for the YouTube video"
292                },
293                "custom_description": {
294                    "type": "string",
295                    "description": "Override the episode description for YouTube"
296                },
297                "tags": {
298                    "type": "string",
299                    "description": "Comma-separated tags for the YouTube video"
300                },
301                "file_path": {
302                    "type": "string",
303                    "description": "Path to video file (required for upload_video)"
304                },
305                "title": {
306                    "type": "string",
307                    "description": "Video title (required for upload_video)"
308                },
309                "description": {
310                    "type": "string",
311                    "description": "Video description (for upload_video)"
312                },
313                "category_id": {
314                    "type": "string",
315                    "description": "YouTube category ID (default: 28 = Science & Technology)",
316                    "default": "28"
317                }
318            },
319            "required": ["action"]
320        })
321    }
322
323    async fn execute(&self, params: Value) -> Result<ToolResult> {
324        let p: Params = serde_json::from_value(params).context("Invalid params")?;
325
326        match p.action.as_str() {
327            "publish_episode" => {
328                let podcast_id = match p.podcast_id {
329                    Some(id) if !id.trim().is_empty() => id,
330                    _ => {
331                        return Ok(ToolResult::structured_error(
332                            "MISSING_PARAM",
333                            "youtube",
334                            "'podcast_id' is required for publish_episode",
335                            Some(vec!["podcast_id"]),
336                            Some(
337                                json!({"action": "publish_episode", "podcast_id": "abc123", "episode_id": "xyz789"}),
338                            ),
339                        ));
340                    }
341                };
342                let episode_id = match p.episode_id {
343                    Some(id) if !id.trim().is_empty() => id,
344                    _ => {
345                        return Ok(ToolResult::structured_error(
346                            "MISSING_PARAM",
347                            "youtube",
348                            "'episode_id' is required for publish_episode",
349                            Some(vec!["episode_id"]),
350                            Some(
351                                json!({"action": "publish_episode", "podcast_id": "abc123", "episode_id": "xyz789"}),
352                            ),
353                        ));
354                    }
355                };
356                self.publish_episode(&PublishEpisodeParams {
357                    podcast_id,
358                    episode_id,
359                    privacy_status: p.privacy_status,
360                    custom_title: p.custom_title,
361                    custom_description: p.custom_description,
362                    tags: p.tags,
363                })
364                .await
365            }
366            "upload_video" => {
367                let file_path = match p.file_path {
368                    Some(f) if !f.trim().is_empty() => f,
369                    _ => {
370                        return Ok(ToolResult::structured_error(
371                            "MISSING_PARAM",
372                            "youtube",
373                            "'file_path' is required for upload_video",
374                            Some(vec!["file_path"]),
375                            Some(
376                                json!({"action": "upload_video", "file_path": "/path/to/video.mp4", "title": "My Video"}),
377                            ),
378                        ));
379                    }
380                };
381                let title = match p.title {
382                    Some(t) if !t.trim().is_empty() => t,
383                    _ => {
384                        return Ok(ToolResult::structured_error(
385                            "MISSING_PARAM",
386                            "youtube",
387                            "'title' is required for upload_video",
388                            Some(vec!["title"]),
389                            Some(
390                                json!({"action": "upload_video", "file_path": "/path/to/video.mp4", "title": "My Video"}),
391                            ),
392                        ));
393                    }
394                };
395                self.upload_video(&UploadVideoParams {
396                    file_path,
397                    title,
398                    description: p.description,
399                    tags: p.tags,
400                    privacy_status: p.privacy_status,
401                    category_id: p.category_id,
402                })
403                .await
404            }
405            "status" => self.status().await,
406            other => Ok(ToolResult::structured_error(
407                "INVALID_ACTION",
408                "youtube",
409                &format!("Unknown action '{other}'. Use: publish_episode, upload_video, status"),
410                None,
411                Some(json!({"action": "status"})),
412            )),
413        }
414    }
415}