Skip to content

Commit

Permalink
feat: adding ability to synchronize likes or to like all songs in pla…
Browse files Browse the repository at this point in the history
…ylists
  • Loading branch information
SilentVoid13 committed Oct 17, 2024
1 parent 75d83dd commit 20e7c01
Show file tree
Hide file tree
Showing 14 changed files with 212 additions and 77 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sync_dis_boi"
version = "0.3.0"
version = "0.4.0"
edition = "2021"

[dependencies]
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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_ID>" --client-secret "<CLIENT_SECRET>"
# sync from Spotify to Tidal
./sync_dis_boi spotify --client-id "<CLIENT_ID>" --client-secret "<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_ID>" --client-secret "<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_ID>" --client-secret "<CLIENT_SECRET>" yt-music

Expand Down
14 changes: 3 additions & 11 deletions src/args.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};
use sync_dis_boi::ConfigArgs;
use tracing::Level;

#[derive(Parser, Debug)]
Expand All @@ -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://<ip>:<port>
#[arg(long)]
pub proxy: Option<String>,
#[command(flatten)]
pub config: ConfigArgs,

/// Logging level
#[arg(short, long, value_enum, default_value_t = LoggingLevel::Info)]
Expand Down
9 changes: 3 additions & 6 deletions src/build_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
)
Expand All @@ -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?,
)
Expand All @@ -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?,
),
Expand Down
30 changes: 30 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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://<ip>:<port>
#[arg(long)]
pub proxy: Option<String>,
}
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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?;
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/music_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Song>>;
}

#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
Expand Down
29 changes: 17 additions & 12 deletions src/spotify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -44,19 +45,15 @@ 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",
"playlist-read-private",
"playlist-modify-private",
];

pub async fn new(
client_id: &str,
client_secret: &str,
debug: bool,
proxy: Option<&str>,
) -> Result<Self> {
pub async fn new(client_id: &str, client_secret: &str, config: ConfigArgs) -> Result<Self> {
let auth_url = SpotifyApi::build_authorization_url(client_id)?;
let auth_code = SpotifyApi::listen_for_code(&auth_url).await?;

Expand Down Expand Up @@ -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)
Expand All @@ -100,7 +97,7 @@ impl SpotifyApi {

Ok(Self {
client,
debug,
config,
country_code,
})
}
Expand Down Expand Up @@ -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)?
Expand All @@ -238,7 +235,7 @@ impl SpotifyApi {

pub fn push_query(queries: &mut Vec<String>, 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);
Expand Down Expand Up @@ -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();
Expand All @@ -422,6 +419,14 @@ impl MusicApi for SpotifyApi {
}
Ok(())
}

async fn get_likes(&self) -> Result<Vec<Song>> {
let res: SpotifyPageResponse<SpotifySongItemResponse> = self
.paginated_request("/me/tracks", HttpMethod::Get(&[]), 50)
.await?;
let songs: Songs = res.try_into()?;
Ok(songs.0)
}
}

#[cfg(test)]
Expand Down
66 changes: 58 additions & 8 deletions src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand All @@ -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!({});
Expand Down Expand Up @@ -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()
Expand All @@ -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);
Expand All @@ -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::<Vec<Song>>();
dst_api.add_like(&new_likes).await?;
}
}

let mut conversion_rate = 1.0;
Expand All @@ -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)?,
Expand Down Expand Up @@ -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(())
Expand Down
Loading

0 comments on commit 20e7c01

Please sign in to comment.