First commit

This commit is contained in:
Super_JK
2022-08-10 18:20:54 +02:00
commit fae5d0db8e
12 changed files with 2313 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
RSPOTIFY_CLIENT_ID=s2nzax6roarnwrnbgc7h732ewh1u2up9
RSPOTIFY_CLIENT_SECRET=85bzrk7wu8a6mpv542l2sv4x3smk490j
RSPOTIFY_REDIRECT_URI=http://localhost:8888/callback
PASSWORD="SuperStrongPassword42"
USERNAME="bob"
SITE="https://music.yourdomain.com"
RUST_LOG="info"

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
.env
.idea
.spotify_token_cache.json

1763
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

35
Cargo.toml Normal file
View File

@@ -0,0 +1,35 @@
[package]
name = "spt-sync"
version = "0.1.0"
edition = "2021"
description = "A simple terminal interface for listening to radio record web stations"
homepage = "https://git.jika.li/Jika/spt-sync"
#documentation = "https://git.jika.li/Jika/spt-sync"
repository = "https://git.jika.li/Jika/spt-sync"
readme = "readme.md"
license-file = "LICENSE"
keywords = ["spotify", "subsonic", "cli", "terminal", "sync"]
categories = ["command-line-utilities"]
authors = ["Jika"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0"
thiserror = "1.0"
log = "0.4"
env_logger = "0.9"
rand="0.8"
md5="0.7"
reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"
regex = "1"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
rspotify = { version="0.11", features=["cli","async-stream","futures"]}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Super_J-K
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

4
playlist.txt Normal file
View File

@@ -0,0 +1,4 @@
https://open.spotify.com/playlist/1yhfU0llvFG8VlMD3CvAtb?si=febfaeaaa8804c48
https://open.spotify.com/playlist/4noj74Daxdb03B6YxFyAeH?si=b3c9bd771c794e5a
https://open.spotify.com/playlist/2Ule0vZ816HKbLvtfMLwxs?si=20bc747bd2b643c8
https://open.spotify.com/playlist/7LmNrfHlNGccOHMephfcTJ?si=4388c164436d4995

20
readme.md Normal file
View File

@@ -0,0 +1,20 @@
# Spt-Sync
A tool to sync your Spotify playlist to a compatible Subsonic server.
## Build and run
You first have to install [Rust](https://www.rust-lang.org/tools/install) ( usage of `rustup` is recommended )
First copy and rename the .env file and fill it with the correct credentials for Spotify ([Spotify Dashboard](https://developer.spotify.com/dashboard/)) and your subsonic server
Then copy and paste the Spotify playlist URL in a file named `playlist.txt`
Run without optimizations :
```bash
cargo run
```
You can also install it with :
```bash
cargo install --path .
```
On the first launch you'll have to connect to your spotify account via a web browser

3
src/lib.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod spotify;
pub mod subsonic;
pub mod util;

30
src/main.rs Normal file
View File

@@ -0,0 +1,30 @@
use anyhow::{Context, Result};
use dotenv::dotenv;
use spt_sync::subsonic::Client;
use spt_sync::util::{get_filtered_spotify_playlists, sync_playlists};
use std::env;
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
env_logger::init();
// Retrieving environment variables
let site = env::var("SITE").context("Failed to find 'SITE' environment variable")?;
let username =
env::var("USERNAME").context("Failed to find 'USERNAME' environment variable")?;
let password =
env::var("PASSWORD").context("Failed to find 'PASSWORD' environment variable")?;
// Creating subsonic client and try to sync with playlist fetched from spotify
let client =
Client::new(&site, &username, &password).context("Could not create Subsonic client")?;
let spt_playlists = get_filtered_spotify_playlists()
.await
.context("Could not retrieve Spotify playlists")?;
sync_playlists(&client, spt_playlists)
.await
.context("Could not sync playlists")?;
Ok(())
}

88
src/spotify.rs Normal file
View File

@@ -0,0 +1,88 @@
use anyhow::{Context, Result};
use log::info;
use rspotify::model::PlayableItem::Track;
use rspotify::model::PlaylistId;
use rspotify::{prelude::*, scopes, AuthCodeSpotify, Config, Credentials, OAuth};
use std::fmt::{Display, Formatter};
use tokio_stream::StreamExt;
/// Simplified representation of spotify playlist
pub struct SimplePlaylist {
pub name: String,
pub tracks: Vec<SimpleTrack>,
}
/// Simplified representation of spotify track
#[derive(Debug)]
pub struct SimpleTrack {
pub artist: String,
pub album: String,
pub name: String,
}
impl Display for SimpleTrack {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} - {}", self.name, self.artist)
}
}
/// Get the spotify playlists with all their songs in simplified version
pub async fn get_spotify_playlists(playlists_id: &[PlaylistId]) -> Result<Vec<SimplePlaylist>> {
let config = Config {
token_cached: true,
token_refreshing: true,
..Default::default()
};
let creds = Credentials::from_env().unwrap();
let oauth = OAuth::from_env(scopes!(
"playlist-read-private",
"playlist-read-collaborative",
"playlist-read-public"
))
.unwrap();
let mut spotify = AuthCodeSpotify::with_config(creds.clone(), oauth, config.clone());
let url = spotify.get_authorize_url(false).unwrap();
loop {
if spotify.prompt_for_token(&url).await.is_ok() {
info!("Connected successfully");
break;
}
println!("Wrong url");
}
let user = spotify.me().await?;
info!("Current user : {}, {}", user.display_name.unwrap(), user.id);
let mut selected_playlists = Vec::new();
for id in playlists_id {
let playlist_name = spotify.playlist(id, None, None).await.context(format!("Could not find playlist : {}",id))?.name;
let mut tracks = Vec::new();
let mut playlist = spotify.playlist_items(id, None, None);
while let Some(item) = playlist.next().await {
let playable_item = item?.track.unwrap();
if let Track(track) = playable_item {
let artist = track.artists.first().unwrap().name.clone();
let album = track.album.name.clone();
tracks.push(SimpleTrack {
artist,
album,
name: track.name,
});
}
}
let playlist = SimplePlaylist {
name: playlist_name,
tracks,
};
selected_playlists.push(playlist);
}
Ok(selected_playlists)
}

249
src/subsonic.rs Normal file
View File

@@ -0,0 +1,249 @@
use crate::spotify::SimpleTrack;
use anyhow::{bail, Result};
use log::debug;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use reqwest::Url;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::fmt::Write as _;
use thiserror::Error;
const SALT_SIZE: usize = 36;
#[derive(Error, Debug)]
pub enum Error {
#[error("The specified url is incorrect : {0}")]
WrongUrl(String),
#[error("Request as incorrect information(s) : {0}")]
WrongRequest(String),
#[error("No match found for song : {0}")]
NoMatch(String),
}
/// Simplified Subsonic playlist
#[derive(Debug, Deserialize)]
pub struct SubPlaylist {
pub id: String,
pub name: String,
}
/// Simplified Subsonic song
#[derive(Debug, Deserialize)]
pub struct SubTrack {
pub id: String,
pub title: String,
pub album: String,
artist: String,
}
impl SubTrack {
pub fn get_artists(&self) -> Vec<&str> {
self.artist.rsplit_terminator('/').collect()
}
}
#[derive(Debug)]
pub struct Client {
base_url: Url,
auth_str: String,
}
impl Client {
/// Constructs a client to interact with a Subsonic instance.
pub fn new(url: &str, user: &str, password: &str) -> Result<Client> {
let base_url = url.parse::<Url>()?;
let salt: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(SALT_SIZE)
.map(char::from)
.collect();
let pre_t = password.to_string() + &salt;
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
let auth_str = format!("u={u}&t={t}&s={s}", u = user, t = token, s = salt);
let crate_name = env!("CARGO_PKG_NAME");
let auth_str = format!(
"{auth}&v={v}&c={c}&f={f}",
auth = auth_str,
c = crate_name,
v = "1.16.0",
f = "json"
);
Ok(Client { base_url, auth_str })
}
/// build the url with the specified endpoint
fn build_url(&self, endpoint: &str) -> Result<String> {
let scheme = self.base_url.scheme();
let addr = self
.base_url
.host_str()
.ok_or_else(|| Error::WrongUrl(self.base_url.to_string()))?;
let mut url = [scheme, "://", addr, "/rest/"].concat();
url.push_str(endpoint);
url.push('?');
url.push_str(&self.auth_str);
Ok(url)
}
// Construct the url with the specified parameter and send it
// If the response is not ok throw an error
pub async fn request_args(
&self,
endpoint: &str,
args: Option<Vec<(String, String)>>,
) -> Result<Map<String, Value>> {
let mut request_url = self.build_url(endpoint)?;
if let Some(map) = args {
map.iter().for_each(|(k, v)| {
write!(request_url, "&{k}={v}").ok();
});
}
let response = reqwest::get(&request_url).await?;
let v: Value = serde_json::from_str(response.text().await?.as_str())?;
let val = v
.as_object()
.unwrap()
.get("subsonic-response")
.unwrap()
.as_object()
.unwrap();
match val.get("status").unwrap().as_str().unwrap() {
"ok" => Ok(val.to_owned()),
_ => {
bail!(Error::WrongRequest(request_url))
}
}
}
pub async fn request(&self, endpoint: &str) -> Result<Map<String, Value>> {
self.request_args(endpoint, None).await
}
/// Fetch all the playlist with songs from the current user
pub async fn get_all_playlists(&self) -> Result<Vec<SubPlaylist>> {
let response = self.request("getPlaylists").await?;
let parsed = response
.get("playlists")
.unwrap()
.as_object()
.unwrap()
.get("playlist")
.unwrap();
let playlists: Vec<SubPlaylist> = serde_json::from_value(parsed.to_owned())?;
Ok(playlists)
}
/// Fetch the playlist
pub async fn get_playlist(&self, id: String) -> Result<Vec<SubTrack>> {
let args = vec![("id".to_string(), id)];
let response = self.request_args("getPlaylist", Some(args)).await?;
let playlist: Vec<SubTrack> = serde_json::from_value(
response
.get("playlist")
.unwrap()
.as_object()
.unwrap()
.get("entry")
.unwrap_or(&Value::Array(vec![]))
.to_owned(),
)?;
Ok(playlist)
}
/// Create the playlist and return it
pub async fn create_playlist(&self, name: String) -> Result<String> {
let args = vec![(String::from("name"), name)];
let response = self.request_args("createPlaylist", Some(args)).await?;
let id = response
.get("playlist")
.unwrap()
.as_object()
.unwrap()
.get("id")
.unwrap()
.to_string()
.replace('"', "");
Ok(id)
}
/// Add tracks to the playlist
pub async fn update_playlist(&self, id: String, tracks: Vec<SubTrack>) -> Result<()> {
let mut sub = Vec::with_capacity(50);
for entry in tracks.into_iter() {
if sub.len() < 50 {
sub.push(entry);
} else {
self.update_playlist_req(id.clone(), sub.drain(..).collect())
.await?;
}
}
if !sub.is_empty() {
self.update_playlist_req(id.clone(), sub.drain(..).collect())
.await?;
}
Ok(())
}
async fn update_playlist_req(&self, id: String, tracks: Vec<SubTrack>) -> Result<()> {
let mut args: Vec<(String, String)> = tracks
.iter()
.map(|x| (String::from("songIdToAdd"), x.id.to_string()))
.collect();
args.push((String::from("playlistId"), id));
self.request_args("updatePlaylist", Some(args)).await?;
Ok(())
}
/// Search for the given track
pub async fn get_track(&self, track: SimpleTrack) -> Result<Vec<SubTrack>> {
let name = &track.name;
let artist = &track.artist;
let album = &track.album;
let args = vec![(String::from("query"), format!("{name} {artist} {album}"))];
let response = self.request_args("search3", Some(args)).await?;
let mut found: Vec<SubTrack> = serde_json::from_value(
response
.get("searchResult3")
.unwrap()
.as_object()
.unwrap()
.get("song")
.unwrap_or(&Value::Array(vec![]))
.to_owned(),
)?;
if found.is_empty() {
debug!("Nothing found");
bail!(Error::NoMatch(track.to_string()))
}
found = found
.into_iter()
.filter(|x| {
x.title == *name && x.album == *album && x.get_artists().contains(&artist.as_str())
})
.collect();
debug!("Found {:?}", found);
Ok(found)
}
}

89
src/util.rs Normal file
View File

@@ -0,0 +1,89 @@
use anyhow::{Context, Result};
use log::{debug, info};
use regex::Regex;
use rspotify::model::{Id, PlaylistId};
use std::path::Path;
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
use crate::spotify::{get_spotify_playlists, SimplePlaylist};
use crate::subsonic::Client;
/// Read playlist id from file and fetch Spotify playlist accordingly
pub async fn get_filtered_spotify_playlists() -> Result<Vec<SimplePlaylist>> {
let path = Path::new("playlist.txt");
let mut file = File::open(&path)
.await
.context(format!("Could not find file : '{}'", path.display()))?;
let mut contents = vec![];
file.read_to_end(&mut contents).await?;
let re = Regex::new(r"https://open\.spotify\.com/playlist/([A-Za-z\d]{22})").unwrap();
let mut playlists_id = Vec::new();
let mut lines = contents.lines();
while let Some(line) = lines.next_line().await? {
if let Some(cap) = re.captures(&line) {
let id = cap.get(1).unwrap().as_str();
let id = PlaylistId::from_id(id).unwrap();
playlists_id.push(id);
}
}
debug!("Playlist found in file : {:?}", playlists_id);
get_spotify_playlists(&playlists_id).await
}
/** Sync playlist from spotify to subsonic compatible client
If the playlist does not exist create it
If a song is already present skip it
**/
pub async fn sync_playlists(client: &Client, spotify_playlists: Vec<SimplePlaylist>) -> Result<()> {
let sub_playlists = client.get_all_playlists().await?;
for spt_playlist in spotify_playlists {
if let Some(sub_pl) = sub_playlists.iter().find(|x| x.name == spt_playlist.name) {
info!("Playlist {} found updating...", spt_playlist.name);
let id = sub_pl.id.to_owned();
let sub_playlist = client.get_playlist(id).await?;
let mut to_add = Vec::new();
for track in spt_playlist.tracks {
if sub_playlist.iter().any(|x| {
x.title == track.name
&& x.album == track.album
&& x.get_artists().contains(&track.artist.as_str())
}) {
debug!("Track exists in playlist skipping : {:?} ", track);
} else {
debug!("Track not in playlist found adding : {:?} ", track);
let display_name = track.to_string();
if let Ok(found) = client.get_track(track).await {
to_add.extend(found);
} else {
println!("Track not found : {}", display_name);
}
}
}
info!("Adding {} new track(s)...", to_add.len());
client.update_playlist(sub_pl.id.clone(), to_add).await?;
} else {
info!("Playlist {} not found creating...", spt_playlist.name);
let playlist_id = client.create_playlist(spt_playlist.name).await?;
let mut to_add = Vec::new();
for track in spt_playlist.tracks {
let display_name = track.to_string();
if let Ok(found) = client.get_track(track).await {
to_add.extend(found);
} else {
println!("Track not found : {}", display_name);
}
}
client.update_playlist(playlist_id, to_add).await?;
}
}
Ok(())
}