committed by
Henrik Friedrichsen
parent
58f34b9288
commit
d78e71871a
@@ -15,6 +15,7 @@ maintenance = { status = "experimental" }
|
||||
[dependencies]
|
||||
clap = "2.32.0"
|
||||
chrono = "0.4"
|
||||
reqwest = "0.9.11"
|
||||
crossbeam-channel = "0.3.8"
|
||||
directories = "1.0"
|
||||
failure = "0.1.3"
|
||||
@@ -34,13 +35,16 @@ rand = "0.6.5"
|
||||
|
||||
[dependencies.librespot]
|
||||
git = "https://github.com/librespot-org/librespot.git"
|
||||
rev = "2fb901a743906a5b49b3148dbfa85074964dd745"
|
||||
rev = "14721f4"
|
||||
default-features = false
|
||||
|
||||
[dependencies.cursive]
|
||||
version = "0.11.1"
|
||||
default-features = false
|
||||
|
||||
[dependencies.winapi]
|
||||
git = "https://github.com/overdrivenpotato/winapi-rs"
|
||||
|
||||
[features]
|
||||
pulseaudio_backend = ["librespot/pulseaudio-backend"]
|
||||
rodio_backend = ["librespot/rodio-backend"]
|
||||
|
||||
169
src/authentication.rs
Normal file
169
src/authentication.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use std::path::Path;
|
||||
|
||||
use cursive::view::Identifiable;
|
||||
use cursive::views::*;
|
||||
use cursive::{CbSink, Cursive};
|
||||
|
||||
use librespot::core::authentication::Credentials as RespotCredentials;
|
||||
use librespot::protocol::authentication::AuthenticationType;
|
||||
|
||||
pub fn create_credentials(path: &Path) -> Result<RespotCredentials, String> {
|
||||
let mut login_cursive = Cursive::default();
|
||||
let mut info_buf = TextContent::new("Failed to authenticate\n");
|
||||
info_buf.append(format!(
|
||||
"Cannot read config file from {}\n",
|
||||
path.to_str().unwrap()
|
||||
));
|
||||
let info_view = Dialog::around(TextView::new_with_content(info_buf.clone()))
|
||||
.button("Login", move |s| {
|
||||
let login_view = Dialog::new()
|
||||
.title("Login with Spotify username and password")
|
||||
.content(
|
||||
ListView::new()
|
||||
.child("username", EditView::new().with_id("spotify_user"))
|
||||
.child(
|
||||
"password",
|
||||
EditView::new().secret().with_id("spotify_password"),
|
||||
),
|
||||
)
|
||||
.button("Login", |s| {
|
||||
let username = s
|
||||
.call_on_id("spotify_user", |view: &mut EditView| view.get_content())
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let auth_data = s
|
||||
.call_on_id("spotify_password", |view: &mut EditView| view.get_content())
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
s.set_user_data::<Result<RespotCredentials, String>>(Ok(RespotCredentials {
|
||||
username,
|
||||
auth_type: AuthenticationType::AUTHENTICATION_USER_PASS,
|
||||
auth_data,
|
||||
}));
|
||||
s.quit();
|
||||
})
|
||||
.button("Quit", |s| s.quit());
|
||||
s.pop_layer();
|
||||
s.add_layer(login_view);
|
||||
})
|
||||
.button("Login with Facebook", |s| {
|
||||
let urls: std::collections::HashMap<String, String> =
|
||||
reqwest::get("https://login2.spotify.com/v1/config")
|
||||
.expect("didn't connect")
|
||||
.json()
|
||||
.expect("didn't parse");
|
||||
// not a dialog to let people copy & paste the URL
|
||||
let url_notice = TextView::new(format!("Browse to {}", urls.get("login_url").unwrap()));
|
||||
|
||||
let controls = Button::new("Quit", |s| s.quit());
|
||||
|
||||
let login_view = LinearLayout::new(cursive::direction::Orientation::Vertical)
|
||||
.child(url_notice)
|
||||
.child(controls);
|
||||
url_open(urls.get("login_url").unwrap().to_string());
|
||||
crappy_poller(urls.get("credentials_url").unwrap(), &s.cb_sink());
|
||||
s.pop_layer();
|
||||
s.add_layer(login_view)
|
||||
})
|
||||
.button("Quit", |s| s.quit());
|
||||
|
||||
login_cursive.add_layer(info_view);
|
||||
login_cursive.run();
|
||||
|
||||
login_cursive
|
||||
.user_data()
|
||||
.cloned()
|
||||
.unwrap_or(Err("Didn't obtain any credentials".to_string()))
|
||||
}
|
||||
|
||||
// TODO: better with futures?
|
||||
fn crappy_poller(url: &str, app_sink: &CbSink) {
|
||||
let app_sink = app_sink.clone();
|
||||
let url = url.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let timeout = std::time::Duration::from_secs(5 * 60);
|
||||
let start_time = std::time::SystemTime::now();
|
||||
while std::time::SystemTime::now()
|
||||
.duration_since(start_time)
|
||||
.unwrap_or(timeout)
|
||||
< timeout
|
||||
{
|
||||
if let Ok(mut response) = reqwest::get(&url) {
|
||||
if response.status() != reqwest::StatusCode::ACCEPTED {
|
||||
let result = match response.status() {
|
||||
reqwest::StatusCode::OK => {
|
||||
let creds = response
|
||||
.json::<AuthResponse>()
|
||||
.expect("Unable to parse")
|
||||
.credentials;
|
||||
Ok(creds)
|
||||
}
|
||||
|
||||
_ => Err(format!(
|
||||
"Facebook auth failed with code {}: {}",
|
||||
response.status(),
|
||||
response.text().unwrap()
|
||||
)),
|
||||
};
|
||||
app_sink
|
||||
.send(Box::new(|s: &mut Cursive| {
|
||||
s.set_user_data(result);
|
||||
s.quit();
|
||||
}))
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
}
|
||||
|
||||
app_sink
|
||||
.send(Box::new(|s: &mut Cursive| {
|
||||
s.set_user_data::<Result<RespotCredentials, String>>(Err(
|
||||
"Timed out authenticating".to_string(),
|
||||
));
|
||||
s.quit();
|
||||
}))
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct AuthResponse {
|
||||
pub credentials: RespotCredentials,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// Thanks to Marko Mijalkovic for this, but I don't want the url crate
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn url_open(url: String) {
|
||||
extern crate shell32;
|
||||
extern crate winapi;
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::ptr;
|
||||
|
||||
unsafe {
|
||||
shell32::ShellExecuteA(
|
||||
ptr::null_mut(),
|
||||
CString::new("open").unwrap().as_ptr(),
|
||||
CString::new(url.replace("\n", "%0A")).unwrap().as_ptr(),
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
winapi::SW_SHOWNORMAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn url_open(url: String) {
|
||||
let _ = std::process::Command::new("open").arg(url).output();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn url_open(url: String) {
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).output();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
|
||||
@@ -8,8 +8,6 @@ pub const CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct Config {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub keybindings: Option<HashMap<String, String>>,
|
||||
pub theme: Option<ConfigTheme>,
|
||||
pub use_nerdfont: Option<bool>,
|
||||
@@ -42,7 +40,7 @@ pub fn config_path(file: &str) -> PathBuf {
|
||||
let proj_dirs = proj_dirs();
|
||||
let cfg_dir = proj_dirs.config_dir();
|
||||
trace!("{:?}", cfg_dir);
|
||||
if !cfg_dir.is_dir() {
|
||||
if cfg_dir.exists() && !cfg_dir.is_dir() {
|
||||
fs::remove_file(cfg_dir).expect("unable to remove old config file");
|
||||
}
|
||||
if !cfg_dir.exists() {
|
||||
@@ -63,3 +61,54 @@ pub fn cache_path(file: &str) -> PathBuf {
|
||||
pb.push(file);
|
||||
pb
|
||||
}
|
||||
|
||||
/// Configuration and credential file helper
|
||||
/// Creates a default configuration if none exist, otherwise will optionally overwrite
|
||||
/// the file if it fails to parse
|
||||
pub fn load_or_generate_default<
|
||||
P: AsRef<Path>,
|
||||
T: serde::Serialize + serde::de::DeserializeOwned,
|
||||
F: Fn(&Path) -> Result<T, String>,
|
||||
>(
|
||||
path: P,
|
||||
default: F,
|
||||
default_on_parse_failure: bool,
|
||||
) -> Result<T, String> {
|
||||
let path = path.as_ref();
|
||||
// Nothing exists so just write the default and return it
|
||||
if !path.exists() {
|
||||
let value = default(&path)?;
|
||||
return write_content_helper(&path, value);
|
||||
}
|
||||
|
||||
// load the serialized content. Always report this failure
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Unable to read {}: {}", path.to_string_lossy(), e))?;
|
||||
|
||||
// Deserialize the content, optionally fall back to default if it fails
|
||||
let result = toml::from_str(&contents);
|
||||
if default_on_parse_failure {
|
||||
if let Err(_) = result {
|
||||
let value = default(&path)?;
|
||||
return write_content_helper(&path, value);
|
||||
}
|
||||
}
|
||||
result.map_err(|e| format!("Unable to parse {}: {}", path.to_string_lossy(), e))
|
||||
}
|
||||
|
||||
fn write_content_helper<P: AsRef<Path>, T: serde::Serialize>(
|
||||
path: P,
|
||||
value: T,
|
||||
) -> Result<T, String> {
|
||||
let content =
|
||||
toml::to_string_pretty(&value).map_err(|e| format!("Failed serializing value: {}", e))?;
|
||||
fs::write(path.as_ref(), content)
|
||||
.map(|_| value)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed writing content to {}: {}",
|
||||
path.as_ref().display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
38
src/main.rs
38
src/main.rs
@@ -35,6 +35,9 @@ use clap::{App, Arg};
|
||||
use cursive::traits::Identifiable;
|
||||
use cursive::Cursive;
|
||||
|
||||
use librespot::core::authentication::Credentials;
|
||||
|
||||
mod authentication;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod events;
|
||||
@@ -98,21 +101,22 @@ fn main() {
|
||||
|
||||
// Things here may cause the process to abort; we must do them before creating curses windows
|
||||
// otherwise the error message will not be seen by a user
|
||||
let path = config::config_path("config.toml");
|
||||
let cfg: ::config::Config = {
|
||||
let path = config::config_path("config.toml");
|
||||
::config::load_or_generate_default(path, |_| Ok(::config::Config::default()), false)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
process::exit(1);
|
||||
})
|
||||
};
|
||||
|
||||
let cfg: config::Config = {
|
||||
let contents = std::fs::read_to_string(&path).unwrap_or_else(|_| {
|
||||
eprintln!("Cannot read config file from {}", path.to_str().unwrap());
|
||||
eprintln!(
|
||||
"Expected a config file with this format:\n{}",
|
||||
toml::to_string_pretty(&config::Config::default()).unwrap()
|
||||
);
|
||||
process::exit(1)
|
||||
});
|
||||
toml::from_str(&contents).unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
process::exit(1)
|
||||
})
|
||||
let credentials: Credentials = {
|
||||
let path = config::config_path("credentials.toml");
|
||||
::config::load_or_generate_default(path, authentication::create_credentials, true)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("{}", e);
|
||||
process::exit(1);
|
||||
})
|
||||
};
|
||||
|
||||
let theme = theme::load(&cfg);
|
||||
@@ -122,11 +126,7 @@ fn main() {
|
||||
|
||||
let event_manager = EventManager::new(cursive.cb_sink().clone());
|
||||
|
||||
let spotify = Arc::new(spotify::Spotify::new(
|
||||
event_manager.clone(),
|
||||
cfg.username.clone(),
|
||||
cfg.password.clone(),
|
||||
));
|
||||
let spotify = Arc::new(spotify::Spotify::new(event_manager.clone(), credentials));
|
||||
|
||||
let queue = Arc::new(queue::Queue::new(spotify.clone()));
|
||||
|
||||
|
||||
@@ -184,13 +184,13 @@ impl futures::Future for Worker {
|
||||
}
|
||||
|
||||
impl Spotify {
|
||||
pub fn new(events: EventManager, user: String, password: String) -> Spotify {
|
||||
pub fn new(events: EventManager, credentials: Credentials) -> Spotify {
|
||||
let player_config = PlayerConfig {
|
||||
bitrate: Bitrate::Bitrate320,
|
||||
normalisation: false,
|
||||
normalisation_pregain: 0.0,
|
||||
};
|
||||
let credentials = Credentials::with_password(user.clone(), password.clone());
|
||||
let user = credentials.username.clone();
|
||||
|
||||
let (tx, rx) = mpsc::unbounded();
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user