Skip to content

Commit

Permalink
feat: using country code for accurate ISRC matching
Browse files Browse the repository at this point in the history
  • Loading branch information
SilentVoid13 committed Oct 13, 2024
1 parent e19749a commit 1bb2a35
Show file tree
Hide file tree
Showing 12 changed files with 120 additions and 56 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.1.0"
version = "0.2.0"
edition = "2021"

[dependencies]
Expand Down
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,33 @@ It's the perfect solution for music enthusiasts who want to keep their playlists
## Accuracy

SyncDisBoi focuses on synchronization accuracy, ensuring that each song on the source playlist accurately matches the corresponding song on the destination playlist. This feature is particularly useful for users who prioritize maintaining the integrity of their playlists and avoid ending up with unexpected remixes during synchronization.
SyncDisBoi focuses on synchronization accuracy, ensuring that each track on the source playlist accurately matches the corresponding track on the destination playlist. This feature is particularly useful for users who prioritize maintaining the integrity of their playlists and avoid ending up with unexpected remixes during synchronization.

SyncDisBoi verifies the following properties to ensure that the two songs match:
If available, SyncDisBoi uses the [International Standard Recording Code (ISRC)](https://en.wikipedia.org/wiki/International_Standard_Recording_Code) to guarantee correct track matching.

When ISRC codes are not available on the platform API, SyncDisBoi falls back to verifying the following properties to ensure that the two tracks match:
- Song name resemblance score ([Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance))
- Album name resemblance score ([Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance))
- Song duration

Notes:
- The duration is not used because some songs have incorrect durations with the Youtube Music API.
- The artist names are not used either because the metadata is inconsistent across platforms.
- For Youtube Music, SyncDisBoi won't sync songs lacking album metadata, as this typically indicates a video from Youtube, which lacks the necessary metadata for accurate synchronization.
- For Youtube Music, SyncDisBoi won't sync tracks lacking album metadata, as this typically indicates a video from Youtube, which lacks the necessary metadata for accurate synchronization.

## Usage

```bash
# convert your playlists from Youtube Music to Spotify
# sync from Youtube Music to Spotify
./sync_dis_boi yt-music spotify --client-id "<CLIENT_ID>" --client-secret "<CLIENT_SECRET>"
# convert your playlists from Tidal to Youtube Music
# sync from Tidal to Youtube Music
./sync_dis_boi tidal yt-music
# convert your playlists from Youtube Music to Tidal, with debug mode enabled to generate statistics JSON files
# sync from Youtube Music to Tidal, with debug mode enabled to generate statistics JSON files
./sync_dis_boi --debug tidal yt-music
```

To use SyncDisBoi, you need to set up account access for the API of the corresponding music platform.

### Spotify API
### Spotify API setup

- Visit [https://developer.spotify.com/](https://developer.spotify.com/)
and create an application.
Expand All @@ -48,7 +50,7 @@ You will then need to provide the client id and client secret as arguments for S
Notes:
- After authorizing access for your Spotify account, SyncDisBoi will open the 'http://localhost:8888/callback' URL in your browser. If you get 'Unable to connect' this is normal.

### Youtube Music API
### Youtube Music API setup

- On the first run, SyncDisBoi will open up a browser tab to request OAuth access for your Youtube Account.
- Authorize the application in your browser, then press ENTER in the CLI to continue.
Expand All @@ -59,7 +61,7 @@ Notes:
- By default, SyncDisBoi uses the "Youtube for TV" application credentials to request OAuth access.
- However, you can also create your own OAuth application, grant access to your account email, and then use it in SyncDisBoi by providing its client id and client secret.

### Tidal API
### Tidal API setup

- On the first run, SyncDisBoi will open up a browser tab to request OAuth access for your Tidal Account.
- Authorize the application in your browser, then press ENTER in the CLI to continue.
Expand All @@ -72,11 +74,13 @@ Notes:

### Debug mode

You can enable the debug mode with `--debug` to generate a certain number of JSON files in a `debug/` folder:
- `conversion_rate.json`: ratio of successfully sychronized songs
- `missing_songs.json`: list of all non-synchronized songs
- `new_songs.json`: list of all synchronized songs
- `songs_with_no_albums.json`: list of songs skipped because they were missing album metadata
You can enable debug mode (`--debug`) to generate detailed statistics about the synchronization process.

Files are saved in the `debug/` folder:
- `conversion_rate.json`: success rate of song synchronization
- `missing_songs.json`: list of tracks that couldn’t be synchronized
- `new_songs.json`: list of tracks successfully synchronized
- `songs_with_no_albums.json`: list of songs skipped due to missing album metadata

## Contributing

Expand Down
5 changes: 4 additions & 1 deletion src/music_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub type DynMusicApi = Box<dyn MusicApi + Sync>;

#[async_trait]
pub trait MusicApi {
fn api_type(&self) -> MusicApiType;
fn country_code(&self) -> &str;

async fn create_playlist(&self, name: &str, public: bool) -> Result<Playlist>;
async fn get_playlists_info(&self) -> Result<Vec<Playlist>>;
async fn get_playlist_songs(&self, id: &str) -> Result<Vec<Song>>;
Expand Down Expand Up @@ -51,7 +54,7 @@ pub trait MusicApi {
}
}

#[derive(Deserialize, Serialize, Clone, Debug)]
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
pub enum MusicApiType {
Spotify,
YtMusic,
Expand Down
20 changes: 18 additions & 2 deletions src/spotify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::time::Duration;
use async_recursion::async_recursion;
use async_trait::async_trait;
use color_eyre::eyre::{eyre, Result};
use model::SpotifyUserResponse;
use reqwest::header::HeaderMap;
use reqwest::{Response, StatusCode};
use serde::de::DeserializeOwned;
Expand All @@ -17,12 +18,13 @@ use tracing::{debug, info, warn};
use self::model::{
SpotifyPageResponse, SpotifyPlaylistResponse, SpotifySnapshotResponse, SpotifySongItemResponse,
};
use crate::music_api::{MusicApi, OAuthToken, Playlist, Playlists, Song, Songs, PLAYLIST_DESC};
use crate::music_api::{MusicApi, MusicApiType, OAuthToken, Playlist, Playlists, Song, Songs, PLAYLIST_DESC};
use crate::spotify::model::SpotifySearchResponse;

pub struct SpotifyApi {
client: reqwest::Client,
debug: bool,
country_code: String,
}

#[derive(Debug)]
Expand Down Expand Up @@ -86,7 +88,13 @@ impl SpotifyApi {

let client = client.build()?;

Ok(Self { client, debug })
let url = format!("{}/me", Self::BASE_API);
let res = client.get(&url).send().await?;
let res = res.error_for_status()?;
let me_res: SpotifyUserResponse = res.json().await?;
let country_code = me_res.country;

Ok(Self { client, debug, country_code })
}

fn build_authorization_url(client_id: &str) -> Result<String> {
Expand Down Expand Up @@ -234,6 +242,14 @@ pub fn push_query(queries: &mut Vec<String>, query: String, max_len: usize) {

#[async_trait]
impl MusicApi for SpotifyApi {
fn api_type(&self) -> MusicApiType {
MusicApiType::Spotify
}

fn country_code(&self) -> &str {
&self.country_code
}

async fn create_playlist(&self, name: &str, public: bool) -> Result<Playlist> {
let path = "/me/playlists";
let body = json!({
Expand Down
8 changes: 8 additions & 0 deletions src/spotify/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct SpotifyEmptyResponse {}

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct SpotifyUserResponse {
pub country: String,
pub display_name: String,
pub email: String,
}

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct SpotifySnapshotResponse {
Expand Down
7 changes: 6 additions & 1 deletion src/spotify/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,16 @@ impl TryInto<Song> for SpotifySongResponse {
id: Some(self.album.id),
name: self.album.name,
};

let isrc = self.external_ids.isrc.to_uppercase();
// the metadata is sometimes inconsistent
let isrc = isrc.replace("-", "");

Ok(Song {
source: MusicApiType::Spotify,
id: self.id,
sid: None,
isrc: Some(self.external_ids.isrc.to_uppercase()),
isrc: Some(isrc),
name: self.name,
album: Some(album),
artists,
Expand Down
10 changes: 7 additions & 3 deletions src/sync.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use color_eyre::eyre::Result;
use color_eyre::eyre::{eyre, Result};
use serde_json::json;
use tracing::{debug, info, warn};

use crate::music_api::{DynMusicApi, MusicApi};
use crate::music_api::{DynMusicApi, MusicApiType};

// TODO: Parse playlist owner to ignore platform-specific playlists?
const SKIPPED_PLAYLISTS: [&str; 10] = [
Expand All @@ -22,9 +22,13 @@ const SKIPPED_PLAYLISTS: [&str; 10] = [

pub async fn synchronize(
src_api: DynMusicApi,
dst_api: Box<dyn MusicApi + Sync>,
dst_api: DynMusicApi,
debug: bool,
) -> Result<()> {
if 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()));
}

info!("retrieving playlists...");
let src_playlists = src_api.get_playlists_full().await?;
let mut dst_playlists = dst_api.get_playlists_full().await?;
Expand Down
52 changes: 30 additions & 22 deletions src/tidal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,26 @@ use std::path::PathBuf;
use async_trait::async_trait;
use color_eyre::eyre::eyre;
use color_eyre::Result;
use model::{TidalMediaResponse, TidalOAuthDeviceRes};
use model::{TidalMediaResponse, TidalMediaResponseSingle, TidalOAuthDeviceRes};
use reqwest::header::HeaderMap;
use reqwest::Response;
use serde::de::DeserializeOwned;
use serde_json::json;
use tracing::info;

use self::model::{
TidalMeResponse, TidalPageResponse, TidalPlaylistResponse, TidalSongItemResponse,
TidalPageResponse, TidalPlaylistResponse, TidalSongItemResponse,
};
use crate::music_api::{
MusicApi, OAuthRefreshToken, OAuthReqToken, OAuthToken, Playlist, Playlists, Song, Songs,
PLAYLIST_DESC,
MusicApi, MusicApiType, OAuthRefreshToken, OAuthReqToken, OAuthToken, Playlist, Playlists, Song, Songs, PLAYLIST_DESC
};
use crate::tidal::model::{TidalPlaylistCreateResponse, TidalSearchResponse};

pub struct TidalApi {
client: reqwest::Client,
debug: bool,
user_id: usize,
user_id: String,
country_code: String,
}

#[derive(Debug)]
Expand All @@ -41,7 +41,6 @@ impl TidalApi {

const AUTH_URL: &'static str = "https://auth.tidal.com/v1/oauth2/device_authorization";
const TOKEN_URL: &'static str = "https://auth.tidal.com/v1/oauth2/token";
const ME_URL: &'static str = "https://login.tidal.com/oauth2/me";
const SCOPE: &'static str = "r_usr w_usr w_sub";

pub async fn new(
Expand Down Expand Up @@ -80,14 +79,17 @@ impl TidalApi {
}
let client = client.build()?;

let res = client.get(Self::ME_URL).send().await?;
let url = format!("{}/users/me", Self::API_V2_URL);
let res = client.get(&url).send().await?;
let res = res.error_for_status()?;
let me_res: TidalMeResponse = res.json().await?;
let me_res: TidalMediaResponseSingle = res.json().await?;
let country_code = me_res.data.attributes.country.unwrap();

Ok(Self {
client,
debug,
user_id: me_res.user_id,
user_id: me_res.data.id,
country_code,
})
}

Expand Down Expand Up @@ -177,14 +179,15 @@ impl TidalApi {
&self,
url: &str,
method: &HttpMethod<'_>,
limit: usize,
offset: usize,
lim_off: Option<(usize, usize)>,
) -> Result<Response> {
let mut request = match method {
HttpMethod::Get(p) => self.client.get(url).query(p),
HttpMethod::Put(b) => self.client.put(url).form(b),
};
request = request.query(&[("limit", limit), ("offset", offset)]);
if let Some((limit, offset)) = lim_off {
request = request.query(&[("limit", limit), ("offset", offset)]);
}
let res = request.send().await?;
let res = res.error_for_status()?;
Ok(res)
Expand All @@ -200,7 +203,7 @@ impl TidalApi {
where
T: DeserializeOwned,
{
let res = self.make_request(url, method, limit, offset).await?;
let res = self.make_request(url, method, Some((limit, offset))).await?;
let obj = if self.debug {
let text = res.text().await?;
std::fs::write("debug/tidal_last_res.json", &text).unwrap();
Expand All @@ -214,6 +217,14 @@ impl TidalApi {

#[async_trait]
impl MusicApi for TidalApi {
fn api_type(&self) -> MusicApiType {
MusicApiType::Tidal
}

fn country_code(&self) -> &str {
&self.country_code
}

async fn create_playlist(&self, name: &str, public: bool) -> Result<Playlist> {
let url = format!(
"{}/v2/my-collection/playlists/folders/create-playlist",
Expand All @@ -239,7 +250,7 @@ impl MusicApi for TidalApi {
async fn get_playlists_info(&self) -> Result<Vec<Playlist>> {
let url = format!("{}/v1/users/{}/playlists", Self::API_URL, self.user_id);
let params = json!({
"countryCode": "US",
"countryCode": self.country_code,
});
let res: TidalPageResponse<TidalPlaylistResponse> = self
.paginated_request(&url, &HttpMethod::Get(&params), 100)
Expand All @@ -251,7 +262,7 @@ impl MusicApi for TidalApi {
async fn get_playlist_songs(&self, id: &str) -> Result<Vec<Song>> {
let url = format!("{}/v1/playlists/{}/items", Self::API_URL, id);
let params = json!({
"countryCode": "US",
"countryCode": self.country_code,
});
let res: TidalPageResponse<TidalSongItemResponse> = self
.paginated_request(&url, &HttpMethod::Get(&params), 100)
Expand All @@ -269,7 +280,7 @@ impl MusicApi for TidalApi {

let url = format!("{}/v1/playlists/{}", Self::API_URL, playlist.id);
let params = json!({
"countryCode": "US",
"countryCode": self.country_code,
});
let res = self.client.get(url).query(&params).send().await?;
let res = res.error_for_status()?;
Expand Down Expand Up @@ -313,7 +324,7 @@ impl MusicApi for TidalApi {
"trns": format!("trn:playlist:{}", playlist.id),
});
let _res = self
.make_request(&url, &HttpMethod::Put(&params), 0, 5)
.make_request(&url, &HttpMethod::Put(&params), None)
.await?;
Ok(())
}
Expand All @@ -322,7 +333,7 @@ impl MusicApi for TidalApi {
if let Some(isrc) = &song.isrc {
let url = format!("{}/tracks", Self::API_V2_URL);
let params = json!({
"countryCode": "US",
"countryCode": self.country_code,
"include": "albums,artists",
"filter[isrc]": isrc.to_uppercase(),
});
Expand All @@ -332,9 +343,6 @@ impl MusicApi for TidalApi {
if res.data.is_empty() {
return Ok(None);
}
dbg!(&res);
dbg!(&song);
todo!();
let res_song: Song = res.try_into()?;
return Ok(Some(res_song));
}
Expand All @@ -344,7 +352,7 @@ impl MusicApi for TidalApi {

while let Some(query) = queries.pop() {
let params = json!({
"countryCode": "US",
"countryCode": self.country_code,
"query": query,
"type": "TRACKS",
});
Expand Down
Loading

0 comments on commit 1bb2a35

Please sign in to comment.