diff --git a/Cargo.lock b/Cargo.lock index a6c09ea..13f9284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1324,7 +1324,7 @@ dependencies = [ [[package]] name = "sync_dis_boi" -version = "0.2.0" +version = "0.4.0" dependencies = [ "async-recursion", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index f4f320c..6a1516e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sync_dis_boi" -version = "0.3.0" +version = "0.4.0" edition = "2021" [dependencies] diff --git a/README.md b/README.md index 49bff53..85c7472 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ SyncDisBoi is a simple and efficient tool designed to synchronize playlists acro - [Tidal](https://tidal.com/) SyncDisBoi is the ideal tool for music enthusiasts who want to: -- Seamlessly migrate to a new music platform while preserving their playlists +- Seamlessly migrate to a new music platform while preserving their playlists and likes - Keep playlists in sync across multiple platforms and enjoy each platform's unique recommendation algorithms - Export existing playlists in a portable JSON format for easy backup or sharing @@ -40,10 +40,10 @@ Here are some command examples: ```bash # sync from Youtube Music to Spotify ./sync_dis_boi yt-music spotify --client-id "" --client-secret "" -# sync from Spotify to Tidal -./sync_dis_boi spotify --client-id "" --client-secret "" tidal -# sync from Tidal to Youtube Music -./sync_dis_boi tidal yt-music +# sync from Spotify to Tidal, sync likes as well +./sync_dis_boi --sync-likes spotify --client-id "" --client-secret "" tidal +# sync from Tidal to Youtube Music, like all synchronized songs +./sync_dis_boi --like-all tidal yt-music # sync from Spotify to Youtube Music, with debug mode enabled to generate detailed statistics about the synchronization process ./sync_dis_boi --debug spotify --client-id "" --client-secret "" yt-music diff --git a/src/args.rs b/src/args.rs index c624b11..ec91ea0 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand, ValueEnum}; +use sync_dis_boi::ConfigArgs; use tracing::Level; #[derive(Parser, Debug)] @@ -10,17 +11,8 @@ pub struct RootArgs { #[command(subcommand)] pub src: MusicPlatformSrc, - /// Enable debug mode to display and generate debug information during - /// synchronization This is useful during development - #[arg(long, default_value = "false")] - pub debug: bool, - - /// Like all songs that will be sychronized on the destination platform - pub like_all: bool, - - /// Proxy to use for all requests in the format http://: - #[arg(long)] - pub proxy: Option, + #[command(flatten)] + pub config: ConfigArgs, /// Logging level #[arg(short, long, value_enum, default_value_t = LoggingLevel::Info)] diff --git a/src/build_api.rs b/src/build_api.rs index a4e3206..06ec84e 100644 --- a/src/build_api.rs +++ b/src/build_api.rs @@ -34,8 +34,7 @@ macro_rules! impl_build_api { client_secret, oauth_token_path, *clear_cache, - args.debug, - args.proxy.as_deref(), + args.config.clone(), ) .await?, ) @@ -53,8 +52,7 @@ macro_rules! impl_build_api { client_secret, oauth_token_path, *clear_cache, - args.debug, - args.proxy.as_deref(), + args.config.clone(), ) .await?, ) @@ -67,8 +65,7 @@ macro_rules! impl_build_api { SpotifyApi::new( &client_id, &client_secret, - args.debug, - args.proxy.as_deref(), + args.config.clone(), ) .await?, ), diff --git a/src/lib.rs b/src/lib.rs index c6dd2df..2c56ff0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,3 +5,33 @@ pub mod sync; pub mod tidal; pub mod utils; pub mod yt_music; + +use clap::Parser; + +// TODO: I don't really like depending on clap for the library, +// but it's the easiest way to share a configuration structure with the bin +#[derive(Parser, Debug, Clone)] +pub struct ConfigArgs { + /// Enable debug mode to display and generate debug information during + /// synchronization This is useful during development + #[arg(long, default_value = "false")] + pub debug: bool, + + /// Like all songs that will be synchronized on the destination platform + #[arg(long, default_value = "false")] + pub like_all: bool, + + /// Sync likes from the source platform to the destination platform. + #[arg(long, default_value = "false")] + pub sync_likes: bool, + + /// Allow the synchronization between platforms with different countries. + /// Be aware that this can lead to invalid sync results, as some songs will + /// have different ISRC codes. + #[arg(long, default_value = "false")] + pub diff_country: bool, + + /// Proxy to use for all requests in the format http://: + #[arg(long)] + pub proxy: Option, +} diff --git a/src/main.rs b/src/main.rs index cc84379..e352ca3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ async fn main() -> Result<()> { std::fs::create_dir_all(&config_dir)?; } - if args.debug { + if args.config.debug { let debug_dir = Path::new("debug"); if !debug_dir.exists() { debug!("creating debug directory"); @@ -52,7 +52,7 @@ async fn main() -> Result<()> { }, _ => { let dst_api = args.src.get_dst().parse(&args, &config_dir).await?; - synchronize(src_api, dst_api, args.debug).await?; + synchronize(src_api, dst_api, args.config).await?; } } diff --git a/src/music_api.rs b/src/music_api.rs index fe70acf..8c65cc7 100644 --- a/src/music_api.rs +++ b/src/music_api.rs @@ -54,7 +54,8 @@ pub trait MusicApi { Ok(results) } - async fn like_songs(&self, songs: &[Song]) -> Result<()>; + async fn add_like(&self, songs: &[Song]) -> Result<()>; + async fn get_likes(&self) -> Result>; } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index de54eb3..7a8690a 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -22,10 +22,11 @@ use crate::music_api::{ MusicApi, MusicApiType, OAuthToken, Playlist, Playlists, Song, Songs, PLAYLIST_DESC, }; use crate::spotify::model::SpotifySearchResponse; +use crate::ConfigArgs; pub struct SpotifyApi { client: reqwest::Client, - debug: bool, + config: ConfigArgs, country_code: String, } @@ -44,6 +45,7 @@ impl SpotifyApi { const SCOPES: &'static [&'static str] = &[ "user-read-email", "user-read-private", + "user-library-read", "user-library-modify", "playlist-read-collaborative", "playlist-modify-public", @@ -51,12 +53,7 @@ impl SpotifyApi { "playlist-modify-private", ]; - pub async fn new( - client_id: &str, - client_secret: &str, - debug: bool, - proxy: Option<&str>, - ) -> Result { + pub async fn new(client_id: &str, client_secret: &str, config: ConfigArgs) -> Result { let auth_url = SpotifyApi::build_authorization_url(client_id)?; let auth_code = SpotifyApi::listen_for_code(&auth_url).await?; @@ -84,7 +81,7 @@ impl SpotifyApi { .default_headers(headers) .cookie_store(true); - if let Some(proxy) = proxy { + if let Some(proxy) = &config.proxy { client = client .proxy(reqwest::Proxy::all(proxy)?) .danger_accept_invalid_certs(true) @@ -100,7 +97,7 @@ impl SpotifyApi { Ok(Self { client, - debug, + config, country_code, }) } @@ -225,7 +222,7 @@ impl SpotifyApi { T: DeserializeOwned, { let res = self.make_request(path, method, limit, offset).await?; - let obj = if self.debug { + let obj = if self.config.debug { let text = res.text().await?; std::fs::write("debug/spotify_last_res.json", &text)?; serde_json::from_str(&text)? @@ -238,7 +235,7 @@ impl SpotifyApi { pub fn push_query(queries: &mut Vec, query: String, max_len: usize) { if query.len() > max_len { - debug!("hit query limit: {}, skipping", query); + debug!("hit query size limit: {}, skipping", query); return; } queries.push(query); @@ -410,7 +407,7 @@ impl MusicApi for SpotifyApi { return Ok(None); } - async fn like_songs(&self, songs: &[Song]) -> Result<()> { + async fn add_like(&self, songs: &[Song]) -> Result<()> { // NOTE: A maximum of 50 items can be specified in one request for songs_chunk in songs.chunks(50) { let ids: Vec<&str> = songs_chunk.iter().map(|s| s.id.as_str()).collect(); @@ -422,6 +419,14 @@ impl MusicApi for SpotifyApi { } Ok(()) } + + async fn get_likes(&self) -> Result> { + let res: SpotifyPageResponse = self + .paginated_request("/me/tracks", HttpMethod::Get(&[]), 50) + .await?; + let songs: Songs = res.try_into()?; + Ok(songs.0) + } } #[cfg(test)] diff --git a/src/sync.rs b/src/sync.rs index fe9b83e..3e38983 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -2,7 +2,8 @@ use color_eyre::eyre::{eyre, Result}; use serde_json::json; use tracing::{debug, info, warn}; -use crate::music_api::{DynMusicApi, MusicApiType}; +use crate::music_api::{DynMusicApi, MusicApiType, Song}; +use crate::ConfigArgs; // TODO: Parse playlist owner to ignore platform-specific playlists? const SKIPPED_PLAYLISTS: [&str; 10] = [ @@ -20,17 +21,29 @@ const SKIPPED_PLAYLISTS: [&str; 10] = [ "High Energy Mix", ]; -pub async fn synchronize(src_api: DynMusicApi, dst_api: DynMusicApi, debug: bool) -> Result<()> { - if src_api.api_type() != MusicApiType::YtMusic +pub async fn synchronize( + src_api: DynMusicApi, + dst_api: DynMusicApi, + config: ConfigArgs, +) -> Result<()> { + if !config.diff_country + && src_api.api_type() != MusicApiType::YtMusic && dst_api.api_type() != MusicApiType::YtMusic && src_api.country_code() != dst_api.country_code() { - return Err(eyre!("source and destination music platforms are in different countries: {} vs {}, this can lead to unexpected results", src_api.country_code(), dst_api.country_code())); + return Err(eyre!( + "source and destination music platforms are in different countries ({} vs {}). \ + You can specify --diff-country to allow it, \ + but this might result in incorrect sync results.", + src_api.country_code(), + dst_api.country_code() + )); } info!("retrieving playlists..."); let src_playlists = src_api.get_playlists_full().await?; let mut dst_playlists = dst_api.get_playlists_full().await?; + let dst_likes = dst_api.get_likes().await?; let mut all_missing_songs = json!({}); let mut all_new_songs = json!({}); @@ -71,7 +84,7 @@ pub async fn synchronize(src_api: DynMusicApi, dst_api: DynMusicApi, debug: bool "No album metadata for source song \"{}\", skipping", src_song.name ); - if debug { + if config.debug { no_albums_songs .as_array_mut() .unwrap() @@ -85,12 +98,18 @@ pub async fn synchronize(src_api: DynMusicApi, dst_api: DynMusicApi, debug: bool let dst_song = dst_api.search_song(src_song).await?; let Some(dst_song) = dst_song else { debug!("no match found for song: {}", src_song.name); - if debug { + if config.debug { missing_songs.as_array_mut().unwrap().push(json!(src_song)); } continue; }; - if debug { + // HACK: takes into account discrepancy for YtMusic with no ISRC + if dst_playlist.songs.contains(&dst_song) { + debug!("discrepancy, song already in playlist: {}", dst_song.name); + continue; + } + + if config.debug { new_songs.as_array_mut().unwrap().push(json!(dst_song)); } dst_songs.push(dst_song); @@ -100,6 +119,14 @@ pub async fn synchronize(src_api: DynMusicApi, dst_api: DynMusicApi, debug: bool dst_api .add_songs_to_playlist(&mut dst_playlist, &dst_songs) .await?; + if config.like_all { + let new_likes = dst_songs + .iter() + .filter(|s| !dst_likes.contains(s)) + .cloned() + .collect::>(); + dst_api.add_like(&new_likes).await?; + } } let mut conversion_rate = 1.0; @@ -116,7 +143,7 @@ pub async fn synchronize(src_api: DynMusicApi, dst_api: DynMusicApi, debug: bool ); } - if debug { + if config.debug { stats.as_object_mut().unwrap().insert( src_playlist.name.clone(), serde_json::to_value(conversion_rate)?, @@ -161,6 +188,29 @@ pub async fn synchronize(src_api: DynMusicApi, dst_api: DynMusicApi, debug: bool } } + if config.sync_likes { + info!("synchronizing likes..."); + let src_likes = src_api.get_likes().await?; + let dst_likes = dst_api.get_likes().await?; + + let mut new_likes = Vec::new(); + for src_like in src_likes { + if dst_likes.contains(&src_like) { + continue; + } + let Some(song) = dst_api.search_song(&src_like).await? else { + continue; + }; + // HACK: takes into account discrepancy for YtMusic with no ISRC + if dst_likes.contains(&song) { + debug!("discrepancy, song already in playlist: {}", song.name); + continue; + } + new_likes.push(song); + } + dst_api.add_like(&new_likes).await?; + } + info!("Synchronization complete."); Ok(()) diff --git a/src/tidal/mod.rs b/src/tidal/mod.rs index 234de50..4cf2ba4 100644 --- a/src/tidal/mod.rs +++ b/src/tidal/mod.rs @@ -20,10 +20,11 @@ use crate::music_api::{ Song, Songs, PLAYLIST_DESC, }; use crate::tidal::model::{TidalPlaylistCreateResponse, TidalSearchResponse}; +use crate::ConfigArgs; pub struct TidalApi { client: reqwest::Client, - debug: bool, + config: ConfigArgs, user_id: String, country_code: String, } @@ -48,8 +49,7 @@ impl TidalApi { client_secret: &str, oauth_token_path: PathBuf, clear_cache: bool, - debug: bool, - proxy: Option<&str>, + config: ConfigArgs, ) -> Result { let token = if !oauth_token_path.exists() || clear_cache { info!("requesting new token"); @@ -72,7 +72,7 @@ impl TidalApi { let mut client = reqwest::Client::builder() .cookie_store(true) .default_headers(headers); - if let Some(proxy) = proxy { + if let Some(proxy) = &config.proxy { client = client .proxy(reqwest::Proxy::all(proxy)?) .danger_accept_invalid_certs(true) @@ -87,7 +87,7 @@ impl TidalApi { Ok(Self { client, - debug, + config, user_id: me_res.data.id, country_code, }) @@ -207,7 +207,7 @@ impl TidalApi { let res = self .make_request(url, method, Some((limit, offset))) .await?; - let obj = if self.debug { + let obj = if self.config.debug { let text = res.text().await?; std::fs::write("debug/tidal_last_res.json", &text)?; serde_json::from_str(&text)? @@ -374,7 +374,11 @@ impl MusicApi for TidalApi { Ok(None) } - async fn like_songs(&self, songs: &[Song]) -> Result<()> { + async fn add_like(&self, songs: &[Song]) -> Result<()> { + if songs.is_empty() { + return Ok(()); + } + let url = format!( "{}/v1/users/{}/favorites/tracks", Self::API_URL, @@ -394,4 +398,19 @@ impl MusicApi for TidalApi { .await?; Ok(()) } + + async fn get_likes(&self) -> Result> { + let url = format!( + "{}/v1/users/{}/favorites/tracks", + Self::API_URL, + self.user_id + ); + let params = json!({ + "countryCode": self.country_code, + }); + let res: TidalPageResponse = + self.paginated_request(&url, &HttpMethod::Get(¶ms), 100).await?; + let songs: Songs = res.try_into()?; + Ok(songs.0) + } } diff --git a/src/yt_music/mod.rs b/src/yt_music/mod.rs index 188bca8..5940c37 100644 --- a/src/yt_music/mod.rs +++ b/src/yt_music/mod.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use async_trait::async_trait; use color_eyre::eyre::{eyre, Result}; use lazy_static::lazy_static; -use model::YtMusicOAuthDeviceRes; +use model::{YtMusicAddLikeResponse, YtMusicOAuthDeviceRes}; use reqwest::header::HeaderMap; use serde::de::DeserializeOwned; use serde_json::json; @@ -22,6 +22,7 @@ use crate::music_api::{ }; use crate::yt_music::model::{YtMusicPlaylistCreateResponse, YtMusicPlaylistDeleteResponse}; use crate::yt_music::response::SearchSongs; +use crate::ConfigArgs; lazy_static! { static ref CONTEXT: serde_json::Value = json!({ @@ -36,7 +37,7 @@ lazy_static! { pub struct YtMusicApi { client: reqwest::Client, - debug: bool, + config: ConfigArgs, } impl YtMusicApi { @@ -55,8 +56,7 @@ impl YtMusicApi { client_secret: &str, oauth_token_path: PathBuf, clear_cache: bool, - debug: bool, - proxy: Option<&str>, + config: ConfigArgs, ) -> Result { let mut headers = HeaderMap::new(); headers.insert("User-Agent", Self::OAUTH_USER_AGENT.parse()?); @@ -67,6 +67,7 @@ impl YtMusicApi { let token = if !oauth_token_path.exists() || clear_cache { Self::request_token(&client, client_id, client_secret).await? } else { + info!("refreshing token"); Self::refresh_token(&client, client_id, client_secret, &oauth_token_path).await? }; // Write new token @@ -84,14 +85,14 @@ impl YtMusicApi { let mut client = reqwest::Client::builder() .cookie_store(true) .default_headers(headers); - if let Some(proxy) = proxy { + if let Some(proxy) = &config.proxy { client = client .proxy(reqwest::Proxy::all(proxy)?) .danger_accept_invalid_certs(true) } let client = client.build()?; - Ok(YtMusicApi { client, debug }) + Ok(YtMusicApi { client, config }) } async fn refresh_token( @@ -210,7 +211,7 @@ impl YtMusicApi { let endpoint = self.build_endpoint(path, ctoken); let res = self.client.post(endpoint).json(&body).send().await?; let res = res.error_for_status()?; - let obj = if self.debug { + let obj = if self.config.debug { let text = res.text().await?; std::fs::write("debug/yt_music_last_res.json", &text)?; serde_json::from_str(&text)? @@ -356,32 +357,58 @@ impl MusicApi for YtMusicApi { } async fn search_song(&self, song: &Song) -> Result> { - let mut queries = song.build_queries(); - let ignore_spelling = "AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D"; - let params = format!("EgWKAQI{}{}", "I", ignore_spelling); - while let Some(query) = queries.pop() { + if let Some(isrc) = &song.isrc { let body = json!({ - "query": query, - "params": params, + "query": format!("\"{}\"", isrc), }); let response = self .make_request::("search", &body, None) .await?; - let res_songs: SearchSongs = response.try_into()?; - // iterate over top 3 results - for res_song in res_songs.0.into_iter().take(3) { - if song.compare(&res_song) { - return Ok(Some(res_song)); + let mut res_songs: SearchSongs = response.try_into()?; + if !res_songs.0.is_empty() { + return Ok(Some(res_songs.0.remove(0))); + } + } else { + let params = format!("EgWKAQ{}{}", "II", ignore_spelling); + let mut queries = song.build_queries(); + while let Some(query) = queries.pop() { + let body = json!({ + "query": query, + "params": params, + }); + let response = self + .make_request::("search", &body, None) + .await?; + let res_songs: SearchSongs = response.try_into()?; + // iterate over top 3 results + for res_song in res_songs.0.into_iter().take(3) { + if song.compare(&res_song) { + return Ok(Some(res_song)); + } } } } Ok(None) } - async fn like_songs(&self, _songs: &[Song]) -> Result<()> { - todo!(); + async fn add_like(&self, songs: &[Song]) -> Result<()> { + // TODO: find a way to bulk-like + for song in songs { + let body = json!({ + "target": { + "videoId": song.id, + } + }); + let _: YtMusicAddLikeResponse = self.make_request("like/like", &body, None).await?; + } + Ok(()) + } + + async fn get_likes(&self) -> Result> { + let songs = self.get_playlist_songs("LM").await?; + Ok(songs) } } diff --git a/src/yt_music/model.rs b/src/yt_music/model.rs index 4033976..5460105 100644 --- a/src/yt_music/model.rs +++ b/src/yt_music/model.rs @@ -79,6 +79,7 @@ impl YtMusicResponse { .content .section_list_renderer .contents + .as_mut()? .iter_mut() .find(|item| { item.music_playlist_shelf_renderer.is_some() || item.grid_renderer.is_some() @@ -89,6 +90,7 @@ impl YtMusicResponse { .content .section_list_renderer .contents + .as_mut()? .iter_mut() .find(|item| { item.music_playlist_shelf_renderer.is_some() @@ -99,6 +101,7 @@ impl YtMusicResponse { tr.secondary_contents .section_list_renderer .contents + .as_mut()? .iter_mut() .find(|item| { item.music_playlist_shelf_renderer.is_some() || item.grid_renderer.is_some() @@ -131,7 +134,7 @@ impl YtMusicResponse { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ContentsVec { - pub contents: Vec, + pub contents: Option>, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -485,7 +488,7 @@ pub enum CommandsContent { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct TabbedSearchResultsRenderer { - pub tabs: [Tab; 1], + pub tabs: Vec, } #[derive(Deserialize, Debug)] @@ -520,3 +523,14 @@ pub struct DeleteCommand { pub struct HandlePlaylistDeletionCommand { pub playlist_id: String, } + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct YtMusicAddLikeResponse { + pub response_context: YtMusicResponseContext, +} +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct YtMusicResponseContext { + pub visitor_data: String, +} diff --git a/src/yt_music/response.rs b/src/yt_music/response.rs index 29b8ec6..99ecddb 100644 --- a/src/yt_music/response.rs +++ b/src/yt_music/response.rs @@ -192,7 +192,7 @@ impl TryInto for YtMusicResponse { name, artists, album, - duration_ms: 0, + duration_ms: duration, }; songs_vec.push(song);