refactor: split it into files
Claude might be the goat but his code ain't that clean yo
This commit is contained in:
76
src/lib.rs
Normal file
76
src/lib.rs
Normal file
@@ -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<Duration, Box<dyn std::error::Error>> {
|
||||||
|
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<DateTime<Utc>>,
|
||||||
|
timestamp_file: Option<&str>,
|
||||||
|
) -> Result<Vec<DateTime<Utc>>, Box<dyn std::error::Error>> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
382
src/main.rs
382
src/main.rs
@@ -1,4 +1,3 @@
|
|||||||
use chrono::{DateTime, Duration, Utc};
|
|
||||||
use clap::{Arg, Command};
|
use clap::{Arg, Command};
|
||||||
use oauth2::basic::BasicClient;
|
use oauth2::basic::BasicClient;
|
||||||
use oauth2::reqwest::async_http_client;
|
use oauth2::reqwest::async_http_client;
|
||||||
@@ -6,375 +5,29 @@ use oauth2::{
|
|||||||
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
|
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
|
||||||
Scope, TokenResponse, TokenUrl,
|
Scope, TokenResponse, TokenUrl,
|
||||||
};
|
};
|
||||||
use rand::seq::SliceRandom;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs::{self, File};
|
use youtube::{create_default_metadata, load_oauth_config, load_video_metadata, YouTubeUploader};
|
||||||
use std::io::{self, BufRead, BufReader};
|
use youtube_scheduler::*;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio;
|
use tokio;
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{self};
|
||||||
|
|
||||||
|
|
||||||
|
mod youtube;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod 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<String>,
|
|
||||||
expires_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct VideoMetadata {
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
tags: Vec<String>,
|
|
||||||
category_id: String,
|
|
||||||
privacy_status: String,
|
|
||||||
scheduled_start_time: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<Self, Box<dyn std::error::Error>> {
|
|
||||||
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<dyn std::error::Error>> {
|
|
||||||
// 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<dyn std::error::Error>> {
|
|
||||||
// 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<StoredTokens, Box<dyn std::error::Error>> {
|
|
||||||
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<dyn std::error::Error>> {
|
|
||||||
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<StoredTokens, Box<dyn std::error::Error>> {
|
|
||||||
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<UploadResponse, Box<dyn std::error::Error>> {
|
|
||||||
// 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<Duration, Box<dyn std::error::Error>> {
|
|
||||||
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<DateTime<Utc>>,
|
|
||||||
timestamp_file: Option<&str>,
|
|
||||||
) -> Result<Vec<DateTime<Utc>>, Box<dyn std::error::Error>> {
|
|
||||||
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<Vec<VideoMetadata>, Box<dyn std::error::Error>> {
|
|
||||||
let content = fs::read_to_string(metadata_path)?;
|
|
||||||
let metadata: Vec<VideoMetadata> = 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<OAuthConfig, Box<dyn std::error::Error>> {
|
|
||||||
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<VideoMetadata> {
|
|
||||||
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let matches = Command::new("youtube-scheduler")
|
let matches = Command::new("youtube-scheduler")
|
||||||
.version("1.0")
|
.version("1.0")
|
||||||
.author("Your Name")
|
.author("LinlyBoi")
|
||||||
.about("Upload and schedule YouTube videos")
|
.about("Upload and schedule YouTube videos")
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("videos")
|
Arg::new("videos")
|
||||||
@@ -529,20 +182,3 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
//For the random descriptions LOL
|
//For the random descriptions LOL
|
||||||
fn get_random_line<P: AsRef<Path>>(path: P) -> io::Result<String> {
|
|
||||||
let file = File::open(path)?;
|
|
||||||
let reader = BufReader::new(file);
|
|
||||||
let lines: Vec<String> = reader.lines().collect::<io::Result<Vec<String>>>()?;
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
322
src/youtube.rs
Normal file
322
src/youtube.rs
Normal file
@@ -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<String>,
|
||||||
|
category_id: String,
|
||||||
|
pub privacy_status: String,
|
||||||
|
pub scheduled_start_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct StoredTokens {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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<Self, Box<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
// 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<dyn std::error::Error>> {
|
||||||
|
// 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<StoredTokens, Box<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<StoredTokens, Box<dyn std::error::Error>> {
|
||||||
|
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<UploadResponse, Box<dyn std::error::Error>> {
|
||||||
|
// 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<VideoMetadata> {
|
||||||
|
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<P: AsRef<Path>>(path: P) -> io::Result<String> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let lines: Vec<String> = reader.lines().collect::<io::Result<Vec<String>>>()?;
|
||||||
|
|
||||||
|
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<Vec<VideoMetadata>, Box<dyn std::error::Error>> {
|
||||||
|
let content = fs::read_to_string(metadata_path)?;
|
||||||
|
let metadata: Vec<VideoMetadata> = serde_json::from_str(&content)?;
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_oauth_config(config_path: &str) -> Result<OAuthConfig, Box<dyn std::error::Error>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user