diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0ae3bfc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,76 @@ +use chrono::{DateTime, Duration, Utc}; +use std::{fs::{self, File}}; +use std::path::Path; +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, BufReader}; +use rand::seq::SliceRandom; + + + + + +pub fn parse_duration(duration_str: &str) -> Result> { + let duration_str = duration_str.to_lowercase(); + + if duration_str.ends_with("h") { + let hours: i64 = duration_str.trim_end_matches("h").parse()?; + Ok(Duration::hours(hours)) + } else if duration_str.ends_with("m") { + let minutes: i64 = duration_str.trim_end_matches("m").parse()?; + Ok(Duration::minutes(minutes)) + } else if duration_str.ends_with("d") { + let days: i64 = duration_str.trim_end_matches("d").parse()?; + Ok(Duration::days(days)) + } else { + // Default to hours if no unit specified + let hours: i64 = duration_str.parse()?; + Ok(Duration::hours(hours)) + } +} + +pub fn generate_schedule( + video_count: usize, + interval: Duration, + start_time: Option>, + timestamp_file: Option<&str>, +) -> Result>, Box> { + let mut schedule = Vec::new(); + + let start = if let Some(file_path) = timestamp_file { + // Read timestamp from file + let expanded_path = expand_tilde(file_path); + let timestamp_str = fs::read_to_string(&expanded_path) + .map_err(|e| format!("Failed to read timestamp from '{}': {}", expanded_path, e))?; + + let timestamp: i64 = timestamp_str + .trim() + .parse() + .map_err(|e| format!("Invalid timestamp in file '{}': {}", expanded_path, e))?; + + DateTime::from_timestamp(timestamp, 0) + .ok_or_else(|| format!("Invalid unix timestamp: {}", timestamp))? + } else if let Some(start_time) = start_time { + start_time + } else { + Utc::now() + Duration::hours(1) + }; + + for i in 0..video_count { + let scheduled_time = start + interval * i as i32; + schedule.push(scheduled_time); + } + + Ok(schedule) +} + +pub fn expand_tilde(path: &str) -> String { + if path.starts_with("~/") { + if let Ok(home) = std::env::var("HOME") { + path.replacen("~", &home, 1) + } else { + path.to_string() + } + } else { + path.to_string() + } +} diff --git a/src/main.rs b/src/main.rs index 15a6c36..aece090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use chrono::{DateTime, Duration, Utc}; use clap::{Arg, Command}; use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; @@ -6,375 +5,29 @@ use oauth2::{ AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, }; -use rand::seq::SliceRandom; use reqwest::Client; -use serde::{Deserialize, Serialize}; use serde_json::json; -use std::fs::{self, File}; -use std::io::{self, BufRead, BufReader}; +use youtube::{create_default_metadata, load_oauth_config, load_video_metadata, YouTubeUploader}; +use youtube_scheduler::*; use std::path::Path; use tokio; +use chrono::{DateTime, Duration, Utc}; +use std::fs::{self, File}; +use serde::{Deserialize, Serialize}; +use std::io::{self}; + +mod youtube; #[cfg(test)] mod test; -#[derive(Debug, Serialize, Deserialize)] -struct OAuthConfig { - client_id: String, - client_secret: String, - redirect_uri: String, -} -#[derive(Debug, Serialize, Deserialize)] -struct StoredTokens { - access_token: String, - refresh_token: Option, - expires_at: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -struct VideoMetadata { - title: String, - description: String, - tags: Vec, - category_id: String, - privacy_status: String, - scheduled_start_time: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct UploadResponse { - id: String, - snippet: serde_json::Value, - status: serde_json::Value, -} - -struct YouTubeUploader { - client: Client, - access_token: String, - oauth_client: BasicClient, - client_id: String, - client_secret: String, -} - -impl YouTubeUploader { - fn new(oauth_config: &OAuthConfig) -> Result> { - let oauth_client = BasicClient::new( - ClientId::new(oauth_config.client_id.clone()), - Some(ClientSecret::new(oauth_config.client_secret.clone())), - AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())?, - Some(TokenUrl::new( - "https://oauth2.googleapis.com/token".to_string(), - )?), - ) - .set_redirect_uri(RedirectUrl::new(oauth_config.redirect_uri.clone())?); - - Ok(Self { - client: Client::new(), - access_token: String::new(), - oauth_client, - client_id: oauth_config.client_id.clone(), - client_secret: oauth_config.client_secret.clone(), - }) - } - - async fn authenticate(&mut self) -> Result<(), Box> { - // Try to load existing tokens - if let Ok(tokens) = self.load_tokens() { - if let Some(expires_at) = tokens.expires_at { - if expires_at > Utc::now() + Duration::minutes(5) { - // Token is still valid - self.access_token = tokens.access_token; - println!("Using existing valid token"); - return Ok(()); - } - } - - // Try to refresh the token - if let Some(refresh_token) = tokens.refresh_token { - if let Ok(new_tokens) = self.refresh_token(&refresh_token).await { - self.access_token = new_tokens.access_token.clone(); - self.store_tokens(&new_tokens)?; - println!("Refreshed access token"); - return Ok(()); - } - } - } - - // Perform full OAuth flow - self.perform_oauth_flow().await?; - Ok(()) - } - - async fn perform_oauth_flow(&mut self) -> Result<(), Box> { - // Generate PKCE challenge - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - - // Generate authorization URL - let (auth_url, _csrf_token) = self - .oauth_client - .authorize_url(CsrfToken::new_random) - .add_scope(Scope::new( - "https://www.googleapis.com/auth/youtube.upload".to_string(), - )) - .set_pkce_challenge(pkce_challenge) - .url(); - - println!("Open this URL in your browser to authenticate:"); - println!("{}", auth_url); - println!("\nAfter authorization, you'll be redirected to your redirect URI."); - println!("Copy the 'code' parameter from the redirect URL and paste it here:"); - - // Get authorization code from user - let mut auth_code = String::new(); - std::io::stdin().read_line(&mut auth_code)?; - let auth_code = auth_code.trim(); - - // Exchange authorization code for access token - let token_result = self - .oauth_client - .exchange_code(AuthorizationCode::new(auth_code.to_string())) - .set_pkce_verifier(pkce_verifier) - .request_async(async_http_client) - .await?; - - // Store tokens - let expires_at = token_result - .expires_in() - .map(|duration| Utc::now() + Duration::seconds(duration.as_secs() as i64)); - - let tokens = StoredTokens { - access_token: token_result.access_token().secret().clone(), - refresh_token: token_result.refresh_token().map(|t| t.secret().clone()), - expires_at, - }; - - self.access_token = tokens.access_token.clone(); - self.store_tokens(&tokens)?; - - println!("Authentication successful!"); - Ok(()) - } - - async fn refresh_token( - &self, - refresh_token: &str, - ) -> Result> { - let params = [ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), - ("client_id", &self.client_id), - ("client_secret", &self.client_secret), - ]; - - let response = self - .client - .post("https://oauth2.googleapis.com/token") - .form(¶ms) - .send() - .await?; - - let token_data: serde_json::Value = response.json().await?; - - let access_token = token_data["access_token"] - .as_str() - .ok_or("No access token in response")? - .to_string(); - - let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600); - let expires_at = Some(Utc::now() + Duration::seconds(expires_in as i64)); - - Ok(StoredTokens { - access_token, - refresh_token: Some(refresh_token.to_string()), - expires_at, - }) - } - - fn store_tokens(&self, tokens: &StoredTokens) -> Result<(), Box> { - let tokens_path = expand_tilde("~/.youtube_tokens.json"); - let tokens_json = serde_json::to_string_pretty(tokens)?; - fs::write(&tokens_path, tokens_json)?; - Ok(()) - } - - fn load_tokens(&self) -> Result> { - let tokens_path = expand_tilde("~/.youtube_tokens.json"); - let tokens_json = fs::read_to_string(&tokens_path)?; - let tokens: StoredTokens = serde_json::from_str(&tokens_json)?; - Ok(tokens) - } - - async fn upload_video( - &self, - video_path: &str, - metadata: &VideoMetadata, - ) -> Result> { - // Read video file - let video_data = fs::read(video_path)?; - - // Combine snippet and status into a single JSON object - let metadata_json = json!({ - "snippet": { - "title": metadata.title, - "description": metadata.description, - "tags": metadata.tags, - "categoryId": metadata.category_id - }, - "status": { - "privacyStatus": metadata.privacy_status, - "publishAt": metadata.scheduled_start_time - } - }); - - // Create multipart form with only 2 parts: metadata and media - let form = reqwest::multipart::Form::new() - .part( - "snippet", - reqwest::multipart::Part::text(metadata_json.to_string()) - .mime_str("application/json")?, - ) - .part( - "media", - reqwest::multipart::Part::bytes(video_data) - .file_name("video.mp4") - .mime_str("video/mp4")?, - ); - - let response = self - .client - .post("https://www.googleapis.com/upload/youtube/v3/videos") - .query(&[("part", "snippet,status")]) - .header("Authorization", format!("Bearer {}", self.access_token)) - .multipart(form) - .send() - .await?; - - if response.status().is_success() { - let upload_response: UploadResponse = response.json().await?; - Ok(upload_response) - } else { - let error_text = response.text().await?; - Err(format!("Upload failed: {}", error_text).into()) - } - } -} - -fn parse_duration(duration_str: &str) -> Result> { - let duration_str = duration_str.to_lowercase(); - - if duration_str.ends_with("h") { - let hours: i64 = duration_str.trim_end_matches("h").parse()?; - Ok(Duration::hours(hours)) - } else if duration_str.ends_with("m") { - let minutes: i64 = duration_str.trim_end_matches("m").parse()?; - Ok(Duration::minutes(minutes)) - } else if duration_str.ends_with("d") { - let days: i64 = duration_str.trim_end_matches("d").parse()?; - Ok(Duration::days(days)) - } else { - // Default to hours if no unit specified - let hours: i64 = duration_str.parse()?; - Ok(Duration::hours(hours)) - } -} - -fn generate_schedule( - video_count: usize, - interval: Duration, - start_time: Option>, - timestamp_file: Option<&str>, -) -> Result>, Box> { - let mut schedule = Vec::new(); - - let start = if let Some(file_path) = timestamp_file { - // Read timestamp from file - let expanded_path = expand_tilde(file_path); - let timestamp_str = fs::read_to_string(&expanded_path) - .map_err(|e| format!("Failed to read timestamp from '{}': {}", expanded_path, e))?; - - let timestamp: i64 = timestamp_str - .trim() - .parse() - .map_err(|e| format!("Invalid timestamp in file '{}': {}", expanded_path, e))?; - - DateTime::from_timestamp(timestamp, 0) - .ok_or_else(|| format!("Invalid unix timestamp: {}", timestamp))? - } else if let Some(start_time) = start_time { - start_time - } else { - Utc::now() + Duration::hours(1) - }; - - for i in 0..video_count { - let scheduled_time = start + interval * i as i32; - schedule.push(scheduled_time); - } - - Ok(schedule) -} - -fn load_video_metadata( - metadata_path: &str, -) -> Result, Box> { - let content = fs::read_to_string(metadata_path)?; - let metadata: Vec = serde_json::from_str(&content)?; - Ok(metadata) -} - -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") { - if let Ok(home) = std::env::var("HOME") { - path.replacen("~", &home, 1) - } else { - path.to_string() - } - } else { - path.to_string() - } -} - -fn load_oauth_config(config_path: &str) -> Result> { - let expanded_path = expand_tilde(config_path); - let content = fs::read_to_string(&expanded_path).map_err(|e| { - format!( - "Failed to read OAuth config from '{}': {}", - expanded_path, e - ) - })?; - let config: OAuthConfig = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse OAuth config: {}", e))?; - Ok(config) -} - -fn create_default_metadata(video_files: &[String]) -> Vec { - video_files - .iter() - .enumerate() - .map(|(_i, file_path)| { - let filename = Path::new(file_path) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - VideoMetadata { - title: format!("{}", filename), - description: get_random_line("/home/linly/org/quotes.org").expect("WHAT DA HAIL"), - tags: vec!["gaming".to_string()], - category_id: "20".to_string(), // GAMING - privacy_status: "private".to_string(), - scheduled_start_time: None, - } - }) - .collect() -} #[tokio::main] async fn main() -> Result<(), Box> { let matches = Command::new("youtube-scheduler") .version("1.0") - .author("Your Name") + .author("LinlyBoi") .about("Upload and schedule YouTube videos") .arg( Arg::new("videos") @@ -529,20 +182,3 @@ async fn main() -> Result<(), Box> { Ok(()) } //For the random descriptions LOL -fn get_random_line>(path: P) -> io::Result { - let file = File::open(path)?; - let reader = BufReader::new(file); - let lines: Vec = reader.lines().collect::>>()?; - - if lines.is_empty() { - return Err(io::Error::new( - io::ErrorKind::Other, - "No lines found in the file.", - )); - } - - let mut rng = rand::thread_rng(); - let random_line = lines.choose(&mut rng).unwrap().to_string(); - - Ok(random_line) -} diff --git a/src/youtube.rs b/src/youtube.rs new file mode 100644 index 0000000..843bdfe --- /dev/null +++ b/src/youtube.rs @@ -0,0 +1,322 @@ +use clap::{Arg, Command}; +use oauth2::basic::BasicClient; +use oauth2::reqwest::async_http_client; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, + Scope, TokenResponse, TokenUrl, +}; +use rand::seq::SliceRandom; +use reqwest::Client; +use serde_json::json; +use youtube_scheduler::*; +use std::path::Path; +use tokio; +use chrono::{DateTime, Duration, Utc}; +use std::fs::{self, File}; +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, BufReader}; +use youtube_scheduler::expand_tilde; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthConfig { + client_id: String, + client_secret: String, + redirect_uri: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VideoMetadata { + title: String, + description: String, + tags: Vec, + category_id: String, + pub privacy_status: String, + pub scheduled_start_time: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredTokens { + access_token: String, + refresh_token: Option, + expires_at: Option>, +} + + +#[derive(Debug, Serialize, Deserialize)] +pub struct UploadResponse { + pub id: String, + snippet: serde_json::Value, + status: serde_json::Value, +} + +pub struct YouTubeUploader { + client: Client, + access_token: String, + oauth_client: BasicClient, + client_id: String, + client_secret: String, +} + +impl YouTubeUploader { + pub fn new(oauth_config: &OAuthConfig) -> Result> { + let oauth_client = BasicClient::new( + ClientId::new(oauth_config.client_id.clone()), + Some(ClientSecret::new(oauth_config.client_secret.clone())), + AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())?, + Some(TokenUrl::new( + "https://oauth2.googleapis.com/token".to_string(), + )?), + ) + .set_redirect_uri(RedirectUrl::new(oauth_config.redirect_uri.clone())?); + + Ok(Self { + client: Client::new(), + access_token: String::new(), + oauth_client, + client_id: oauth_config.client_id.clone(), + client_secret: oauth_config.client_secret.clone(), + }) + } + + pub async fn authenticate(&mut self) -> Result<(), Box> { + // Try to load existing tokens + if let Ok(tokens) = self.load_tokens() { + if let Some(expires_at) = tokens.expires_at { + if expires_at > Utc::now() + Duration::minutes(5) { + // Token is still valid + self.access_token = tokens.access_token; + println!("Using existing valid token"); + return Ok(()); + } + } + + // Try to refresh the token + if let Some(refresh_token) = tokens.refresh_token { + if let Ok(new_tokens) = self.refresh_token(&refresh_token).await { + self.access_token = new_tokens.access_token.clone(); + self.store_tokens(&new_tokens)?; + println!("Refreshed access token"); + return Ok(()); + } + } + } + + // Perform full OAuth flow + self.perform_oauth_flow().await?; + Ok(()) + } + + async fn perform_oauth_flow(&mut self) -> Result<(), Box> { + // Generate PKCE challenge + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Generate authorization URL + let (auth_url, _csrf_token) = self + .oauth_client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new( + "https://www.googleapis.com/auth/youtube.upload".to_string(), + )) + .set_pkce_challenge(pkce_challenge) + .url(); + + println!("Open this URL in your browser to authenticate:"); + println!("{}", auth_url); + println!("\nAfter authorization, you'll be redirected to your redirect URI."); + println!("Copy the 'code' parameter from the redirect URL and paste it here:"); + + // Get authorization code from user + let mut auth_code = String::new(); + std::io::stdin().read_line(&mut auth_code)?; + let auth_code = auth_code.trim(); + + // Exchange authorization code for access token + let token_result = self + .oauth_client + .exchange_code(AuthorizationCode::new(auth_code.to_string())) + .set_pkce_verifier(pkce_verifier) + .request_async(async_http_client) + .await?; + + // Store tokens + let expires_at = token_result + .expires_in() + .map(|duration| Utc::now() + Duration::seconds(duration.as_secs() as i64)); + + let tokens = StoredTokens { + access_token: token_result.access_token().secret().clone(), + refresh_token: token_result.refresh_token().map(|t| t.secret().clone()), + expires_at, + }; + + self.access_token = tokens.access_token.clone(); + self.store_tokens(&tokens)?; + + println!("Authentication successful!"); + Ok(()) + } + + async fn refresh_token( + &self, + refresh_token: &str, + ) -> Result> { + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ]; + + let response = self + .client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await?; + + let token_data: serde_json::Value = response.json().await?; + + let access_token = token_data["access_token"] + .as_str() + .ok_or("No access token in response")? + .to_string(); + + let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600); + let expires_at = Some(Utc::now() + Duration::seconds(expires_in as i64)); + + Ok(StoredTokens { + access_token, + refresh_token: Some(refresh_token.to_string()), + expires_at, + }) + } + + fn store_tokens(&self, tokens: &StoredTokens) -> Result<(), Box> { + let tokens_path = expand_tilde("~/.youtube_tokens.json"); + let tokens_json = serde_json::to_string_pretty(tokens)?; + fs::write(&tokens_path, tokens_json)?; + Ok(()) + } + + fn load_tokens(&self) -> Result> { + let tokens_path = expand_tilde("~/.youtube_tokens.json"); + let tokens_json = fs::read_to_string(&tokens_path)?; + let tokens: StoredTokens = serde_json::from_str(&tokens_json)?; + Ok(tokens) + } + + pub async fn upload_video( + &self, + video_path: &str, + metadata: &VideoMetadata, + ) -> Result> { + // Read video file + let video_data = fs::read(video_path)?; + + // Combine snippet and status into a single JSON object + let metadata_json = json!({ + "snippet": { + "title": metadata.title, + "description": metadata.description, + "tags": metadata.tags, + "categoryId": metadata.category_id + }, + "status": { + "privacyStatus": metadata.privacy_status, + "publishAt": metadata.scheduled_start_time + } + }); + + // Create multipart form with only 2 parts: metadata and media + let form = reqwest::multipart::Form::new() + .part( + "snippet", + reqwest::multipart::Part::text(metadata_json.to_string()) + .mime_str("application/json")?, + ) + .part( + "media", + reqwest::multipart::Part::bytes(video_data) + .file_name("video.mp4") + .mime_str("video/mp4")?, + ); + + let response = self + .client + .post("https://www.googleapis.com/upload/youtube/v3/videos") + .query(&[("part", "snippet,status")]) + .header("Authorization", format!("Bearer {}", self.access_token)) + .multipart(form) + .send() + .await?; + + if response.status().is_success() { + let upload_response: UploadResponse = response.json().await?; + Ok(upload_response) + } else { + let error_text = response.text().await?; + Err(format!("Upload failed: {}", error_text).into()) + } + } +} + + +pub fn create_default_metadata(video_files: &[String]) -> Vec { + video_files + .iter() + .enumerate() + .map(|(_i, file_path)| { + let filename = Path::new(file_path) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + VideoMetadata { + title: format!("{}", filename), + description: get_random_line("/home/linly/org/quotes.org").expect("WHAT DA HAIL"), + tags: vec!["gaming".to_string()], + category_id: "20".to_string(), // GAMING + privacy_status: "private".to_string(), + scheduled_start_time: None, + } + }) + .collect() +} +fn get_random_line>(path: P) -> io::Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let lines: Vec = reader.lines().collect::>>()?; + + if lines.is_empty() { + return Err(io::Error::new( + io::ErrorKind::Other, + "No lines found in the file.", + )); + } + + let mut rng = rand::thread_rng(); + let random_line = lines.choose(&mut rng).unwrap().to_string(); + + Ok(random_line) +} +pub fn load_video_metadata( + metadata_path: &str, +) -> Result, Box> { + let content = fs::read_to_string(metadata_path)?; + let metadata: Vec = serde_json::from_str(&content)?; + Ok(metadata) +} + +pub fn load_oauth_config(config_path: &str) -> Result> { + let expanded_path = expand_tilde(config_path); + let content = fs::read_to_string(&expanded_path).map_err(|e| { + format!( + "Failed to read OAuth config from '{}': {}", + expanded_path, e + ) + })?; + let config: OAuthConfig = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse OAuth config: {}", e))?; + Ok(config) +}