mirror of
https://framagit.org/veretcle/tootube.git
synced 2025-07-20 12:31:19 +02:00
First functional version
This commit is contained in:
5
.gitlab-ci.yml
Normal file
5
.gitlab-ci.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
include:
|
||||||
|
project: 'veretcle/ci-common'
|
||||||
|
ref: 'main'
|
||||||
|
file: 'ci_rust.yml'
|
@@ -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
28
README.md
Normal 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
|
||||||
|
|
||||||
|
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`.
|
@@ -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
33
src/error.rs
Normal 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}"))
|
||||||
|
}
|
||||||
|
}
|
61
src/lib.rs
61
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;
|
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 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));
|
||||||
}
|
}
|
||||||
|
@@ -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
167
src/youtube.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user