From ebc42f915d7f3b96bdb463c520cf741cf8acdfbe Mon Sep 17 00:00:00 2001 From: VC Date: Sat, 14 Oct 2023 22:45:08 +0200 Subject: [PATCH] feat: add the newly added video to playlists if any --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/lib.rs | 12 +++- src/main.rs | 17 +++++- src/youtube.rs | 154 ++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 178 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60a0e85..dd14064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "tootube" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bytes", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8d30145..af32a81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tootube" authors = ["VC "] -version = "0.3.0" +version = "0.4.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/lib.rs b/src/lib.rs index 44412d1..567d065 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ use peertube::{get_latest_video, get_max_resolution_dl}; mod youtube; pub use youtube::register; -use youtube::{create_resumable_upload, now_kiss}; +use youtube::{add_video_to_playlists, create_resumable_upload, now_kiss}; async fn get_dl_video_stream( u: &str, @@ -22,7 +22,7 @@ async fn get_dl_video_stream( } #[tokio::main] -pub async fn run(config: Config) { +pub async fn run(config: Config, pl: Vec) { // Get the latest video object let latest_vid = get_latest_video(&config.peertube.base_url) .await @@ -40,7 +40,13 @@ pub async fn run(config: Config) { .await .unwrap_or_else(|e| panic!("Cannot retrieve the upload’s resumable id: {e}")); - now_kiss(pt_stream, &resumable_upload_url, &config.youtube) + let yt_video_id = now_kiss(pt_stream, &resumable_upload_url, &config.youtube) .await .unwrap_or_else(|e| panic!("Cannot resume upload!: {e}")); + + if !pl.is_empty() { + add_video_to_playlists(&config.youtube, &yt_video_id, &pl) + .await + .unwrap_or_else(|e| panic!("Cannot add video to playlist(s): {e}")); + } } diff --git a/src/main.rs b/src/main.rs index 1efeb9a..f3d9c71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,15 @@ fn main() { .default_value(DEFAULT_CONFIG_PATH) .display_order(1), ) + .arg( + Arg::new("playlists") + .short('p') + .long("playlist") + .value_name("PLAYLIST") + .help("List of playlists to add the video to") + .num_args(0..) + .display_order(2), + ) .subcommand( Command::new("register") .version(env!("CARGO_PKG_VERSION")) @@ -43,7 +52,13 @@ fn main() { let config = parse_toml(matches.get_one::("config").unwrap()); + let playlists: Vec = matches + .get_many::("playlists") + .unwrap_or_default() + .map(|v| v.to_string()) + .collect(); + env_logger::init(); - run(config); + run(config, playlists); } diff --git a/src/youtube.rs b/src/youtube.rs index 9d971df..5e5c873 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -87,6 +87,71 @@ impl Default for YoutubeUploadParamsStatus { } } +#[derive(Serialize, Debug)] +struct YoutubePlaylistItemsParams { + snippet: YoutubePlaylistItemsParamsSnippet, +} + +#[derive(Serialize, Debug)] +struct YoutubePlaylistItemsParamsSnippet { + #[serde(rename = "playlistId")] + playlist_id: String, + position: u16, + #[serde(rename = "resourceId")] + resource_id: YoutubePlaylistItemsParamsSnippetResourceId, +} + +impl Default for YoutubePlaylistItemsParamsSnippet { + fn default() -> Self { + YoutubePlaylistItemsParamsSnippet { + playlist_id: "".to_string(), + position: 0, + resource_id: YoutubePlaylistItemsParamsSnippetResourceId { + ..Default::default() + }, + } + } +} + +#[derive(Serialize, Debug)] +struct YoutubePlaylistItemsParamsSnippetResourceId { + kind: String, + #[serde(rename = "videoId")] + video_id: String, +} + +impl Default for YoutubePlaylistItemsParamsSnippetResourceId { + fn default() -> Self { + YoutubePlaylistItemsParamsSnippetResourceId { + kind: "youtube#video".to_string(), + video_id: "".to_string(), + } + } +} + +#[derive(Deserialize, Debug)] +struct YoutubeVideos { + id: String, +} + +#[derive(Deserialize, Debug)] +struct YoutubePlaylistListResponse { + #[serde(rename = "nextPageToken")] + next_page_token: Option, + items: Vec, +} + +#[derive(Deserialize, Debug)] +struct YoutubePlaylistListResponseItem { + id: String, + snippet: YoutubePlaylistListResponseItemSnippet, +} + +#[derive(Deserialize, Debug)] +struct YoutubePlaylistListResponseItemSnippet { + title: String, +} + /// This function makes the registration process a little bit easier #[tokio::main] pub async fn register(config: &YoutubeConfig) -> Result<(), Box> { @@ -148,6 +213,88 @@ async fn refresh_token(config: &YoutubeConfig) -> Result .cloned() } +/// This function takes a list of playlists keyword and returns a list of playlist ID +async fn get_playlist_ids( + config: &YoutubeConfig, + pl: &[String], +) -> Result, Box> { + let mut page_token = String::new(); + let mut playlists: Vec = vec![]; + + let access_token = refresh_token(config).await?; + let client = Client::new(); + + while let Ok(local_pl) = client + .get(&format!( + "https://www.googleapis.com/youtube/v3/playlists?part=snippet&mine=true&pageToken={}", + page_token + )) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await? + .json::() + .await + { + playlists.append( + &mut local_pl + .items + .iter() + .filter_map(|s| pl.contains(&s.snippet.title).then_some(s.id.clone())) + .collect(), + ); + + // if nextPageToken is present, continue the loop + match local_pl.next_page_token { + None => break, + Some(a) => page_token = a.clone(), + } + } + + Ok(playlists) +} + +/// This function adds the video id to the corresponding named playlist(s) +pub async fn add_video_to_playlists( + config: &YoutubeConfig, + v: &str, + pl: &[String], +) -> Result<(), Box> { + let access_token = refresh_token(config).await?; + let playlists_ids = get_playlist_ids(config, pl).await?; + + for pl_id in playlists_ids { + let yt_pl_upload_params = YoutubePlaylistItemsParams { + snippet: YoutubePlaylistItemsParamsSnippet { + playlist_id: pl_id.clone(), + resource_id: YoutubePlaylistItemsParamsSnippetResourceId { + video_id: v.to_string(), + ..Default::default() + }, + ..Default::default() + }, + }; + let client = Client::new(); + + let res = client + .post("https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet") + .header("Authorization", format!("Bearer {}", access_token)) + .json(&yt_pl_upload_params) + .send() + .await?; + + if !res.status().is_success() { + return Err(TootubeError::new(&format!( + "Something went wrong when trying to add the video to a playlist: {}", + res.text().await? + )) + .into()); + } + } + + Ok(()) +} + +/// This function creates a resumable YT upload, putting all the parameters in pub async fn create_resumable_upload( config: &YoutubeConfig, vid: &PeerTubeVideo, @@ -171,7 +318,6 @@ pub async fn create_resumable_upload( }; let client = 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) @@ -189,6 +335,7 @@ pub async fn create_resumable_upload( } } +/// This takes the PT stream for download, connects it to YT stream for upload pub async fn now_kiss<'a>( stream: impl Stream> + std::marker::Send @@ -196,7 +343,7 @@ pub async fn now_kiss<'a>( + 'a + 'static, r_url: &'a str, config: &'a YoutubeConfig, -) -> Result<(), Box> { +) -> Result> { // Get access token let access_token = refresh_token(config).await?; @@ -211,7 +358,8 @@ pub async fn now_kiss<'a>( .await?; if res.status().is_success() { - Ok(()) + let yt_videos: YoutubeVideos = res.json().await?; + Ok(yt_videos.id) } else { Err(TootubeError::new(&format!("Cannot upload video: {:?}", res.text().await?)).into()) }