use crate::{config::PeertubeConfig, config::PeertubeConfigOauth2, error::TootubeError}; use log::debug; use reqwest::{ header::{HeaderMap, HeaderValue}, multipart::Form, Client, }; use rpassword::prompt_password; use serde::{Deserialize, Serialize}; use std::{boxed::Box, cmp::Ordering, error::Error, io::stdin}; #[derive(Debug, Deserialize)] pub struct PeerTubeVideos { pub data: Vec, } #[derive(Debug, Deserialize)] pub struct PeerTubeVideo { pub name: String, pub uuid: String, pub description: String, pub duration: u64, #[serde(rename = "aspectRatio")] pub aspect_ratio: Option, #[serde(rename = "previewPath")] pub preview_path: String, #[serde(rename = "streamingPlaylists")] pub streaming_playlists: Option>, pub tags: Option>, pub channel: PeerTubeVideoChannel, } impl PeerTubeVideo { pub fn is_short(&self) -> bool { if self.duration < 60 && self.aspect_ratio.is_some_and(|x| x == 0.5625) { return true; } false } } #[derive(Debug, Deserialize)] pub struct PeerTubeVideoChannel { pub id: u8, } #[derive(Debug, Deserialize)] pub struct PeerTubeVideoStreamingPlaylists { pub files: Vec, } #[derive(Debug, Deserialize)] struct PeerTubeOauthClientsLocalResponse { client_id: String, client_secret: String, } #[derive(Debug, Serialize)] struct PeerTubeUsersToken { client_id: String, client_secret: String, grant_type: String, password: Option, username: Option, refresh_token: Option, } #[derive(Debug, Deserialize)] struct PeerTubeUsersTokenResponse { refresh_token: String, access_token: String, } #[derive(Debug, Deserialize)] struct PeerTubeVideoSourceResponse { #[serde(rename = "fileDownloadUrl")] pub file_download_url: String, } #[derive(Debug, Deserialize)] struct PeerTubeVideoTokenResponse { files: PeerTubeVideoTokenResponseFiles, } #[derive(Debug, Deserialize)] struct PeerTubeVideoTokenResponseFiles { token: String, } #[derive(Eq, Debug, Deserialize)] pub struct PeerTubeVideoStreamingPlaylistsFiles { pub resolution: PeerTubeVideoStreamingPlaylistsFilesResolution, #[serde(rename = "fileDownloadUrl")] pub file_download_url: String, } impl Ord for PeerTubeVideoStreamingPlaylistsFiles { fn cmp(&self, other: &Self) -> Ordering { self.resolution.id.cmp(&other.resolution.id) } } impl PartialOrd for PeerTubeVideoStreamingPlaylistsFiles { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl PartialEq for PeerTubeVideoStreamingPlaylistsFiles { fn eq(&self, other: &Self) -> bool { self.resolution.id == other.resolution.id } } #[derive(Eq, Debug, Deserialize, PartialEq)] pub struct PeerTubeVideoStreamingPlaylistsFilesResolution { pub id: u16, } #[derive(Debug, Deserialize)] pub struct PeerTubeVideoPlaylists { pub total: u16, pub data: Vec, } #[derive(Debug, Deserialize)] pub struct PeerTubeVideoPlaylist { pub uuid: String, #[serde(rename = "displayName")] pub display_name: String, } #[derive(Debug, Serialize)] struct PeerTubeVideoPlaylistsVideos { #[serde(rename = "videoId")] video_id: String, } #[derive(Debug, Deserialize)] struct PeerTubeVideoPlaylistResponse { #[serde(rename = "videoPlaylist")] video_playlist: PeerTubeVideoPlaylistResponseVideoPlaylist, } #[derive(Debug, Deserialize)] struct PeerTubeVideoPlaylistResponseVideoPlaylist { uuid: String, } #[derive(Debug, Deserialize)] struct PeerTubeVideoPlaylistsPlaylistIdVideos { total: u32, data: Vec, } #[derive(Debug, Deserialize)] struct PeerTubeVideoPlaylistsPlaylistIdVideosData { video: PeerTubeVideo, } /// This function makes the registration process a little bit easier #[tokio::main] pub async fn register(config: &PeertubeConfig) -> Result> { // Get client ID/secret let oauth2_client = reqwest::get(format!("{}/api/v1/oauth-clients/local", config.base_url)) .await? .json::() .await?; println!( "Please type your PeerTube username for instance {}:", config.base_url ); let mut username = String::new(); stdin() .read_line(&mut username) .expect("Unable to read back the username!"); let password = prompt_password("Your password: ").expect("Unable to read back the password!"); let params = PeerTubeUsersToken { client_id: oauth2_client.client_id.clone(), client_secret: oauth2_client.client_secret.clone(), grant_type: "password".to_string(), username: Some(username.trim().to_string()), password: Some(password.clone()), refresh_token: None, }; let client = Client::new(); let oauth2_token = client .post(format!("{}/api/v1/users/token", config.base_url)) .form(¶ms) .send() .await? .json::() .await?; println!( "The following lines will be written to the `peertube` section of your tootube.toml file:" ); println!("[peertube.oauth2]"); println!("client_id=\"{}\"", oauth2_client.client_id); println!("client_secret=\"{}\"", oauth2_client.client_secret); println!("refresh_token=\"{}\"", oauth2_token.refresh_token); Ok(PeertubeConfigOauth2 { client_id: oauth2_client.client_id, client_secret: oauth2_client.client_secret, refresh_token: oauth2_token.refresh_token, }) } #[derive(Debug)] pub struct PeerTube { base_url: String, pub refresh_token: Option, client: Client, } impl PeerTube { /// Create a new PeerTube struct with a basic embedded reqwest::Client pub fn new(base_url: &str) -> Self { PeerTube { base_url: format!("{}/api/v1", base_url), refresh_token: None, client: Client::new(), } } /// Retrieve the refresh_token and access_token and update the embedded reqwest::Client to have /// the default required header pub async fn with_client( mut self, pt_oauth2: &PeertubeConfigOauth2, ) -> Result> { let params = PeerTubeUsersToken { client_id: pt_oauth2.client_id.to_owned(), client_secret: pt_oauth2.client_secret.to_owned(), grant_type: "refresh_token".to_string(), refresh_token: Some(pt_oauth2.refresh_token.to_owned()), username: None, password: None, }; let req = self .client .post(format!("{}/users/token", self.base_url)) .form(¶ms) .send() .await? .json::() .await?; let mut headers = HeaderMap::new(); headers.insert( "Authorization", HeaderValue::from_str(&format!("Bearer {}", req.access_token))?, ); self.client = reqwest::Client::builder() .default_headers(headers) .build()?; self.refresh_token = Some(req.refresh_token); Ok(self) } /// This gets the last video uploaded to the PeerTube server pub async fn get_latest_video(&self) -> Result> { let body = self .client .get(format!( "{}/videos?count=1&sort=-publishedAt", self.base_url )) .send() .await? .json::() .await?; let vid = self.get_video_detail(&body.data[0].uuid).await?; Ok(vid) } /// This gets all the crispy details about one particular video pub async fn get_video_detail(&self, v: &str) -> Result> { let body = self .client .get(format!("{}/videos/{}", self.base_url, v)) .send() .await? .json::() .await?; Ok(body) } /// Get the original video source pub async fn get_original_video_source(&self, uuid: &str) -> Result> { let source_vid = self .client .get(format!("{}/videos/{}/source", self.base_url, uuid)) .send() .await? .json::() .await?; debug!("Got the Source Vid URL: {}", &source_vid.file_download_url); let video_file_token = self .client .post(format!("{}/videos/{}/token", self.base_url, uuid)) .send() .await? .json::() .await?; debug!("Got the File Token: {}", &video_file_token.files.token); Ok(format!( "{}?videoFileToken={}", source_vid.file_download_url, video_file_token.files.token )) } /// Delete the original video source pub async fn delete_original_video_source(&self, uuid: &str) -> Result<(), Box> { let res = self .client .delete(format!("{}/videos/{}/source/file", self.base_url, uuid)) .send() .await?; if !res.status().is_success() { return Err(TootubeError::new(&format!( "Cannot delete source video file {}: {}", uuid, res.text().await? )) .into()); } Ok(()) } /// List every playlists on PeerTube pub async fn list_video_playlists(&self) -> Result, Box> { let mut playlists: Vec = vec![]; let mut start = 0; let inc = 15; while let Ok(mut local_pl) = self .client .get(format!( "{}/video-playlists?count={}&playlistType=1&start={}", self.base_url, inc, start )) .send() .await? .json::() .await { start += inc; playlists.append(&mut local_pl.data); if start >= local_pl.total { break; } } Ok(playlists) } /// Add a public playlist pub async fn create_video_playlist( &self, c_id: u8, display_name: &str, ) -> Result> { let form = Form::new() .text("displayName", display_name.to_string()) .text("privacy", "1") .text("videoChannelId", format!("{}", c_id)); let pl_created = self .client .post(format!("{}/video-playlists", self.base_url)) .multipart(form) .send() .await? .json::() .await?; Ok(pl_created.video_playlist.uuid) } /// Add a video into a playlist pub async fn add_video_to_playlist( &self, vid_uuid: &str, pl_uuid: &str, ) -> Result<(), Box> { let video_to_add = PeerTubeVideoPlaylistsVideos { video_id: vid_uuid.to_string(), }; let res = self .client .post(format!( "{}/video-playlists/{}/videos", self.base_url, pl_uuid )) .json(&video_to_add) .send() .await?; if !res.status().is_success() { return Err(TootubeError::new(&format!( "Cannot add video {} to playlist {}: {}", vid_uuid, pl_uuid, res.text().await? )) .into()); }; Ok(()) } /// List all videos of a playlist pub async fn list_videos_playlist(&self, uuid: &str) -> Result, Box> { let mut videos: Vec = vec![]; let mut start = 0; let inc = 15; while let Ok(l_vid) = self .client .get(format!( "{}/video-playlists/{}/videos?start={}&count={}", &self.base_url, &uuid, start, inc )) .send() .await? .json::() .await { start += inc; videos.append(&mut l_vid.data.into_iter().map(|x| x.video).collect()); if start >= l_vid.total { break; } } Ok(videos.into_iter().map(|x| x.uuid).collect()) } } /// Given a PeerTube instance, video UUID, list of named playlists and channel ID, this function: /// * adds playlists if they do not exists /// * adds the video to said playlists if they’re not in it already pub async fn get_playlists_to_be_added_to( peertube: &PeerTube, vid_uuid: &str, pl: &[String], c_id: u8, ) -> Result, Box> { let mut playlist_to_be_added_to: Vec = vec![]; if let Ok(local_pl) = peertube.list_video_playlists().await { // list the displayNames of each playlist let current_playlist: Vec = local_pl.iter().map(|s| s.display_name.clone()).collect(); // get the playlist whose displayName does not exist yet let pl_to_create: Vec<_> = pl .iter() .filter(|x| !current_playlist.contains(x)) .collect(); debug!("Playlists to be added: {:?}", &pl_to_create); playlist_to_be_added_to = local_pl .into_iter() .filter(|x| pl.contains(&x.display_name)) .map(|x| x.uuid.clone()) .collect(); // create the missing playlists for p in pl_to_create { if let Ok(s) = peertube.create_video_playlist(c_id, p).await { playlist_to_be_added_to.push(s); } } for p in playlist_to_be_added_to.clone().iter() { if let Ok(s) = peertube.list_videos_playlist(p).await { // if a video already exists inside a playlist, drop it if s.contains(&vid_uuid.to_string()) { playlist_to_be_added_to.retain(|i| *i != *p) } } } debug!("Playlists to be added to: {:?}", &playlist_to_be_added_to); }; Ok(playlist_to_be_added_to) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_latest_video() { let peertube = PeerTube::new("https://peertube.cpy.re"); let vid = peertube.get_latest_video().await.unwrap(); assert_eq!(vid.uuid.len(), 36); } #[tokio::test] #[should_panic] async fn test_get_original_video_source() { let peertube = PeerTube::new("https://peertube.cpy.re"); let _vid = peertube .get_original_video_source("t") .await .expect("Should panic!"); } #[tokio::test] async fn test_list_video_playlists() { let peertube = PeerTube::new("https://peertube.cpy.re"); let pl = peertube.list_video_playlists().await.unwrap(); assert!(!pl.is_empty()); } #[tokio::test] async fn test_list_videos_playlist() { let peertube = PeerTube::new("https://peertube.cpy.re"); let pl_videos = peertube .list_videos_playlist("73a5c1fa-64c5-462d-81e5-b120781c2d72") .await .unwrap(); assert!(!pl_videos.is_empty()); } }