Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Changed

- **Your own Spotify app now comes first**: Spotify rate limits are per app, and the shared ncspot client ID that the quick setup uses is counted against every ncspot and spotatui user at once, so playlists stopped loading and the log filled with `429 Too Many Requests` no matter what spotatui itself did. Option 2 of the setup wizard (`spotatui --reconfigure-auth`) now writes your own app as the primary `client_id` and keeps the shared id only as the fallback for a failed login; before, it was the other way round and your own app was never used while the shared one still answered. A `client.yml` from an older version is read the new way round, but your app still needs one login: the first launch after the upgrade runs on the shared id and says so until you run `spotatui --reconfigure-auth` once. When a session runs on the shared id, the rate-limit status message and the log say so and name `spotatui --reconfigure-auth` as the way out, the wizard's text says which option shares its limit, the wizard asks for the port before it prints the Redirect URI to register, and the login's wait for the browser callback gives up after three minutes instead of holding startup forever. A rate limit on the plan check at startup no longer switches native streaming off for the session: the check waits for the window and asks again.

### Added

- **Qobuz source** (`--features qobuz`, included in the Linux, macOS, and Windows release binaries): spotatui can now play your Qobuz library. Pick Qobuz in the first-run picker or the `d` menu and log in through the browser; the token is saved in `qobuz_credentials.yml` (owner-only on Unix), and `SPOTATUI_QOBUZ_TOKEN` overrides it. The sidebar lists your favorite tracks, playlists, and favorite albums, search finds tracks, and every playback feature the other sources have works: next/previous, seek, repeat and shuffle, the cross-source queue, `last_session.yml` resume, cover art, lyrics, and OS media controls. Playback uses the same encrypted stream the Qobuz web player uses: each track is decrypted and rebuilt as a FLAC temporary file while it plays through spotatui's own engine (playback starts after the first segments arrive, and a seek past the downloaded part continues the download from there), all off the event pump so the UI stays responsive. `behavior.qobuz_quality` picks the stream (5 MP3 320, 6 FLAC 16/44.1, 7 FLAC 24/96, 27 FLAC 24/192; default 6). The constants the API needs are read from the Qobuz web player at runtime and cached by bundle version, never embedded; `SPOTATUI_QOBUZ_APP_ID`, `SPOTATUI_QOBUZ_APP_SECRET`, and `SPOTATUI_QOBUZ_OAUTH_KEY` override them when Qobuz changes its web player.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ Spotify is different: it uses the [Web API](https://developer.spotify.com/docume

Playing Spotify tracks requires a **Premium** account. With a free Spotify account spotatui can authenticate and browse your library/search results, but playback actions (play/pause/seek/transfer) will not work in either native streaming or Web API playback control mode.

Spotify rate limits are per app. The shared ncspot client ID from the quick setup is shared by every ncspot and spotatui user and is often rate limited; your own Spotify app gets a limit of its own. Run `spotatui --reconfigure-auth` and choose option 2, see [Connecting to Spotify](docs/installation.md#connecting-to-spotify).

### Deprecated Spotify API Features

As of November 2024, Spotify removed access to certain API endpoints for new applications. The following features **only work if your Spotify Developer application was created before November 27, 2024**:
Expand Down
23 changes: 13 additions & 10 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,16 @@ If you installed via a package manager (AUR, cargo, etc.), update through there

## Connecting to Spotify

spotatui needs to connect to Spotify's API. Instructions are shown when you first run the app.

1. Go to the [Spotify Dashboard](https://developer.spotify.com/dashboard/applications)
2. Click **Create an app**
3. Note your `Client ID` and `Client Secret`
4. Click **Edit Settings**
5. Add these Redirect URIs:
- `http://127.0.0.1:8888/callback` (API authentication)
- `http://127.0.0.1:8989/login` (native streaming)
6. Save and run `spotatui`
spotatui talks to Spotify's Web API through a Spotify app. The first run asks which app to use:

1. **The shared ncspot client ID.** No dashboard needed. Spotify counts every ncspot and spotatui user against this one app, so it is often rate limited: playlists load slowly or not at all, and the status bar says so.
2. **Your own Spotify app** (recommended). Spotify gives each app a rate limit of its own.

To use your own app:

1. Go to the [Spotify Dashboard](https://developer.spotify.com/dashboard/applications) and click **Create app**.
2. Add `http://127.0.0.1:8888/callback` to the Redirect URIs. The wizard asks for the port; 8888 is the default.
3. Copy the `Client ID`. No client secret is needed.
4. Run `spotatui --reconfigure-auth`, choose option 2, and paste the id. The browser opens once to sign in.

The wizard keeps the shared id as `fallback_client_id` in `client.yml`, for a login that fails on your own app. A `client.yml` from an older version, with the shared id as `client_id` and your app as `fallback_client_id`, is read the other way round: your app leads. Your app still has no login of its own, so the first launch after the upgrade runs on the shared id and the status bar says so; run `spotatui --reconfigure-auth` once, choose 2, and paste the id. Native streaming signs in on its own and needs no dashboard entry.
102 changes: 34 additions & 68 deletions src/core/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,6 @@ pub struct AuthenticatedClient {
/// diagnostic time — and nothing in the app ever said so.
#[derive(Debug, Clone)]
pub enum ClientIdNotice {
/// Authenticated as the shared ncspot app while the user also has their own
/// app configured. This is the *normal* outcome of the wizard's Option 2:
/// `run_auth_setup_wizard` writes ncspot as the primary `client_id` and the
/// user's own app as `fallback_client_id`, so "I set up my own app" does not
/// mean "I am using my own app".
SharedWhilePersonalConfigured { personal_client_id: String },
/// An earlier candidate had no usable session, so authentication moved on to
/// the next one.
FellBack {
Expand Down Expand Up @@ -455,7 +449,22 @@ async fn ensure_auth_token(
"Waiting for authorization callback on http://127.0.0.1:{}...\n",
auth_port
));
serve_spotify_callback(listener).await
// Bounded like the in-TUI login: a Redirect URI missing from the
// app's dashboard entry means the callback never comes.
match tokio::time::timeout(
std::time::Duration::from_secs(180),
serve_spotify_callback(listener),
)
.await
{
Ok(result) => result,
Err(_) => {
onboarding.info(
"No authorization callback within 3 minutes. Check the Redirect URI in the app's Spotify dashboard entry.",
);
Err(())
}
}
}
Err(()) => Err(()),
};
Expand Down Expand Up @@ -531,43 +540,18 @@ pub async fn try_load_spotify_silently(
}

/// Decide what (if anything) to tell the user about the app they just
/// authenticated as.
///
/// The subtlety this exists to get right: the setup wizard writes
/// `NCSPOT_CLIENT_ID` as the **primary** `client_id` for *both* of its options
/// and puts the user's own app in `fallback_client_id` (see
/// `ClientConfig::run_auth_setup_wizard`). So "landed on the shared app" is
/// `index == 0`, not a fallback — reading it the other way round reports the
/// healthy resilience path as a failure and stays silent in the case that
/// actually confused an issue #395 reporter.
/// authenticated as. The primary `client_id` is what the user configured
/// (`ClientConfig::prefer_own_app` puts their own app first), so only a
/// fall-through to a later candidate is worth a word.
fn client_id_notice_for(
index: usize,
requested_client_id: &str,
winning_client_id: &str,
candidates: &[String],
) -> Option<ClientIdNotice> {
if index > 0 {
return Some(ClientIdNotice::FellBack {
from_client_id: requested_client_id.to_string(),
to_client_id: winning_client_id.to_string(),
});
}

if winning_client_id != NCSPOT_CLIENT_ID {
// On a personal app, which is what the user would expect. Nothing to say.
return None;
}

// On the shared app. Worth mentioning only when the user has an app of their
// own configured and might believe they are using it.
candidates
.iter()
.find(|candidate| *candidate != NCSPOT_CLIENT_ID)
.map(
|personal_client_id| ClientIdNotice::SharedWhilePersonalConfigured {
personal_client_id: personal_client_id.clone(),
},
)
(index > 0).then(|| ClientIdNotice::FellBack {
from_client_id: requested_client_id.to_string(),
to_client_id: winning_client_id.to_string(),
})
}

async fn authenticate_candidates(
Expand Down Expand Up @@ -613,14 +597,13 @@ async fn authenticate_candidates(
match auth_result {
Ok(me) => {
validated_me = me;
client_id_notice =
client_id_notice_for(index, &requested_client_id, client_id, &client_candidates);
client_id_notice = client_id_notice_for(index, &requested_client_id, client_id);
if *client_id == NCSPOT_CLIENT_ID {
info!(
"Using ncspot shared client ID. If it breaks in the future, configure fallback_client_id in client.yml."
"Using the shared ncspot client ID; its Spotify rate limit is shared by every user. Run `spotatui --reconfigure-auth` and choose 2 to use your own app."
);
} else {
info!("Using fallback client ID {}", client_id);
info!("Using client ID {} (your own app)", client_id);
}
client_config.client_id = client_id.clone();
#[cfg(feature = "streaming")]
Expand Down Expand Up @@ -699,45 +682,28 @@ mod tests {

const PERSONAL_CLIENT_ID: &str = "0123456789abcdef0123456789abcdef";

/// The wizard's Option 2 writes ncspot as the PRIMARY id and the user's own
/// app as the fallback, so being on ncspot is `index == 0`. This is the case
/// an issue #395 reporter misread as "I'm running my own app".
#[test]
fn shared_client_id_with_a_personal_one_configured_is_reported() {
let candidates = vec![NCSPOT_CLIENT_ID.to_string(), PERSONAL_CLIENT_ID.to_string()];
let notice = client_id_notice_for(0, NCSPOT_CLIENT_ID, NCSPOT_CLIENT_ID, &candidates);
assert!(matches!(
notice,
Some(ClientIdNotice::SharedWhilePersonalConfigured { personal_client_id })
if personal_client_id == PERSONAL_CLIENT_ID
));
}

/// Option 1: the user only ever asked for the shared app. Nothing surprising.
#[test]
fn shared_client_id_alone_is_not_reported() {
let candidates = vec![NCSPOT_CLIENT_ID.to_string()];
assert!(client_id_notice_for(0, NCSPOT_CLIENT_ID, NCSPOT_CLIENT_ID, &candidates).is_none());
assert!(client_id_notice_for(0, NCSPOT_CLIENT_ID, NCSPOT_CLIENT_ID).is_none());
}

/// A hand-edited `client.yml` with a personal id as primary: the user is on
/// exactly the app they configured.
/// A personal id as primary with its own session: the user is on exactly
/// the app they configured.
#[test]
fn personal_client_id_as_primary_is_not_reported() {
let candidates = vec![PERSONAL_CLIENT_ID.to_string()];
assert!(client_id_notice_for(0, PERSONAL_CLIENT_ID, PERSONAL_CLIENT_ID, &candidates).is_none());
assert!(client_id_notice_for(0, PERSONAL_CLIENT_ID, PERSONAL_CLIENT_ID).is_none());
}

/// The resilience path: the shared app had no usable session, so the user's
/// own app took over. Reported as a fallback, naming both apps.
/// The user's own app had no session yet, so the shared app took over.
/// Reported as a fallback, naming both apps.
#[test]
fn falling_through_to_the_next_candidate_is_reported_as_a_fallback() {
let candidates = vec![NCSPOT_CLIENT_ID.to_string(), PERSONAL_CLIENT_ID.to_string()];
let notice = client_id_notice_for(1, NCSPOT_CLIENT_ID, PERSONAL_CLIENT_ID, &candidates);
let notice = client_id_notice_for(1, PERSONAL_CLIENT_ID, NCSPOT_CLIENT_ID);
assert!(matches!(
notice,
Some(ClientIdNotice::FellBack { from_client_id, to_client_id })
if from_client_id == NCSPOT_CLIENT_ID && to_client_id == PERSONAL_CLIENT_ID
if from_client_id == PERSONAL_CLIENT_ID && to_client_id == NCSPOT_CLIENT_ID
));
}

Expand Down
94 changes: 69 additions & 25 deletions src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ impl ClientConfig {
self.streaming_device_name = config_yml.streaming_device_name;
self.streaming_bitrate = config_yml.streaming_bitrate;
self.streaming_audio_cache = config_yml.streaming_audio_cache;
self.prefer_own_app();

Ok(false)
} else {
Expand All @@ -175,6 +176,20 @@ impl ClientConfig {
self.setup_version < AUTH_SETUP_VERSION
}

/// An older wizard wrote the shared id as primary and the user's own app as
/// the fallback; the own app leads, since its rate limit is the reason to
/// have one, and the shared id becomes the fallback.
fn prefer_own_app(&mut self) {
if self.client_id != NCSPOT_CLIENT_ID {
return;
}
if let Some(own_app) = self.fallback_client_id.take_if(|fallback| {
fallback != NCSPOT_CLIENT_ID && ClientConfig::validate_client_key(fallback).is_ok()
}) {
self.fallback_client_id = Some(std::mem::replace(&mut self.client_id, own_app));
}
}

pub fn reconfigure_auth(&mut self, onboarding: &dyn Onboarding) -> Result<()> {
self.run_auth_setup_wizard(onboarding)
}
Expand All @@ -199,49 +214,44 @@ impl ClientConfig {

fn run_auth_setup_wizard(&mut self, onboarding: &dyn Onboarding) -> Result<()> {
onboarding.info("\nClient setup options:\n");
onboarding
.info(" 1) Use ncspot client ID (quick setup, may break if Spotify revokes shared access)");
onboarding
.info(" 2) Use ncspot client ID + your own fallback app ID (recommended for resilience)");
onboarding.info(
" 1) Use the shared ncspot client ID (quick start; its Spotify rate limit is shared by every ncspot and spotatui user)",
);
onboarding.info(
" 2) Use your own Spotify app, with the shared ID as a fallback (recommended: a rate limit of your own)",
);

let setup_option = ClientConfig::get_setup_option(onboarding)?;

let (client_id, fallback_client_id) = match setup_option {
let (client_id, fallback_client_id, port) = match setup_option {
1 => {
onboarding.info("\nUsing ncspot redirect URI: http://127.0.0.1:8989/login");
(NCSPOT_CLIENT_ID.to_string(), None)
(NCSPOT_CLIENT_ID.to_string(), None, 8989)
}
2 => {
onboarding.info("\nCreate your fallback Spotify app:\n");
// The port comes first so the Redirect URI below names the real one.
let port = onboarding.prompt_line(&format!(
"\nEnter port of your app's redirect uri (default {}): \n",
DEFAULT_PORT
))?;
let port = port.trim().parse::<u16>().unwrap_or(DEFAULT_PORT);
onboarding.info("\nCreate your Spotify app:\n");
let instructions = [
"Go to https://developer.spotify.com/dashboard/applications",
"Click `Create app` and add your own name and description",
&format!(
"Add `http://127.0.0.1:{}/callback` to Redirect URIs",
DEFAULT_PORT
),
&format!("Add `http://127.0.0.1:{port}/callback` to Redirect URIs"),
];

for (number, item) in instructions.iter().enumerate() {
onboarding.info(&format!(" {}. {}", number + 1, item));
}

let fallback = ClientConfig::get_client_key_from_input("Fallback Client ID", onboarding)?;
(NCSPOT_CLIENT_ID.to_string(), Some(fallback))
let own_app = ClientConfig::get_client_key_from_input("Client ID", onboarding)?;
(own_app, Some(NCSPOT_CLIENT_ID.to_string()), port)
}
_ => unreachable!(),
};

let port = if setup_option == 1 {
8989
} else {
let port = onboarding.prompt_line(&format!(
"\nEnter port of fallback redirect uri (default {}): \n",
DEFAULT_PORT
))?;
port.trim().parse::<u16>().unwrap_or(DEFAULT_PORT)
};

self.client_id = client_id;
self.fallback_client_id = fallback_client_id;
self.client_secret = String::new();
Expand Down Expand Up @@ -369,6 +379,40 @@ mod tests {
use super::*;
use crate::core::test_helpers::ScriptedOnboarding;

#[test]
fn prefer_own_app_swaps_a_shared_primary_with_a_personal_fallback() {
let own = "0123456789abcdef0123456789abcdef";
let mut config = ClientConfig::new();
config.client_id = NCSPOT_CLIENT_ID.to_string();
config.fallback_client_id = Some(own.to_string());

config.prefer_own_app();

assert_eq!(config.client_id, own);
assert_eq!(config.fallback_client_id.as_deref(), Some(NCSPOT_CLIENT_ID));
}

#[test]
fn prefer_own_app_leaves_other_layouts_alone() {
let own = "0123456789abcdef0123456789abcdef";
for (primary, fallback) in [
(NCSPOT_CLIENT_ID, None),
(NCSPOT_CLIENT_ID, Some(NCSPOT_CLIENT_ID)),
(NCSPOT_CLIENT_ID, Some("not-a-client-id")),
(own, Some(NCSPOT_CLIENT_ID)),
(own, None),
] {
let mut config = ClientConfig::new();
config.client_id = primary.to_string();
config.fallback_client_id = fallback.map(str::to_string);

config.prefer_own_app();

assert_eq!(config.client_id, primary);
assert_eq!(config.fallback_client_id.as_deref(), fallback);
}
}

#[test]
fn setup_option_accepts_a_valid_choice_after_garbage() {
let onboarding = ScriptedOnboarding::with_answers(&["x", "2"]);
Expand All @@ -387,10 +431,10 @@ mod tests {
let valid = "0123456789abcdef0123456789abcdef";
let onboarding = ScriptedOnboarding::with_answers(&["tooshort", valid]);
assert_eq!(
ClientConfig::get_client_key_from_input("Fallback Client ID", &onboarding).unwrap(),
ClientConfig::get_client_key_from_input("Client ID", &onboarding).unwrap(),
valid
);
assert!(onboarding.saw("\nEnter your Fallback Client ID: \n"));
assert!(onboarding.saw("\nEnter your Client ID: \n"));
}

#[test]
Expand Down
Loading
Loading