botserver/src/channels/youtube.rs
Rodrigo Rodriguez (Pragmatismo) 5919aa6bf0 Add video module, RBAC, security features, billing, contacts, dashboards, learn, social, and multiple new modules
Major additions:
- Video editing engine with AI features (transcription, captions, TTS, scene detection)
- RBAC middleware and organization management
- Security enhancements (MFA, passkey, DLP, encryption, audit)
- Billing and subscription management
- Contacts management
- Dashboards module
- Learn/LMS module
- Social features
- Compliance (SOC2, SOP middleware, vulnerability scanner)
- New migrations for RBAC, learn, and video tables
2026-01-08 13:16:17 -03:00

1715 lines
52 KiB
Rust

//! 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<YouTubeVideo, ChannelError> {
// 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::<YouTubeVideo>()
.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<CommunityPost, ChannelError> {
// 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::<CommunityPost>()
.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<YouTubeChannel, ChannelError> {
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<YouTubeChannel, ChannelError> {
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<VideoListResponse, ChannelError> {
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<YouTubeVideo, ChannelError> {
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<YouTubeVideo, ChannelError> {
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<YouTubePlaylist, ChannelError> {
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<u32>,
) -> Result<PlaylistItem, ChannelError> {
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<ThumbnailSetResponse, ChannelError> {
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<CommentThread, ChannelError> {
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<Comment, ChannelError> {
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<u32>,
) -> Result<CommentThreadListResponse, ChannelError> {
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<AnalyticsResponse, ChannelError> {
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<OAuthTokenResponse, ChannelError> {
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<Subscription, ChannelError> {
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<LiveBroadcast, ChannelError> {
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::<YouTubeErrorResponse>(&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<PostResult, ChannelError> {
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<bool, ChannelError> {
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<String>,
pub tags: Option<Vec<String>>,
pub category_id: Option<String>,
pub privacy_status: String, // "private", "public", "unlisted"
pub content_type: String, // e.g., "video/mp4"
pub default_language: Option<String>,
pub default_audio_language: Option<String>,
pub embeddable: Option<bool>,
pub license: Option<String>,
pub public_stats_viewable: Option<bool>,
pub scheduled_publish_at: Option<String>,
pub made_for_kids: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityPostRequest {
pub channel_id: String,
pub text: String,
pub attached_video_id: Option<String>,
pub image_urls: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoListOptions {
pub channel_id: Option<String>,
pub for_mine: Option<bool>,
pub order: Option<String>, // "date", "rating", "relevance", "title", "viewCount"
pub page_token: Option<String>,
pub published_after: Option<String>,
pub published_before: Option<String>,
pub max_results: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoUpdateRequest {
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub category_id: Option<String>,
pub privacy_status: Option<String>,
pub embeddable: Option<bool>,
pub public_stats_viewable: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistCreateRequest {
pub title: String,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub default_language: Option<String>,
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<String>,
pub dimensions: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveBroadcastRequest {
pub title: String,
pub description: Option<String>,
pub scheduled_start_time: String,
pub privacy_status: String,
pub enable_auto_start: Option<bool>,
pub enable_auto_stop: Option<bool>,
pub enable_dvr: Option<bool>,
pub enable_embed: Option<bool>,
pub record_from_start: Option<bool>,
}
// ============================================================================
// 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<VideoSnippetResponse>,
pub content_details: Option<VideoContentDetails>,
pub statistics: Option<VideoStatistics>,
pub status: Option<VideoStatusResponse>,
pub player: Option<VideoPlayer>,
}
#[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<Thumbnails>,
pub tags: Option<Vec<String>>,
pub category_id: Option<String>,
pub live_broadcast_content: Option<String>,
pub default_language: Option<String>,
pub default_audio_language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoContentDetails {
pub duration: String,
pub dimension: String,
pub definition: String,
pub caption: Option<String>,
pub licensed_content: bool,
pub projection: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoStatistics {
pub view_count: Option<String>,
pub like_count: Option<String>,
pub dislike_count: Option<String>,
pub favorite_count: Option<String>,
pub comment_count: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoStatusResponse {
pub upload_status: String,
pub privacy_status: String,
pub license: Option<String>,
pub embeddable: Option<bool>,
pub public_stats_viewable: Option<bool>,
pub made_for_kids: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoPlayer {
pub embed_html: Option<String>,
pub embed_width: Option<i64>,
pub embed_height: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thumbnails {
pub default: Option<Thumbnail>,
pub medium: Option<Thumbnail>,
pub high: Option<Thumbnail>,
pub standard: Option<Thumbnail>,
pub maxres: Option<Thumbnail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thumbnail {
pub url: String,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YouTubeChannel {
pub id: String,
pub kind: String,
pub etag: String,
pub snippet: Option<ChannelSnippet>,
pub content_details: Option<ChannelContentDetails>,
pub statistics: Option<ChannelStatistics>,
pub status: Option<ChannelStatus>,
pub branding_settings: Option<BrandingSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelSnippet {
pub title: String,
pub description: String,
pub custom_url: Option<String>,
pub published_at: String,
pub thumbnails: Option<Thumbnails>,
pub default_language: Option<String>,
pub country: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelContentDetails {
pub related_playlists: Option<RelatedPlaylists>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RelatedPlaylists {
pub likes: Option<String>,
pub uploads: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelStatistics {
pub view_count: Option<String>,
pub subscriber_count: Option<String>,
pub hidden_subscriber_count: bool,
pub video_count: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelStatus {
pub privacy_status: String,
pub is_linked: Option<bool>,
pub long_uploads_status: Option<String>,
pub made_for_kids: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrandingSettings {
pub channel: Option<ChannelBranding>,
pub image: Option<ImageBranding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelBranding {
pub title: Option<String>,
pub description: Option<String>,
pub keywords: Option<String>,
pub default_tab: Option<String>,
pub country: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageBranding {
pub banner_external_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YouTubePlaylist {
pub id: String,
pub kind: String,
pub etag: String,
pub snippet: Option<PlaylistSnippet>,
pub status: Option<PlaylistStatus>,
pub content_details: Option<PlaylistContentDetails>,
}
#[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<Thumbnails>,
pub default_language: Option<String>,
}
#[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<PlaylistItemSnippet>,
}
#[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<Thumbnails>,
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<String>,
pub channel_id: Option<String>,
pub playlist_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommunityPost {
pub id: String,
pub kind: String,
pub etag: String,
pub snippet: Option<CommunityPostSnippet>,
}
#[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<CommentThreadSnippet>,
pub replies: Option<CommentReplies>,
}
#[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<CommentSnippet>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommentSnippet {
pub video_id: Option<String>,
pub text_display: String,
pub text_original: String,
pub author_display_name: String,
pub author_profile_image_url: Option<String>,
pub author_channel_url: Option<String>,
pub author_channel_id: Option<AuthorChannelId>,
pub can_rate: bool,
pub viewer_rating: Option<String>,
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<Comment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Subscription {
pub id: String,
pub kind: String,
pub etag: String,
pub snippet: Option<SubscriptionSnippet>,
}
#[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<Thumbnails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveBroadcast {
pub id: String,
pub kind: String,
pub etag: String,
pub snippet: Option<LiveBroadcastSnippet>,
pub status: Option<LiveBroadcastStatus>,
pub content_details: Option<LiveBroadcastContentDetails>,
}
#[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<Thumbnails>,
pub scheduled_start_time: Option<String>,
pub scheduled_end_time: Option<String>,
pub actual_start_time: Option<String>,
pub actual_end_time: Option<String>,
pub is_default_broadcast: bool,
pub live_chat_id: Option<String>,
}
#[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<String>,
pub made_for_kids: Option<bool>,
pub self_declared_made_for_kids: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveBroadcastContentDetails {
pub bound_stream_id: Option<String>,
pub bound_stream_last_update_time_ms: Option<String>,
pub enable_closed_captions: Option<bool>,
pub enable_content_encryption: Option<bool>,
pub enable_dvr: Option<bool>,
pub enable_embed: Option<bool>,
pub enable_auto_start: Option<bool>,
pub enable_auto_stop: Option<bool>,
pub record_from_start: Option<bool>,
pub start_with_slate: Option<bool>,
pub projection: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThumbnailSetResponse {
pub kind: String,
pub etag: String,
pub items: Vec<ThumbnailItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThumbnailItem {
pub default: Option<Thumbnail>,
pub medium: Option<Thumbnail>,
pub high: Option<Thumbnail>,
pub standard: Option<Thumbnail>,
pub maxres: Option<Thumbnail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsResponse {
pub kind: String,
pub column_headers: Vec<ColumnHeader>,
pub rows: Option<Vec<Vec<serde_json::Value>>>,
}
#[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<String>,
pub expires_in: Option<u64>,
pub token_type: String,
pub scope: Option<String>,
}
// ============================================================================
// 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<PageInfo>,
pub items: Vec<YouTubeChannel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YouTubeVideoListResponse {
pub kind: String,
pub etag: String,
pub page_info: Option<PageInfo>,
pub items: Vec<YouTubeVideo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoListResponse {
pub kind: String,
pub etag: String,
pub next_page_token: Option<String>,
pub prev_page_token: Option<String>,
pub page_info: Option<PageInfo>,
pub items: Vec<VideoSearchResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoSearchResult {
pub kind: String,
pub etag: String,
pub id: VideoSearchId,
pub snippet: Option<VideoSnippetResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoSearchId {
pub kind: String,
pub video_id: Option<String>,
pub channel_id: Option<String>,
pub playlist_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommentThreadListResponse {
pub kind: String,
pub etag: String,
pub next_page_token: Option<String>,
pub page_info: Option<PageInfo>,
pub items: Vec<CommentThread>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<String>>,
category_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
default_language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
default_audio_language: Option<String>,
}
#[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<String>,
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<YouTubeErrorDetail>,
}
#[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<String> {
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())
}
}