//! YouTube Data API v3 Integration //! //! Provides video upload, community posts, and channel management capabilities. //! Supports OAuth 2.0 authentication flow. use crate::channels::{ ChannelAccount, ChannelCredentials, ChannelError, ChannelProvider, ChannelType, PostContent, PostResult, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// YouTube API provider for video uploads and community posts pub struct YouTubeProvider { client: reqwest::Client, api_base_url: String, upload_base_url: String, oauth_base_url: String, } impl YouTubeProvider { pub fn new() -> Self { Self { client: reqwest::Client::new(), api_base_url: "https://www.googleapis.com/youtube/v3".to_string(), upload_base_url: "https://www.googleapis.com/upload/youtube/v3".to_string(), oauth_base_url: "https://oauth2.googleapis.com".to_string(), } } /// Upload a video to YouTube pub async fn upload_video( &self, access_token: &str, video: &VideoUploadRequest, video_data: &[u8], ) -> Result { // Step 1: Initialize resumable upload let init_url = format!( "{}/videos?uploadType=resumable&part=snippet,status,contentDetails", self.upload_base_url ); let metadata = VideoMetadata { snippet: VideoSnippet { title: video.title.clone(), description: video.description.clone(), tags: video.tags.clone(), category_id: video.category_id.clone().unwrap_or_else(|| "22".to_string()), // 22 = People & Blogs default_language: video.default_language.clone(), default_audio_language: video.default_audio_language.clone(), }, status: VideoStatus { privacy_status: video.privacy_status.clone(), embeddable: video.embeddable.unwrap_or(true), license: video.license.clone().unwrap_or_else(|| "youtube".to_string()), public_stats_viewable: video.public_stats_viewable.unwrap_or(true), publish_at: video.scheduled_publish_at.clone(), self_declared_made_for_kids: video.made_for_kids.unwrap_or(false), }, }; let init_response = self .client .post(&init_url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .header("X-Upload-Content-Type", &video.content_type) .header("X-Upload-Content-Length", video_data.len().to_string()) .json(&metadata) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !init_response.status().is_success() { return Err(self.parse_error_response(init_response).await); } let upload_url = init_response .headers() .get("location") .and_then(|v| v.to_str().ok()) .ok_or_else(|| ChannelError::ApiError { code: None, message: "Missing upload URL in response".to_string(), })? .to_string(); // Step 2: Upload video data let upload_response = self .client .put(&upload_url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", &video.content_type) .header("Content-Length", video_data.len().to_string()) .body(video_data.to_vec()) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !upload_response.status().is_success() { return Err(self.parse_error_response(upload_response).await); } upload_response .json::() .await .map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Create a community post (text, poll, image, or video) pub async fn create_community_post( &self, access_token: &str, post: &CommunityPostRequest, ) -> Result { // Note: Community Posts API is limited and may require additional permissions let url = format!("{}/activities", self.api_base_url); let request_body = serde_json::json!({ "snippet": { "description": post.text, "channelId": post.channel_id }, "contentDetails": { "bulletin": { "resourceId": post.attached_video_id.as_ref().map(|vid| { serde_json::json!({ "kind": "youtube#video", "videoId": vid }) }) } } }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet,contentDetails")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response .json::() .await .map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Get channel information pub async fn get_channel(&self, access_token: &str) -> Result { let url = format!("{}/channels", self.api_base_url); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[ ("part", "snippet,contentDetails,statistics,status,brandingSettings"), ("mine", "true"), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let list_response: ChannelListResponse = response.json().await.map_err(|e| { ChannelError::ApiError { code: None, message: e.to_string(), } })?; list_response.items.into_iter().next().ok_or_else(|| { ChannelError::ApiError { code: None, message: "No channel found".to_string(), } }) } /// Get channel by ID pub async fn get_channel_by_id( &self, access_token: &str, channel_id: &str, ) -> Result { let url = format!("{}/channels", self.api_base_url); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[ ("part", "snippet,contentDetails,statistics,status"), ("id", channel_id), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let list_response: ChannelListResponse = response.json().await.map_err(|e| { ChannelError::ApiError { code: None, message: e.to_string(), } })?; list_response.items.into_iter().next().ok_or_else(|| { ChannelError::ApiError { code: None, message: "Channel not found".to_string(), } }) } /// List videos from a channel or playlist pub async fn list_videos( &self, access_token: &str, options: &VideoListOptions, ) -> Result { let url = format!("{}/search", self.api_base_url); let mut query_params = vec![ ("part", "snippet".to_string()), ("type", "video".to_string()), ("maxResults", options.max_results.unwrap_or(25).to_string()), ]; if let Some(channel_id) = &options.channel_id { query_params.push(("channelId", channel_id.clone())); } if options.for_mine.unwrap_or(false) { query_params.push(("forMine", "true".to_string())); } if let Some(order) = &options.order { query_params.push(("order", order.clone())); } if let Some(page_token) = &options.page_token { query_params.push(("pageToken", page_token.clone())); } if let Some(published_after) = &options.published_after { query_params.push(("publishedAfter", published_after.clone())); } if let Some(published_before) = &options.published_before { query_params.push(("publishedBefore", published_before.clone())); } let query_refs: Vec<(&str, &str)> = query_params .iter() .map(|(k, v)| (*k, v.as_str())) .collect(); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&query_refs) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Get video details by ID pub async fn get_video( &self, access_token: &str, video_id: &str, ) -> Result { let url = format!("{}/videos", self.api_base_url); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[ ("part", "snippet,contentDetails,statistics,status,player"), ("id", video_id), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let list_response: YouTubeVideoListResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; list_response.items.into_iter().next().ok_or_else(|| { ChannelError::ApiError { code: None, message: "Video not found".to_string(), } }) } /// Update video metadata pub async fn update_video( &self, access_token: &str, video_id: &str, update: &VideoUpdateRequest, ) -> Result { let url = format!("{}/videos", self.api_base_url); let update_body = serde_json::json!({ "id": video_id, "snippet": { "title": update.title, "description": update.description, "tags": update.tags, "categoryId": update.category_id }, "status": { "privacyStatus": update.privacy_status, "embeddable": update.embeddable, "publicStatsViewable": update.public_stats_viewable } }); let response = self .client .put(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet,status")]) .json(&update_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Delete a video pub async fn delete_video( &self, access_token: &str, video_id: &str, ) -> Result<(), ChannelError> { let url = format!("{}/videos", self.api_base_url); let response = self .client .delete(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[("id", video_id)]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if response.status().as_u16() == 204 { return Ok(()); } if !response.status().is_success() { return Err(self.parse_error_response(response).await); } Ok(()) } /// Create a playlist pub async fn create_playlist( &self, access_token: &str, playlist: &PlaylistCreateRequest, ) -> Result { let url = format!("{}/playlists", self.api_base_url); let request_body = serde_json::json!({ "snippet": { "title": playlist.title, "description": playlist.description, "tags": playlist.tags, "defaultLanguage": playlist.default_language }, "status": { "privacyStatus": playlist.privacy_status } }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet,status")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Add video to playlist pub async fn add_video_to_playlist( &self, access_token: &str, playlist_id: &str, video_id: &str, position: Option, ) -> Result { let url = format!("{}/playlistItems", self.api_base_url); let mut request_body = serde_json::json!({ "snippet": { "playlistId": playlist_id, "resourceId": { "kind": "youtube#video", "videoId": video_id } } }); if let Some(pos) = position { request_body["snippet"]["position"] = serde_json::json!(pos); } let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Remove video from playlist pub async fn remove_from_playlist( &self, access_token: &str, playlist_item_id: &str, ) -> Result<(), ChannelError> { let url = format!("{}/playlistItems", self.api_base_url); let response = self .client .delete(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[("id", playlist_item_id)]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if response.status().as_u16() == 204 { return Ok(()); } if !response.status().is_success() { return Err(self.parse_error_response(response).await); } Ok(()) } /// Set video thumbnail pub async fn set_thumbnail( &self, access_token: &str, video_id: &str, image_data: &[u8], content_type: &str, ) -> Result { let url = format!("{}/thumbnails/set", self.upload_base_url); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", content_type) .query(&[("videoId", video_id)]) .body(image_data.to_vec()) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Add a comment to a video pub async fn add_comment( &self, access_token: &str, video_id: &str, comment_text: &str, ) -> Result { let url = format!("{}/commentThreads", self.api_base_url); let request_body = serde_json::json!({ "snippet": { "videoId": video_id, "topLevelComment": { "snippet": { "textOriginal": comment_text } } } }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Reply to a comment pub async fn reply_to_comment( &self, access_token: &str, parent_id: &str, reply_text: &str, ) -> Result { let url = format!("{}/comments", self.api_base_url); let request_body = serde_json::json!({ "snippet": { "parentId": parent_id, "textOriginal": reply_text } }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Get video comments pub async fn get_comments( &self, access_token: &str, video_id: &str, page_token: Option<&str>, max_results: Option, ) -> Result { let url = format!("{}/commentThreads", self.api_base_url); let mut query_params = vec![ ("part", "snippet,replies"), ("videoId", video_id), ]; let max_results_str = max_results.unwrap_or(20).to_string(); query_params.push(("maxResults", &max_results_str)); if let Some(token) = page_token { query_params.push(("pageToken", token)); } let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&query_params) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Get channel analytics (requires YouTube Analytics API) pub async fn get_analytics( &self, access_token: &str, options: &AnalyticsRequest, ) -> Result { let url = "https://youtubeanalytics.googleapis.com/v2/reports"; let metrics = options .metrics .as_deref() .unwrap_or("views,estimatedMinutesWatched,averageViewDuration,subscribersGained"); let response = self .client .get(url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[ ("ids", format!("channel=={}", options.channel_id).as_str()), ("startDate", &options.start_date), ("endDate", &options.end_date), ("metrics", metrics), ("dimensions", options.dimensions.as_deref().unwrap_or("day")), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Refresh OAuth token pub async fn refresh_oauth_token( &self, client_id: &str, client_secret: &str, refresh_token: &str, ) -> Result { let url = format!("{}/token", self.oauth_base_url); let response = self .client .post(&url) .form(&[ ("client_id", client_id), ("client_secret", client_secret), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Subscribe to a channel pub async fn subscribe( &self, access_token: &str, channel_id: &str, ) -> Result { let url = format!("{}/subscriptions", self.api_base_url); let request_body = serde_json::json!({ "snippet": { "resourceId": { "kind": "youtube#channel", "channelId": channel_id } } }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Create a live broadcast pub async fn create_live_broadcast( &self, access_token: &str, broadcast: &LiveBroadcastRequest, ) -> Result { let url = format!("{}/liveBroadcasts", self.api_base_url); let request_body = serde_json::json!({ "snippet": { "title": broadcast.title, "description": broadcast.description, "scheduledStartTime": broadcast.scheduled_start_time }, "status": { "privacyStatus": broadcast.privacy_status }, "contentDetails": { "enableAutoStart": broadcast.enable_auto_start, "enableAutoStop": broadcast.enable_auto_stop, "enableDvr": broadcast.enable_dvr, "enableEmbed": broadcast.enable_embed, "recordFromStart": broadcast.record_from_start } }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .query(&[("part", "snippet,status,contentDetails")]) .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } async fn parse_error_response(&self, response: reqwest::Response) -> ChannelError { let status = response.status(); if status.as_u16() == 401 { return ChannelError::AuthenticationFailed("Invalid or expired token".to_string()); } if status.as_u16() == 403 { return ChannelError::AuthenticationFailed("Insufficient permissions".to_string()); } if status.as_u16() == 429 { let retry_after = response .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()); return ChannelError::RateLimited { retry_after }; } let error_text = response.text().await.unwrap_or_default(); if let Ok(error_response) = serde_json::from_str::(&error_text) { return ChannelError::ApiError { code: Some(error_response.error.code.to_string()), message: error_response.error.message, }; } ChannelError::ApiError { code: Some(status.to_string()), message: error_text, } } } impl Default for YouTubeProvider { fn default() -> Self { Self::new() } } #[async_trait::async_trait] impl ChannelProvider for YouTubeProvider { fn channel_type(&self) -> ChannelType { ChannelType::YouTube } fn max_text_length(&self) -> usize { 5000 // Max description length for videos } fn supports_images(&self) -> bool { true // Thumbnails } fn supports_video(&self) -> bool { true } fn supports_links(&self) -> bool { true } async fn post( &self, account: &ChannelAccount, content: &PostContent, ) -> Result { let access_token = match &account.credentials { ChannelCredentials::OAuth { access_token, .. } => access_token.clone(), _ => { return Err(ChannelError::AuthenticationFailed( "OAuth credentials required for YouTube".to_string(), )) } }; let text = content.text.as_deref().unwrap_or(""); // Get channel ID for community post let channel = self.get_channel(&access_token).await?; // Create community post with the content let post_request = CommunityPostRequest { channel_id: channel.id.clone(), text: text.to_string(), attached_video_id: content .metadata .get("video_id") .and_then(|v| v.as_str()) .map(String::from), image_urls: content.image_urls.clone(), }; let post = self.create_community_post(&access_token, &post_request).await?; let url = format!("https://www.youtube.com/post/{}", post.id); Ok(PostResult::success(ChannelType::YouTube, post.id, Some(url))) } async fn validate_credentials( &self, credentials: &ChannelCredentials, ) -> Result { match credentials { ChannelCredentials::OAuth { access_token, .. } => { match self.get_channel(access_token).await { Ok(_) => Ok(true), Err(ChannelError::AuthenticationFailed(_)) => Ok(false), Err(e) => Err(e), } } _ => Ok(false), } } async fn refresh_token(&self, account: &mut ChannelAccount) -> Result<(), ChannelError> { let (refresh_token, client_id, client_secret) = match &account.credentials { ChannelCredentials::OAuth { refresh_token, .. } => { let refresh = refresh_token.as_ref().ok_or_else(|| { ChannelError::AuthenticationFailed("No refresh token available".to_string()) })?; let client_id = account .settings .custom .get("client_id") .and_then(|v| v.as_str()) .ok_or_else(|| { ChannelError::AuthenticationFailed("Missing client_id".to_string()) })?; let client_secret = account .settings .custom .get("client_secret") .and_then(|v| v.as_str()) .ok_or_else(|| { ChannelError::AuthenticationFailed("Missing client_secret".to_string()) })?; (refresh.clone(), client_id.to_string(), client_secret.to_string()) } _ => { return Err(ChannelError::AuthenticationFailed( "OAuth credentials required".to_string(), )) } }; let token_response = self .refresh_oauth_token(&client_id, &client_secret, &refresh_token) .await?; let expires_at = chrono::Utc::now() + chrono::Duration::seconds(token_response.expires_in.unwrap_or(3600) as i64); account.credentials = ChannelCredentials::OAuth { access_token: token_response.access_token, refresh_token: token_response.refresh_token.or(Some(refresh_token)), expires_at: Some(expires_at), scope: token_response.scope, }; Ok(()) } } // ============================================================================ // Request/Response Types // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoUploadRequest { pub title: String, pub description: Option, pub tags: Option>, pub category_id: Option, pub privacy_status: String, // "private", "public", "unlisted" pub content_type: String, // e.g., "video/mp4" pub default_language: Option, pub default_audio_language: Option, pub embeddable: Option, pub license: Option, pub public_stats_viewable: Option, pub scheduled_publish_at: Option, pub made_for_kids: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommunityPostRequest { pub channel_id: String, pub text: String, pub attached_video_id: Option, pub image_urls: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoListOptions { pub channel_id: Option, pub for_mine: Option, pub order: Option, // "date", "rating", "relevance", "title", "viewCount" pub page_token: Option, pub published_after: Option, pub published_before: Option, pub max_results: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoUpdateRequest { pub title: Option, pub description: Option, pub tags: Option>, pub category_id: Option, pub privacy_status: Option, pub embeddable: Option, pub public_stats_viewable: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlaylistCreateRequest { pub title: String, pub description: Option, pub tags: Option>, pub default_language: Option, pub privacy_status: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalyticsRequest { pub channel_id: String, pub start_date: String, pub end_date: String, pub metrics: Option, pub dimensions: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LiveBroadcastRequest { pub title: String, pub description: Option, pub scheduled_start_time: String, pub privacy_status: String, pub enable_auto_start: Option, pub enable_auto_stop: Option, pub enable_dvr: Option, pub enable_embed: Option, pub record_from_start: Option, } // ============================================================================ // API Response Types // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct YouTubeVideo { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, pub content_details: Option, pub statistics: Option, pub status: Option, pub player: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoSnippetResponse { pub title: String, pub description: String, pub published_at: String, pub channel_id: String, pub channel_title: String, pub thumbnails: Option, pub tags: Option>, pub category_id: Option, pub live_broadcast_content: Option, pub default_language: Option, pub default_audio_language: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoContentDetails { pub duration: String, pub dimension: String, pub definition: String, pub caption: Option, pub licensed_content: bool, pub projection: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoStatistics { pub view_count: Option, pub like_count: Option, pub dislike_count: Option, pub favorite_count: Option, pub comment_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoStatusResponse { pub upload_status: String, pub privacy_status: String, pub license: Option, pub embeddable: Option, pub public_stats_viewable: Option, pub made_for_kids: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoPlayer { pub embed_html: Option, pub embed_width: Option, pub embed_height: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Thumbnails { pub default: Option, pub medium: Option, pub high: Option, pub standard: Option, pub maxres: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Thumbnail { pub url: String, pub width: Option, pub height: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct YouTubeChannel { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, pub content_details: Option, pub statistics: Option, pub status: Option, pub branding_settings: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelSnippet { pub title: String, pub description: String, pub custom_url: Option, pub published_at: String, pub thumbnails: Option, pub default_language: Option, pub country: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelContentDetails { pub related_playlists: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RelatedPlaylists { pub likes: Option, pub uploads: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelStatistics { pub view_count: Option, pub subscriber_count: Option, pub hidden_subscriber_count: bool, pub video_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelStatus { pub privacy_status: String, pub is_linked: Option, pub long_uploads_status: Option, pub made_for_kids: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BrandingSettings { pub channel: Option, pub image: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelBranding { pub title: Option, pub description: Option, pub keywords: Option, pub default_tab: Option, pub country: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ImageBranding { pub banner_external_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct YouTubePlaylist { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, pub status: Option, pub content_details: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistSnippet { pub title: String, pub description: String, pub published_at: String, pub channel_id: String, pub channel_title: String, pub thumbnails: Option, pub default_language: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistStatus { pub privacy_status: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistContentDetails { pub item_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistItem { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistItemSnippet { pub playlist_id: String, pub position: u32, pub resource_id: ResourceId, pub title: String, pub description: String, pub thumbnails: Option, pub channel_id: String, pub channel_title: String, pub published_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResourceId { pub kind: String, pub video_id: Option, pub channel_id: Option, pub playlist_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommunityPost { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommunityPostSnippet { pub channel_id: String, pub description: String, pub published_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommentThread { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, pub replies: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommentThreadSnippet { pub channel_id: String, pub video_id: String, pub top_level_comment: Comment, pub can_reply: bool, pub total_reply_count: u32, pub is_public: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Comment { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommentSnippet { pub video_id: Option, pub text_display: String, pub text_original: String, pub author_display_name: String, pub author_profile_image_url: Option, pub author_channel_url: Option, pub author_channel_id: Option, pub can_rate: bool, pub viewer_rating: Option, pub like_count: u32, pub published_at: String, pub updated_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorChannelId { pub value: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommentReplies { pub comments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Subscription { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubscriptionSnippet { pub published_at: String, pub title: String, pub description: String, pub resource_id: ResourceId, pub channel_id: String, pub thumbnails: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LiveBroadcast { pub id: String, pub kind: String, pub etag: String, pub snippet: Option, pub status: Option, pub content_details: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LiveBroadcastSnippet { pub published_at: String, pub channel_id: String, pub title: String, pub description: String, pub thumbnails: Option, pub scheduled_start_time: Option, pub scheduled_end_time: Option, pub actual_start_time: Option, pub actual_end_time: Option, pub is_default_broadcast: bool, pub live_chat_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LiveBroadcastStatus { pub life_cycle_status: String, pub privacy_status: String, pub recording_status: Option, pub made_for_kids: Option, pub self_declared_made_for_kids: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LiveBroadcastContentDetails { pub bound_stream_id: Option, pub bound_stream_last_update_time_ms: Option, pub enable_closed_captions: Option, pub enable_content_encryption: Option, pub enable_dvr: Option, pub enable_embed: Option, pub enable_auto_start: Option, pub enable_auto_stop: Option, pub record_from_start: Option, pub start_with_slate: Option, pub projection: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThumbnailSetResponse { pub kind: String, pub etag: String, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThumbnailItem { pub default: Option, pub medium: Option, pub high: Option, pub standard: Option, pub maxres: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AnalyticsResponse { pub kind: String, pub column_headers: Vec, pub rows: Option>>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ColumnHeader { pub name: String, pub column_type: String, pub data_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OAuthTokenResponse { pub access_token: String, pub refresh_token: Option, pub expires_in: Option, pub token_type: String, pub scope: Option, } // ============================================================================ // List Response Types // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelListResponse { pub kind: String, pub etag: String, pub page_info: Option, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct YouTubeVideoListResponse { pub kind: String, pub etag: String, pub page_info: Option, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoListResponse { pub kind: String, pub etag: String, pub next_page_token: Option, pub prev_page_token: Option, pub page_info: Option, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoSearchResult { pub kind: String, pub etag: String, pub id: VideoSearchId, pub snippet: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VideoSearchId { pub kind: String, pub video_id: Option, pub channel_id: Option, pub playlist_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommentThreadListResponse { pub kind: String, pub etag: String, pub next_page_token: Option, pub page_info: Option, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PageInfo { pub total_results: u32, pub results_per_page: u32, } // ============================================================================ // Internal Types // ============================================================================ #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct VideoMetadata { snippet: VideoSnippet, status: VideoStatus, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct VideoSnippet { title: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, #[serde(skip_serializing_if = "Option::is_none")] tags: Option>, category_id: String, #[serde(skip_serializing_if = "Option::is_none")] default_language: Option, #[serde(skip_serializing_if = "Option::is_none")] default_audio_language: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct VideoStatus { privacy_status: String, embeddable: bool, license: String, public_stats_viewable: bool, #[serde(skip_serializing_if = "Option::is_none")] publish_at: Option, self_declared_made_for_kids: bool, } #[derive(Debug, Clone, Deserialize)] struct YouTubeErrorResponse { error: YouTubeError, } #[derive(Debug, Clone, Deserialize)] struct YouTubeError { code: u16, message: String, #[serde(default)] errors: Vec, } #[derive(Debug, Clone, Deserialize)] struct YouTubeErrorDetail { message: String, domain: String, reason: String, } // ============================================================================ // Helper Functions // ============================================================================ impl YouTubeVideo { /// Get the video URL pub fn url(&self) -> String { format!("https://www.youtube.com/watch?v={}", self.id) } /// Get the embed URL pub fn embed_url(&self) -> String { format!("https://www.youtube.com/embed/{}", self.id) } /// Get the thumbnail URL (high quality) pub fn thumbnail_url(&self) -> Option { self.snippet .as_ref() .and_then(|s| s.thumbnails.as_ref()) .and_then(|t| { t.high .as_ref() .or(t.medium.as_ref()) .or(t.default.as_ref()) }) .map(|t| t.url.clone()) } } impl YouTubeChannel { /// Get the channel URL pub fn url(&self) -> String { if let Some(snippet) = &self.snippet { if let Some(custom_url) = &snippet.custom_url { return format!("https://www.youtube.com/{}", custom_url); } } format!("https://www.youtube.com/channel/{}", self.id) } } /// Video categories commonly used on YouTube pub struct VideoCategories; impl VideoCategories { pub const FILM_AND_ANIMATION: &'static str = "1"; pub const AUTOS_AND_VEHICLES: &'static str = "2"; pub const MUSIC: &'static str = "10"; pub const PETS_AND_ANIMALS: &'static str = "15"; pub const SPORTS: &'static str = "17"; pub const TRAVEL_AND_EVENTS: &'static str = "19"; pub const GAMING: &'static str = "20"; pub const PEOPLE_AND_BLOGS: &'static str = "22"; pub const COMEDY: &'static str = "23"; pub const ENTERTAINMENT: &'static str = "24"; pub const NEWS_AND_POLITICS: &'static str = "25"; pub const HOWTO_AND_STYLE: &'static str = "26"; pub const EDUCATION: &'static str = "27"; pub const SCIENCE_AND_TECHNOLOGY: &'static str = "28"; pub const NONPROFITS_AND_ACTIVISM: &'static str = "29"; } /// Privacy status options for videos and playlists #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PrivacyStatus { Public, Private, Unlisted, } impl PrivacyStatus { pub fn as_str(&self) -> &'static str { match self { Self::Public => "public", Self::Private => "private", Self::Unlisted => "unlisted", } } } impl std::fmt::Display for PrivacyStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) } }