1use 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); fn 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(¶ms.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}