Merge branch 'tootube1' into 'master'

First functional version

See merge request veretcle/tootube!2
This commit is contained in:
VC
2023-10-03 13:41:28 +00:00
8 changed files with 304 additions and 5 deletions

5
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,5 @@
---
include:
project: 'veretcle/ci-common'
ref: 'main'
file: 'ci_rust.yml'

View File

@@ -1,5 +1,6 @@
[package] [package]
name = "tootube" name = "tootube"
authors = ["VC <veretcle+framagit@mateu.be>"]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@@ -10,3 +11,8 @@ reqwest = { version = "^0.11", features = ["blocking", "json"] }
clap = "^4" clap = "^4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "^0.5" toml = "^0.5"
[profile.release]
strip = true
lto = true
codegen-units = 1

28
README.md Normal file
View File

@@ -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
Youll 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. Youll 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
```
Youll get a Token. The only important part is the `refresh_token`
In your `tootube.toml` config file, put the `refresh_token`.

View File

@@ -16,7 +16,9 @@ pub struct PeertubeConfig {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct YoutubeConfig { 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 /// Parses the TOML file into a Config struct

33
src/error.rs Normal file
View File

@@ -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<Box<dyn Error>> for TootubeError {
fn from(error: Box<dyn Error>) -> Self {
TootubeError::new(&format!("Error in a subset crate: {error}"))
}
}

View File

@@ -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; mod config;
pub use config::parse_toml; pub use config::parse_toml;
use config::Config; use config::Config;
@@ -5,13 +16,59 @@ use config::Config;
mod peertube; mod peertube;
use peertube::{get_latest_video, get_max_resolution_dl}; 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<String, Box<dyn Error>> {
// 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) { pub fn run(config: Config) {
// Get the latest video object // Get the latest video object
let latest_vid = get_latest_video(&config.peertube.base_url).unwrap_or_else(|e| { 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}") 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 uploads 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));
} }

View File

@@ -14,6 +14,7 @@ pub struct PeerTubeVideo {
pub description: String, pub description: String,
#[serde(rename = "streamingPlaylists")] #[serde(rename = "streamingPlaylists")]
pub streaming_playlists: Option<Vec<PeerTubeVideoStreamingPlaylists>>, pub streaming_playlists: Option<Vec<PeerTubeVideoStreamingPlaylists>>,
pub tags: Option<Vec<String>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -36,7 +37,7 @@ pub struct PeerTubeVideoStreamingPlaylistsFilesResolution {
/// This gets the last video uploaded to the PeerTube server /// This gets the last video uploaded to the PeerTube server
pub fn get_latest_video(u: &str) -> Result<PeerTubeVideo, Box<dyn Error>> { pub fn get_latest_video(u: &str) -> Result<PeerTubeVideo, Box<dyn Error>> {
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::<PeerTubeVideos>()?; .json::<PeerTubeVideos>()?;
let vid = get_video_detail(u, &body.data[0].uuid)?; let vid = get_video_detail(u, &body.data[0].uuid)?;
@@ -45,7 +46,7 @@ pub fn get_latest_video(u: &str) -> Result<PeerTubeVideo, Box<dyn Error>> {
} }
/// This returns the direct download URL for with the maximum resolution /// This returns the direct download URL for with the maximum resolution
pub fn get_max_resolution_dl(p: &Vec<PeerTubeVideoStreamingPlaylists>) -> String { pub fn get_max_resolution_dl(p: &[PeerTubeVideoStreamingPlaylists]) -> String {
let mut res = 0; let mut res = 0;
let mut dl_url = String::new(); let mut dl_url = String::new();

167
src/youtube.rs Normal file
View File

@@ -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<String> = 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<Vec<String>>,
}
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<String, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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<dyn Error>> {
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())
}
}