From e6648537283b96fe5624cce4e64431b392cc53e8 Mon Sep 17 00:00:00 2001 From: VC Date: Fri, 29 Sep 2023 18:10:10 +0200 Subject: [PATCH] First functional version --- .gitlab-ci.yml | 5 ++ Cargo.toml | 6 ++ README.md | 28 ++++++++ src/config.rs | 4 +- src/error.rs | 33 ++++++++++ src/lib.rs | 61 +++++++++++++++++- src/peertube.rs | 5 +- src/youtube.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 README.md create mode 100644 src/error.rs create mode 100644 src/youtube.rs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..71d49ab --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,5 @@ +--- +include: + project: 'veretcle/ci-common' + ref: 'main' + file: 'ci_rust.yml' diff --git a/Cargo.toml b/Cargo.toml index f28191a..852204f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tootube" +authors = ["VC "] version = "0.1.0" edition = "2021" @@ -10,3 +11,8 @@ reqwest = { version = "^0.11", features = ["blocking", "json"] } clap = "^4" serde = { version = "1.0", features = ["derive"] } toml = "^0.5" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..447fde7 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Obtain Authorization Token from Google + +That the complicated part: +* create an OAuth2.0 application with authorization for Youtube DATA Api v3 Upload +* create a OAuth2.0 client with Desktop client + +You’ll need: +* the `client_id` from your OAuth2.0 client +* the `client_secret` from you OAuth2.0 client + +Then enter in: + +``` +https://accounts.google.com/o/oauth2/v2/auth?client_id=XXX.apps.googleusercontent.com&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/youtube.upload&response_type=code +``` + +And accept that your YouTube account might be modified. You’ll get a code then; enter this `curl` post: + +``` +curl -s \ +--request POST \ +--data "code=[THE_CODE]&client_id=XXX.apps.googleusercontent.com&client_secret=[THE_CLIENT_SECRET]&redirect_uri=urn:ietf:wg:oauth:2.0:oob&grant_type=authorization_code" \ +https://accounts.google.com/o/oauth2/token +``` + +You’ll get a Token. The only important part is the `refresh_token` + +In your `tootube.toml` config file, put the `refresh_token`. diff --git a/src/config.rs b/src/config.rs index 0f7d028..f86e3cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,7 +16,9 @@ pub struct PeertubeConfig { #[derive(Debug, Deserialize)] pub struct YoutubeConfig { - pub api_key: String, + pub refresh_token: String, + pub client_id: String, + pub client_secret: String, } /// Parses the TOML file into a Config struct diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..dc012bf --- /dev/null +++ b/src/error.rs @@ -0,0 +1,33 @@ +use std::{ + boxed::Box, + convert::From, + error::Error, + fmt::{Display, Formatter, Result}, +}; + +#[derive(Debug)] +pub struct TootubeError { + details: String, +} + +impl TootubeError { + pub fn new(msg: &str) -> TootubeError { + TootubeError { + details: msg.to_string(), + } + } +} + +impl Error for TootubeError {} + +impl Display for TootubeError { + fn fmt(&self, f: &mut Formatter) -> Result { + write!(f, "{}", self.details) + } +} + +impl From> for TootubeError { + fn from(error: Box) -> Self { + TootubeError::new(&format!("Error in a subset crate: {error}")) + } +} diff --git a/src/lib.rs b/src/lib.rs index 2bbbe90..100bcfe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,14 @@ +use std::{ + error::Error, + fs::create_dir_all, + fs::{remove_file, File}, +}; + +use reqwest::Url; + +mod error; +use error::TootubeError; + mod config; pub use config::parse_toml; use config::Config; @@ -5,13 +16,59 @@ use config::Config; mod peertube; use peertube::{get_latest_video, get_max_resolution_dl}; +mod youtube; +use youtube::{create_resumable_upload, upload_video}; + +const TMP_DIR: &str = "/tmp/tootube"; + +fn dl_video(u: &str) -> Result> { + // create dir + create_dir_all(TMP_DIR)?; + + // get file + let mut response = reqwest::blocking::get(u)?; + + // create local file + let url = Url::parse(u)?; + let dest_filename = url + .path_segments() + .ok_or_else(|| { + TootubeError::new(&format!( + "Cannot determine the destination filename for {u}" + )) + })? + .last() + .ok_or_else(|| { + TootubeError::new(&format!( + "Cannot determine the destination filename for {u}" + )) + })?; + + let dest_filepath = format!("{TMP_DIR}/{dest_filename}"); + + let mut dest_file = File::create(&dest_filepath)?; + + response.copy_to(&mut dest_file)?; + + Ok(dest_filepath) +} + pub fn run(config: Config) { // Get the latest video object let latest_vid = get_latest_video(&config.peertube.base_url).unwrap_or_else(|e| { panic!("Cannot retrieve the latest video, something must have gone terribly wrong: {e}") }); - let dl_url = get_max_resolution_dl(&latest_vid.streaming_playlists.unwrap()); + let dl_url = get_max_resolution_dl(latest_vid.streaming_playlists.as_ref().unwrap()); - println!("{dl_url}"); + let local_path = dl_video(&dl_url) + .unwrap_or_else(|e| panic!("Cannot download video at URL {}: {}", dl_url, e)); + + let resumable_upload_id = create_resumable_upload(&config.youtube, &latest_vid) + .unwrap_or_else(|e| panic!("Cannot retrieve the upload’s resumable id: {e}")); + + upload_video(&local_path, &resumable_upload_id, &config.youtube) + .unwrap_or_else(|e| panic!("Cannot resume upload!: {e}")); + + remove_file(&local_path).unwrap_or_else(|e| panic!("Cannot delete file {}: {}", local_path, e)); } diff --git a/src/peertube.rs b/src/peertube.rs index ed0b4bf..78c94a0 100644 --- a/src/peertube.rs +++ b/src/peertube.rs @@ -14,6 +14,7 @@ pub struct PeerTubeVideo { pub description: String, #[serde(rename = "streamingPlaylists")] pub streaming_playlists: Option>, + pub tags: Option>, } #[derive(Debug, Deserialize)] @@ -36,7 +37,7 @@ pub struct PeerTubeVideoStreamingPlaylistsFilesResolution { /// This gets the last video uploaded to the PeerTube server pub fn get_latest_video(u: &str) -> Result> { - let body = reqwest::blocking::get(format!("{}/api/v1/videos?count=1&sort=-publishedAt", u))? + let body = reqwest::blocking::get(format!("{}/api/v1/videos?count=1&sort=publishedAt", u))? .json::()?; let vid = get_video_detail(u, &body.data[0].uuid)?; @@ -45,7 +46,7 @@ pub fn get_latest_video(u: &str) -> Result> { } /// This returns the direct download URL for with the maximum resolution -pub fn get_max_resolution_dl(p: &Vec) -> String { +pub fn get_max_resolution_dl(p: &[PeerTubeVideoStreamingPlaylists]) -> String { let mut res = 0; let mut dl_url = String::new(); diff --git a/src/youtube.rs b/src/youtube.rs new file mode 100644 index 0000000..2f7f177 --- /dev/null +++ b/src/youtube.rs @@ -0,0 +1,167 @@ +use crate::{config::YoutubeConfig, error::TootubeError, peertube::PeerTubeVideo}; +use serde::{Deserialize, Serialize}; +use std::{error::Error, sync::Mutex}; + +static ACCESS_TOKEN: Mutex = Mutex::new(String::new()); + +#[derive(Serialize, Debug)] +struct RefreshTokenRequest { + refresh_token: String, + client_id: String, + client_secret: String, + redirect_uri: String, + grant_type: String, +} + +impl Default for RefreshTokenRequest { + fn default() -> Self { + RefreshTokenRequest { + refresh_token: "".to_string(), + client_id: "".to_string(), + client_secret: "".to_string(), + redirect_uri: "urn:ietf:wg:oauth:2.0:oob".to_string(), + grant_type: "refresh_token".to_string(), + } + } +} + +#[derive(Deserialize, Debug)] +struct AccessTokenResponse { + access_token: String, +} + +#[derive(Serialize, Debug)] +struct YoutubeUploadParams { + snippet: YoutubeUploadParamsSnippet, + status: YoutubeUploadParamsStatus, +} + +#[derive(Serialize, Debug)] +struct YoutubeUploadParamsSnippet { + title: String, + description: String, + #[serde(rename = "categoryId")] + category_id: String, + #[serde(rename = "defaultAudioLanguage")] + default_audio_language: String, + tags: Option>, +} + +impl Default for YoutubeUploadParamsSnippet { + fn default() -> Self { + YoutubeUploadParamsSnippet { + title: "".to_string(), + description: "".to_string(), + category_id: "20".to_string(), + default_audio_language: "fr".to_string(), + tags: None, + } + } +} + +#[derive(Serialize, Debug)] +struct YoutubeUploadParamsStatus { + #[serde(rename = "selfDeclaredMadeForKids")] + self_declared_made_for_kids: bool, + #[serde(rename = "privacyStatus")] + privacy_status: String, + license: String, +} + +impl Default for YoutubeUploadParamsStatus { + fn default() -> Self { + YoutubeUploadParamsStatus { + self_declared_made_for_kids: false, + privacy_status: "public".to_string(), + license: "creativeCommon".to_string(), + } + } +} + +/// Ensures that Token has been refreshed and that it is unique +fn refresh_token(config: &YoutubeConfig) -> Result> { + if let Ok(mut unlocked_access_token) = ACCESS_TOKEN.lock() { + if unlocked_access_token.is_empty() { + let refresh_token = RefreshTokenRequest { + refresh_token: config.refresh_token.clone(), + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + ..Default::default() + }; + + let client = reqwest::blocking::Client::new(); + let res = client + .post("https://accounts.google.com/o/oauth2/token") + .json(&refresh_token) + .send()?; + + let access_token: AccessTokenResponse = res.json()?; + + *unlocked_access_token = access_token.access_token.clone(); + } + } + + Ok(ACCESS_TOKEN.lock().unwrap().to_string()) +} + +pub fn create_resumable_upload( + config: &YoutubeConfig, + vid: &PeerTubeVideo, +) -> Result> { + let access_token = refresh_token(config)?; + + let upload_params = YoutubeUploadParams { + snippet: { + YoutubeUploadParamsSnippet { + title: vid.name.clone(), + description: vid.description.clone(), + tags: vid.tags.clone(), + ..Default::default() + } + }, + status: { + YoutubeUploadParamsStatus { + ..Default::default() + } + }, + }; + + let client = reqwest::blocking::Client::new(); + + let res = client.post("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet%2Cstatus") + .header("Authorization", format!("Bearer {}", access_token)) + .json(&upload_params) + .send()?; + + if res.status().is_success() { + Ok(res + .headers() + .get("x-guploader-uploadid") + .ok_or("Cannot find suitable header")? + .to_str()? + .to_string()) + } else { + Err(TootubeError::new("Cannot create resumable upload!").into()) + } +} + +pub fn upload_video( + f_path: &str, + r_id: &str, + config: &YoutubeConfig, +) -> Result<(), Box> { + let access_token = refresh_token(config)?; + + let client = reqwest::blocking::Client::new(); + + let res = client.put(format!("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet%2Cstatus&upload_id={}", r_id)) + .header("Authorization", format!("Bearer {}", access_token)) + .body(f_path.to_string()) + .send()?; + + if res.status().is_success() { + Ok(()) + } else { + Err(TootubeError::new(&format!("Cannot upload video: {:?}", res.text())).into()) + } +}