First commit
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
.env
|
||||
.idea
|
||||
.spotify_token_cache.json
|
||||
1763
Cargo.lock
generated
Normal file
1763
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
Cargo.toml
Normal file
35
Cargo.toml
Normal 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
21
LICENSE
Normal 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
4
playlist.txt
Normal 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
20
readme.md
Normal 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
3
src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod spotify;
|
||||
pub mod subsonic;
|
||||
pub mod util;
|
||||
30
src/main.rs
Normal file
30
src/main.rs
Normal 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
88
src/spotify.rs
Normal 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
249
src/subsonic.rs
Normal 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
89
src/util.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user