Skip to content

Commit

Permalink
fix: switch to librespot 0.5.0 (#570)
Browse files Browse the repository at this point in the history
closes #520 
closes #579 
closes #580 

- upgrade dependencies. Main change involves the migration to `librespot v0.5.0`
- migrate authentication workflow to OAuth implemented in (librespot-org/librespot#1309)

## Next step
- handle Spotify Connect with user-provided `client_id`

Co-authored-by: Julia Mertz <info@juliamertz.dev>
Co-authored-by: Thang Pham <phamducthang1234@gmail.com>
  • Loading branch information
3 people authored Oct 28, 2024
1 parent 9894fa9 commit 68b205b
Show file tree
Hide file tree
Showing 16 changed files with 2,167 additions and 1,426 deletions.
3,092 changes: 1,930 additions & 1,162 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
## Table of Contents

- [Introduction](#introduction)
- [Important Notice](#important-notice)
- [Examples](#examples)
- [Installation](#installation)
- [Features](#features)
Expand Down Expand Up @@ -41,9 +40,6 @@
- Support running the application as [a daemon](#daemon)
- Offer a wide range of [CLI commands](#cli-commands)

## Important Notice
spotify-player throws error "Login failed with reason: Bad credentials" when authenticating from 7/29/2024 because Spotify removed username & password authentication from API through Mercury/Hermes. Please use [librespot-auth repository](https://github.com/dspearson/librespot-auth). For more details, see [#580](https://github.com/aome510/spotify-player/issues/580)

## Examples

A demo of `spotify_player` `v0.5.0-pre-release` on [youtube](https://www.youtube.com/watch/Jbfe9GLNWbA) or on [asciicast](https://asciinema.org/a/446913):
Expand Down
47 changes: 24 additions & 23 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- [Keymaps](#keymaps)

All configuration files should be placed inside the application's configuration folder (default to be `$HOME/.config/spotify-player`).

## General

**The default `app.toml` can be found in the example [`app.toml`](../examples/app.toml) file.**
Expand All @@ -25,10 +25,10 @@ All configuration files should be placed inside the application's configuration
| Option | Description | Default |
| --------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `client_id` | the Spotify client's ID | `65b708073fc0480ea92a077233ca87bd` |
| `client_id_command` | a shell command that prints the Spotify client ID to stdout (overrides `client_id`) | `None` |
| `client_id_command` | a shell command that prints the Spotify client ID to stdout (overrides `client_id`) | `None` |
| `client_port` | the port that the application's client is running on to handle CLI commands | `8080` |
| `tracks_playback_limit` | the limit for the number of tracks played in a **tracks** playback | `50` |
| `playback_format` | the format of the text in the playback's window | `{status} {track} • {artists}\n{album}\n{metadata}` |
| `playback_format` | the format of the text in the playback's window | `{status} {track} • {artists}\n{album}\n{metadata}` |
| `notify_format` | the format of a notification (`notify` feature only) | `{ summary = "{track} • {artists}", body = "{album}" }` |
| `notify_timeout_in_secs` | the timeout (in seconds) of a notification (`notify` feature only) | `0` (no timeout) |
| `player_event_hook_command` | the hook command executed when there is a new player event | `None` |
Expand All @@ -44,9 +44,9 @@ All configuration files should be placed inside the application's configuration
| `enable_cover_image_cache` | store album's cover images in the cache folder | `true` |
| `notify_streaming_only` | only send notification when streaming is enabled (`streaming` and `notify` feature only) | `false` |
| `default_device` | the default device to connect to on startup if no playing device found | `spotify-player` |
| `play_icon` | the icon to indicate playing state of a Spotify item | `` |
| `play_icon` | the icon to indicate playing state of a Spotify item | `` |
| `pause_icon` | the icon to indicate pause state of a Spotify item | `▌▌` |
| `liked_icon` | the icon to indicate the liked state of a song | `` |
| `liked_icon` | the icon to indicate the liked state of a song | `` |
| `border_type` | the type of the application's borders | `Plain` |
| `progress_bar_type` | the type of the playback progress bar | `Rectangle` |
| `cover_img_width` | the width of the cover image (`image` feature only) | `5` |
Expand Down Expand Up @@ -93,17 +93,17 @@ If specified, `player_event_hook_command` should be an object with two fields `c

A player event is represented as a list of arguments with either of the following values:

- `"Changed" OLD_TRACK_ID NEW_TRACK_ID`
- `"Playing" TRACK_ID POSITION_MS DURATION_MS`
- `"Paused" TRACK_ID POSITION_MS DURATION_MS`
- `"Changed" NEW_TRACK_ID`
- `"Playing" TRACK_ID POSITION_MS`
- `"Paused" TRACK_ID POSITION_MS`
- `"EndOfTrack" TRACK_ID`

**Note**: if `args` is specified, such arguments will be called before the event's arguments.

For example, if `player_event_hook_command = { command = "a.sh", args = ["-b", "c", "-d"] }`, upon receiving a `Changed` event with `OLD_TRACK_ID=x`, `NEW_TRACK_ID=y`, the following command will be run
For example, if `player_event_hook_command = { command = "a.sh", args = ["-b", "c", "-d"] }`, upon receiving a `Changed` event with `NEW_TRACK_ID=id`, the following command will be run

```shell
a.sh -b c -d Changed x y
a.sh -b c -d Changed id
```

Example script that reads event's data from arguments and prints them to a file:
Expand All @@ -114,9 +114,9 @@ Example script that reads event's data from arguments and prints them to a file:
set -euo pipefail

case "$1" in
"Changed") echo "command: $1, old_track_id: $2, new_track_id: $3" >> /tmp/log.txt ;;
"Playing") echo "command: $1, track_id: $2, position_ms: $3, duration_ms: $4" >> /tmp/log.txt ;;
"Paused") echo "command: $1, track_id: $2, position_ms: $3, duration_ms: $4" >> /tmp/log.txt ;;
"Changed") echo "command: $1, new_track_id: $2" >> /tmp/log.txt ;;
"Playing") echo "command: $1, track_id: $2, position_ms: $3" >> /tmp/log.txt ;;
"Paused") echo "command: $1, track_id: $2, position_ms: $3" >> /tmp/log.txt ;;
"EndOfTrack") echo "command: $1, track_id: $2" >> /tmp/log.txt ;;
esac
```
Expand All @@ -139,24 +139,25 @@ More details on the above configuration options can be found under the [Librespo

### Layout configurations

The layout of the application can be adjusted via these options.
The layout of the application can be adjusted via these options.

| Option | Description | Default |
| -------------------------- | ---------------------------------------------------------------- | ------- |
| `library.album_percent` | The percentage of the album window in the library | `40` |
| `library.playlist_percent` | The percentage of the playlist window in the library | `40` |
| `playback_window_position` | The position of the playback window | `Top` |
| `playback_window_height` | The height of the playback window | `6` |
| Option | Description | Default |
| -------------------------- | ---------------------------------------------------- | ------- |
| `library.album_percent` | The percentage of the album window in the library | `40` |
| `library.playlist_percent` | The percentage of the playlist window in the library | `40` |
| `playback_window_position` | The position of the playback window | `Top` |
| `playback_window_height` | The height of the playback window | `6` |

Example:
Example:

``` toml
```toml

[layout]
library = { album_percent = 40, playlist_percent = 40 }
playback_window_position = "Top"

```

## Themes

`spotify_player` uses the `theme.toml` config file to look for user-defined themes.
Expand Down Expand Up @@ -286,7 +287,7 @@ key_sequence = "q"

## Actions

Actions are located in the same `keymap.toml` file as keymaps. An action can be triggered by a key sequence that is not bound to any command. Once the mapped key sequence is pressed, the corresponding action will be triggered. By default actions will act upon the currently selected item, you can change this behaviour by setting the `target` field for a keymap to either `PlayingTrack` or `SelectedItem`.
Actions are located in the same `keymap.toml` file as keymaps. An action can be triggered by a key sequence that is not bound to any command. Once the mapped key sequence is pressed, the corresponding action will be triggered. By default actions will act upon the currently selected item, you can change this behaviour by setting the `target` field for a keymap to either `PlayingTrack` or `SelectedItem`.
a list of actions can be found [here](../README.md#actions).

For example,
Expand Down
34 changes: 21 additions & 13 deletions spotify_player/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,40 @@ readme = "../README.md"
anyhow = "1.0.86"
clap = { version = "4.5.8", features = ["derive", "string"] }
config_parser2 = "0.1.5"
crossterm = "0.27.0"
crossterm = "0.28.1"
dirs-next = "2.0.0"
librespot-connect = { version = "0.4.2", optional = true }
librespot-playback = { version = "0.4.2", optional = true }
librespot-core = "0.4.2"
librespot-connect = { version = "0.5.0", optional = true }
librespot-core = "0.5.0"
librespot-oauth = "0.5.0"
librespot-playback = { version = "0.5.0", optional = true }
log = "0.4.22"
chrono = "0.4.38"
reqwest = { version = "0.12.5", features = ["json"] }
rpassword = "7.3.1"
rspotify = "0.13.2"
rspotify = "0.13.3"
serde = { version = "1.0.204", features = ["derive"] }
tokio = { version = "1.38.0", features = ["rt", "rt-multi-thread", "macros", "time"] }
tokio = { version = "1.38.0", features = [
"rt",
"rt-multi-thread",
"macros",
"time",
] }
toml = "0.8.14"
tui = { package = "ratatui", version = "0.27.0" }
tui = { package = "ratatui", version = "0.29.0" }
rand = "0.8.5"
maybe-async = "0.2.10"
async-trait = "0.1.81"
parking_lot = "0.12.3"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
lyric_finder = { version = "0.1.6", path = "../lyric_finder" , optional = true }
lyric_finder = { version = "0.1.6", path = "../lyric_finder", optional = true }
backtrace = "0.3.73"
souvlaki = { version = "0.7.3", optional = true }
viuer = { version = "0.7.1", optional = true }
image = { version = "0.24.9", optional = true }
notify-rust = { version = "4.11.0", optional = true, default-features = false, features = ["d"] }
viuer = { version = "0.9.1", optional = true }
image = { version = "0.25.4", optional = true }
notify-rust = { version = "4.11.0", optional = true, default-features = false, features = [
"d",
] }
flume = "0.11.0"
serde_json = "1.0.120"
once_cell = "1.19.0"
Expand All @@ -49,6 +57,7 @@ clap_complete = "4.5.7"
which = "6.0.1"
fuzzy-matcher = { version = "0.3.7", optional = true }
html-escape = "0.2.13"
rustls = "0.23.14"

[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.winit]
version = "0.30.3"
Expand All @@ -63,7 +72,7 @@ features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_UI_WindowsAndMessaging"
"Win32_UI_WindowsAndMessaging",
]
optional = true

Expand All @@ -89,4 +98,3 @@ default = ["rodio-backend", "media-control"]

[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }_{ target }{ archive-suffix }"

139 changes: 49 additions & 90 deletions spotify_player/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
use std::io::Write;

use anyhow::{anyhow, Result};
use librespot_core::{
authentication::Credentials,
cache::Cache,
config::SessionConfig,
session::{Session, SessionError},
};
use anyhow::Result;
use librespot_core::{authentication::Credentials, cache::Cache, config::SessionConfig, Session};
use librespot_oauth::get_access_token;

use crate::config;

pub const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
pub const CLIENT_REDIRECT_URI: &str = "http://127.0.0.1:8989/login";
pub const OAUTH_SCOPES: &[&str] = &[
"playlist-modify",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read",
"playlist-read-collaborative",
"playlist-read-private",
"streaming",
"user-follow-modify",
"user-follow-read",
"user-library-modify",
"user-library-read",
"user-modify",
"user-modify-playback-state",
"user-modify-private",
"user-personalized",
"user-read-currently-playing",
"user-read-email",
"user-read-play-history",
"user-read-playback-position",
"user-read-playback-state",
"user-read-private",
"user-read-recently-played",
"user-top-read",
];

#[derive(Clone)]
pub struct AuthConfig {
pub cache: Cache,
Expand All @@ -26,6 +48,11 @@ impl Default for AuthConfig {
}

impl AuthConfig {
/// Create a `librespot::Session` from authentication configs
pub fn session(&self) -> Session {
Session::new(self.session_config.clone(), Some(self.cache.clone()))
}

pub fn new(configs: &config::Configs) -> Result<AuthConfig> {
let audio_cache_folder = if configs.app_config.device.audio_cache {
Some(configs.cache_folder.join("audio"))
Expand All @@ -42,99 +69,31 @@ impl AuthConfig {

Ok(AuthConfig {
cache,
session_config: configs.app_config.session_config(),
session_config: configs.app_config.session_config()?,
})
}
}

fn read_user_auth_details(user: Option<String>) -> Result<(String, String)> {
let mut username = String::new();
let mut stdout = std::io::stdout();
match user {
None => write!(stdout, "Username: ")?,
Some(ref u) => write!(stdout, "Username (default: {u}): ")?,
}
stdout.flush()?;
std::io::stdin().read_line(&mut username)?;
username = username.trim_end().to_string();
if username.is_empty() {
username = user.unwrap_or_default();
}
let password = rpassword::prompt_password(format!("Password for {username}: "))?;
Ok((username, password))
}

pub async fn new_session_with_new_creds(auth_config: &AuthConfig) -> Result<Session> {
tracing::info!("Creating a new session with new authentication credentials");

let mut user: Option<String> = None;

for i in 0..3 {
let (username, password) = read_user_auth_details(user)?;
user = Some(username.clone());
match Session::connect(
auth_config.session_config.clone(),
Credentials::with_password(username, password),
Some(auth_config.cache.clone()),
true,
)
.await
{
Ok((session, _)) => {
println!("Successfully authenticated as {}", user.unwrap_or_default());
return Ok(session);
}
Err(err) => {
eprintln!("Failed to authenticate, {} tries left", 2 - i);
tracing::warn!("Failed to authenticate: {err:#}")
}
}
}

Err(anyhow!("authentication failed!"))
}

/// Creates a new Librespot session
///
/// By default, the function will look for cached credentials in the `APP_CACHE_FOLDER` folder.
///
/// If `reauth` is true, re-authenticate by asking the user for Spotify's username and password.
/// The re-authentication process should only happen on the terminal using stdin/stdout.
pub async fn new_session(auth_config: &AuthConfig, reauth: bool) -> Result<Session> {
match auth_config.cache.credentials() {
/// Get Spotify credentials to authenticate the application
pub async fn get_creds(auth_config: &AuthConfig, reauth: bool) -> Result<Credentials> {
Ok(match auth_config.cache.credentials() {
None => {
let msg = "No cached credentials found, please authenticate the application first.";
if reauth {
eprintln!("{msg}");
new_session_with_new_creds(auth_config).await
get_access_token(
SPOTIFY_CLIENT_ID,
CLIENT_REDIRECT_URI,
OAUTH_SCOPES.to_vec(),
)
.map(|t| Credentials::with_access_token(t.access_token))?
} else {
anyhow::bail!(msg);
}
}
Some(creds) => {
match Session::connect(
auth_config.session_config.clone(),
creds,
Some(auth_config.cache.clone()),
true,
)
.await
{
Ok((session, _)) => {
tracing::info!(
"Successfully used the cached credentials to create a new session!"
);
Ok(session)
}
Err(err) => match err {
SessionError::AuthenticationError(err) => {
anyhow::bail!("Failed to authenticate using cached credentials: {err:#}");
}
SessionError::IoError(err) => {
anyhow::bail!("{err:#}\nPlease check your internet connection.");
}
},
}
tracing::info!("Using cached credentials");
creds
}
}
})
}
Loading

0 comments on commit 68b205b

Please sign in to comment.