Skip to content

Commit

Permalink
feat: update deps and move librespot
Browse files Browse the repository at this point in the history
  • Loading branch information
BenJeau committed Mar 24, 2024
1 parent a5273d2 commit 2a6b7dc
Show file tree
Hide file tree
Showing 11 changed files with 2,277 additions and 873 deletions.
2,675 changes: 2,094 additions & 581 deletions Cargo.lock

Large diffs are not rendered by default.

34 changes: 17 additions & 17 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
[package]
edition = "2021"
name = "currently_playing_spotify"
version = "0.2.9"
edition = "2021"

[dependencies]
axum = { version = "0.6.1", features = ["ws", "headers"] }
chrono = { version = "0.4.23", features = ["serde"] }
clap = { version = "4.0.32", features = ["derive", "env"] }
http = "0.2.8"
reqwest = { version = "0.11.11", features = ["json"] }
serde = { version = "1.0.151", features = ["derive"] }
serde_json = "1.0.91"
tracing = "0.1.37"
tracing-subscriber = "0.3.16"
tokio = { version = "1.23.0", default-features = false, features = ["io-util", "macros", "rt", "rt-multi-thread", "sync"] }
tower-http = { version = "0.3.5", features = ["cors"] }
axum = { version = "0.7.4", features = ["ws"] }
axum-extra = { version = "0.9.3", features = ["typed-header"] }
clap = { version = "4.5.3", features = ["derive", "env"] }
http = "0.2.9"
librespot = { git = "https://github.com/librespot-org/librespot", branch = "dev", default-features = false }
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.114"
tokio = { version = "1.36.0", default-features = false, features = ["io-util", "macros", "rt", "rt-multi-thread", "sync"] }
tower-http = { version = "0.5.2", features = ["cors"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"

[profile.release]
opt-level = 'z' # Optimize for size.
lto = true # Enable Link Time Optimization
codegen-units = 1 # Reduce number of codegen units to increase optimizations.
panic = 'abort' # Abort on panic
strip = "debuginfo"
codegen-units = 1 # Reduce number of codegen units to increase optimizations.
lto = true # Enable Link Time Optimization
opt-level = 'z' # Optimize for size.
panic = 'abort' # Abort on panic
strip = true
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM rust:1.77.0-alpine3.19 AS builder
RUN apk update && apk upgrade --no-cache
RUN apk add --no-cache musl-dev upx
WORKDIR /app
COPY ./src ./src
COPY ./Cargo.toml .
COPY ./Cargo.lock .

RUN cargo build --release
RUN upx --best --lzma /app/target/release/currently_playing_spotify

FROM scratch
COPY --from=builder /app/target/release/currently_playing_spotify /

CMD ["./currently_playing_spotify"]
59 changes: 20 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,8 @@
# currently_playing_spotify

Simple Rust WebSocket proxy server using [axum](https://crates.io/crates/axum) to know what track the specified user is currently listening to. Server caching is used to keep the latest song in memory to not overload Spotify's REST API and the WebSocket API is available on port `8080`.
Rust WebSocket proxy server using [axum](https://crates.io/crates/axum) relaying Spotify tracks that are currently being played by the logged in user. WebSocket messages are only sent if the track changes or the state has changed (playing or paused). A background job periodically queries the API and relays the information to all users connected.

## Usage

Call the binary with the required parameters to the binary, to learn more about the parameters use `--help` or `-h` or look at the table below.

| Parameter name | Environment name | Required | Default | Description |
| -------------------- | ----------------------- | -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `--interval` or `-i` | `INTERVAL_QUERY_SECS` | No | `2` | Maximum interval in seconds which the Spotify API will be called |
| `--port` or `-p` | `WEBSOCKET_PORT` | No | `8080` | WebSocket server port |
| `--address` or `-a` | `WEBSOCKET_ADDRESS` | No | `0.0.0.0` | WebSocket server address |
| `--cors-origin` | `CORS_ORIGIN` | No | `*` | Set a single allow origin target, permissive if nothing is passed |
| `--auth-code` | `SPOTIFY_AUTH_CODE` | Yes | - | Authentication code from the Spotify user taken from the Authentication authentication flow (learn more [below](#authentication-code)) |
| `--client-id` | `SPOTIFY_CLIENT_ID` | Yes | - | Spotify application client id (learn more [below](#client-id-and-secret)) |
| `--client-secret` | `SPOTIFY_CLIENT_SECRET` | Yes | - | Spotify application client secret (learn more [below](#client-id-and-secret)) |
| `--compact` | `COMPACT` | No | `false` | Compacts the JSON response (removes many fields from the Spotify response) |

## Developing

Have rust installed and run `cargo run` with the appropriate parameters or environment variables (e.g. `cargo run -- --auth-code asdf...`).

### Response

The periodic WebSocket response is (where data is `null` if the user is not playing anything or is an object from the [Currently Playing Spotify API](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-the-users-currently-playing-track) if compact is `false`, or if compact is `true` it is a subset of the fields from the Spotify API):
Once the server is running, you can connect to it using a WebSocket via the `/ws` endpoint on the `8080` port. The server will send a message to the client when the track changes or the state changes (playing or paused). The message is a JSON object with the following fields (where data is `null` if the user is not playing anything or is an object from the [Currently Playing Spotify API](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-the-users-currently-playing-track) if compact is `false`, or if compact is `true` it is a subset of the fields from the Spotify API):

```json
{
Expand All @@ -32,6 +11,24 @@ The periodic WebSocket response is (where data is `null` if the user is not play
}
```

## Usage

Call the binary with the required parameters to the binary, to learn more about the parameters use `--help` or `-h` or look at the table below.

| Parameter name | Environment name | Required | Default | Description |
| -------------------- | --------------------- | -------- | --------- | -------------------------------------------------------------------------- |
| `--username` or `-u` | `SPOTIFY_USERNAME` | Yes | - | Spotify account username |
| `--password` or `-p` | `SPOTIFY_PASSWORD` | Yes | - | Spotify account password |
| `--interval` or `-i` | `INTERVAL_QUERY_SECS` | No | `1` | Maximum interval in seconds which the Spotify API will be called |
| `--port` | `WEBSOCKET_PORT` | No | `8080` | WebSocket server port |
| `--address` or `-a` | `WEBSOCKET_ADDRESS` | No | `0.0.0.0` | WebSocket server address |
| `--cors-origin` | `CORS_ORIGIN` | No | `*` | Set a single allow origin target, permissive if nothing is passed |
| `--compact` or `-c` | `COMPACT` | No | `false` | Compacts the JSON response (removes many fields from the Spotify response) |

## Developing

Have rust installed and run `cargo run` with the appropriate parameters or environment variables (e.g. `cargo run -- --username BenJeau...`).

## Download

Get it from GitHub releases or use curl from the terminal (and replace `VERSION` with the appropriate version):
Expand All @@ -40,19 +37,3 @@ Get it from GitHub releases or use curl from the terminal (and replace `VERSION`
curl -L https://github.com/BenJeau/currently_playing_spotify/releases/download/VERSION/currently_playing_spotify --output ./currently_playing_spotify
chmod +x ./currently_playing_spotify
```

## Spotify Credentials

### Client ID and Secret

1. Create a new application in the [Spotify's dashboard](https://developer.spotify.com/dashboard/)
2. The client id and client secret is available on the dashboard

### Authentication Code

The following steps are what is described in the [Spotify Authorization Flow](https://developer.spotify.com/documentation/general/guides/authorization/code-flow/) and assumes you already created a Spotify application.

1. Add `http://localhost:8888/callback` as a Redirect URI in the settings
2. Go to the following website (must replace `SPOTIFY_CLIENT_ID` with your own Spotify application client id)
* https://accounts.spotify.com/authorize?scope=user-read-recently-played%20user-read-playback-state&response_type=code&redirect_uri=http://localhost:8888/callback&client_id=SPOTIFY_CLIENT_ID
3. Extract the authentication code from the URL (what follows `?code=` from the URL response, such as http://localhost:8888/callback?code=AQA_F-eO8V...)
215 changes: 55 additions & 160 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,194 +1,89 @@
use chrono::{DateTime, Utc};
use serde::Deserialize;
use axum::body::Bytes;
use http::{
header::{ACCEPT, AUTHORIZATION},
Request,
};
use librespot::{
core::{config::SessionConfig, session::Session},
discovery::Credentials,
};
use std::time::Duration;
use tokio::{sync::watch::Sender, time::interval};
use tracing::{error, info, warn};
use tracing::info;

use crate::{song::Song, utils::has_time_passed};
use crate::{
error::Result,
song::{Song, SongContent},
};

#[derive(Clone)]
pub struct SpotifyAuth {
auth_code: String,
expires_in: i64,
fetched: DateTime<Utc>,
refresh_token: Option<String>,
access_token: Option<String>,
client_id: String,
client_secret: String,
session: Session,
compact: bool,
}

#[derive(Deserialize)]
struct SpotifyAuthCodeResponse {
access_token: String,
expires_in: i64,
refresh_token: String,
}

#[derive(Deserialize)]
struct SpotifyAuthResponse {
access_token: String,
expires_in: i64,
}

impl SpotifyAuth {
pub async fn new(
auth_code: String,
client_id: String,
client_secret: String,
compact: bool,
) -> SpotifyAuth {
let mut auth = SpotifyAuth {
auth_code,
expires_in: 0,
fetched: Utc::now(),
access_token: None,
refresh_token: None,
client_id,
client_secret,
compact,
};

auth.get_auth_tokens().await;

auth
}
pub async fn new(username: &str, password: &str, compact: bool) -> Self {
let session_config = SessionConfig::default();
let session = Session::new(session_config, None);

fn should_get_new_access_token(&self) -> bool {
has_time_passed(self.fetched, self.expires_in)
}

async fn get_auth_tokens(&mut self) {
info!("Querying Spotify auth tokens API");

let SpotifyAuthCodeResponse {
access_token,
expires_in,
refresh_token,
} = reqwest::Client::new()
.post("https://accounts.spotify.com/api/token")
.basic_auth(self.client_id.clone(), Some(self.client_secret.clone()))
.form(&[
("redirect_uri", "http://localhost:8888/callback"),
("grant_type", "authorization_code"),
("code", &self.auth_code),
])
.send()
.await
.expect("Error querying Spotify auth tokens API")
.json::<SpotifyAuthCodeResponse>()
let credentials = Credentials::with_password(username, password);
session
.connect(credentials, false)
.await
.expect("Invalid authorization code");

self.access_token = Some(access_token);
self.refresh_token = Some(refresh_token);
self.expires_in = expires_in;
self.fetched = Utc::now();
}
.expect("Unable to connect with provided credentials");

async fn get_new_access_token(&mut self) {
let refresh_token = match self.refresh_token.clone() {
Some(token) => token,
None => {
warn!("Not querying Spotify access token auth API, no refresh token saved");
return;
}
};

info!("Querying Spotify access token auth API");

let response = reqwest::Client::new()
.post("https://accounts.spotify.com/api/token")
.basic_auth(self.client_id.clone(), Some(self.client_secret.clone()))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &refresh_token),
])
.send()
.await;

match response {
Ok(data) => {
let body = data.text().await.unwrap();

let data = match serde_json::from_str::<SpotifyAuthResponse>(&body) {
Ok(auth) => auth,
Err(err) => {
error!("Error parsing body. Error: {err:?}. Body: {body:?}");
return;
}
};

self.access_token = Some(data.access_token);
self.expires_in = data.expires_in;
self.fetched = Utc::now();
}
Err(err) => {
error!("Error querying Spotify access token auth API: {err:?}");
}
};
Self { session, compact }
}

async fn currently_playing_request(&self) -> reqwest::Result<Song> {
async fn query_currently_playing(&self) -> Result<Bytes> {
info!("Querying Spotify currently playing track API");

let access_token = match self.access_token.clone() {
Some(token) => token,
None => {
error!("Access token does not exist");
return Ok(Song::new(None, self.compact));
}
};

let response = reqwest::Client::new()
.get("https://api.spotify.com/v1/me/player/currently-playing")
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|err| {
error!("Error querying Spotify currently playing track API: {err:?}");
err
})?
.text()
.await
.map_err(|err| {
info!("User NOT currently playing music: {err}");
})
.map(Option::Some)
.unwrap_or(None);
let token = self
.session
.token_provider()
.get_token("user-read-currently-playing")
.await?;

Ok(Song::new(response, self.compact))
}
let request = Request::get("https://api.spotify.com/v1/me/player/currently-playing")
.header(AUTHORIZATION, format!("Bearer {}", token.access_token))
.header(ACCEPT, "application/json")
.body(Default::default())?;

pub async fn query_currently_playing(&mut self) -> Option<Song> {
if self.should_get_new_access_token() {
self.get_new_access_token().await;
}
let response = self.session.http_client().request_body(request).await?;

match self.currently_playing_request().await {
Ok(song) => Some(song),
_ => {
self.get_new_access_token().await;
match self.currently_playing_request().await {
Ok(song) => Some(song),
_ => None,
}
}
}
Ok(response)
}
}

pub async fn query_periodically_spotify_api(
interval_time: u64,
mut spotify_auth: SpotifyAuth,
spotify_auth: SpotifyAuth,
tx: Sender<String>,
) {
let mut query_interval = interval(Duration::from_secs(interval_time));
let mut previous_response: Option<SongContent> = None;

loop {
let song = spotify_auth.query_currently_playing().await;
let song = spotify_auth.query_currently_playing().await.unwrap();

let song_content = serde_json::from_slice::<SongContent>(&song).ok();

if song_content != previous_response {
let data = if spotify_auth.compact {
song_content
.clone()
.map(|s| serde_json::to_value(s).unwrap())
} else {
Some(serde_json::from_slice(&song).unwrap())
};

let song = Song::new(data).unwrap();

let _ = tx.send(serde_json::to_string(&song).unwrap());
previous_response = song_content;
}

let _ = tx.send(serde_json::to_string(&song).unwrap());
query_interval.tick().await;
}
}

0 comments on commit 2a6b7dc

Please sign in to comment.