Spanfile / Ferrispot

A wrapper for the Spotify Web API
Apache License 2.0
1 stars 1 forks source link

Blocking API #1

Closed ShayBox closed 1 year ago

ShayBox commented 1 year ago

I'd like to use this over rspotify as this doesn't make PKCE a completely separate struct, allowing me to swap at runtime.
But I need blocking, so this is just me asking 😄

Unrelated but would you consider a way to automatically handle the Spotify callback via a tiny_http server and webbrowser?
This is my existing code using rspotify, it could be an optional feature.
image

Spanfile commented 1 year ago

Thank you for the post, I didn't think my library would attract attention any time soon :sweat_smile:

I haven't given the blocking API much though at all since, like the readme says, right now I'm developing this library for my own needs. I have a rough idea of how the blocking API could work alongside the async API, but it's going to require further thought and experimentation. I can start working on it, no problems there! Expect breaking changes :smile:

As for the web browser and HTTP server inclusion, I don't think they fall into the scope of the library. As much as the library could do it, so can you in your own application. I don't want to mandate a certain method for the authorization flow; the library simply gives you an authorization URL and expects back the authorization code and state. It's up to you to handle the in-between.

Spanfile commented 1 year ago

I've just published version 0.2.0 that contains the first blocking API, available through the crate feature sync. The build methods on the Spotify client builders have been renamed to build_async, and they've gained build_sync methods that return synchronous clients. All the clients have new type signatures that lets them be generic over the underlying HTTP client, so for convenience there are type aliases for each kind of client. From there on their usage is (hopefully) unchanged.

There should be documentation for everything, soon as docs.rs updates. The synchronous endpoints are essentially copypastes, and preliminary tests show they work identically to the asynchronous ones. I could've of course missed something, so if there's any issues, do let me know!

ShayBox commented 1 year ago

So far the library works as intended with blocking, but I'm not sure what "State" is in the examples for incomplete_auth_code_client.finalize, there's only a code query from the callback

I'm also struggling to optionally use PKCE at runtime, but that's likely a lack of Rust knowledge,

let spotify_client;
if config.spotify.pkce {
    spotify_client = SpotifyClientBuilder::new(&config.spotify.client_id).build_sync()
} else {
    spotify_client = SpotifyClientBuilder::new(&config.spotify.client_id)
        .client_secret(&config.spotify.client_secret)
        .build_sync()
        .into_report()
        .change_context(VRCError::Spotify)
        .attach_printable("Failed to build Spotify client")?;
}

but SpotifyClient and SpotifyClientWithSecret aren't compatible, any ideas/insights?

Spanfile commented 1 year ago

When you retrieve the authorization URL from an incomplete authorization code client, you can check that the state parameter is in the query parameters: https://accounts.spotify.com/authorize?response_type=code&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&client_id=...&state=i8njcOCODJkPs0IE. The callback URL will contain that same state parameter: https://localhost/callback?code=...&state=i8njcOCODJkPs0IE. The only scenario when Spotify doesn't return the state is when it wasn't given one in the first place, but the library should always include it. Can you show if the state is missing from the authorization URL for some reason?

SpotifyClient and SpotifyClientWithSecret are purposefully two distinct types since the latter implements the unscoped endpoint traits and the former doesn't. If you only need one client in your application, you can go straight to an authorization code client:

let incomplete_auth_code_client = if config.spotify.pkce {
    let spotify_client = SpotifyClientBuilder::new(&config.spotify.client_id).build_sync();
    spotify_client
        .authorization_code_client_with_pkce("callback")
        .build()
} else {
    let spotify_client = SpotifyClientBuilder::new(&config.spotify.client_id)
        .client_secret(&config.spotify.client_secret)
        .build_sync();
    spotify_client
        .authorization_code_client("callback")
        .build()
};

// the Spotify clients inside the if-expression are dropped but that's okay, the final auth
// code client can access all unscoped and scoped endpoints

let auth_url = incomplete_auth_code_client.get_authorize_url();
...
let auth_code_client = incomplete_auth_code_pkce_client
    .finalize(code, state);

If you do need to keep the original Spotify clients (which is only if you need to create more simultaneous auth code clients for different users), then I suggest grouping them in one common enum type, for example:

enum SpotifyClients {
    NoSecret(SyncSpotifyClient),
    Secret(SyncSpotifyClientWithSecret),
}
ShayBox commented 1 year ago

Today I'm getting a state query parameter, 10 hours ago I wasn't... I don't know why.
That code works, thank you!

EDIT: This is my final method using tiny_http and webbrowser, should anyone want to use it

fn get_query_from_user(url: &str, uri: &str) -> Result<(String, String), VRCError> {
    match webbrowser::open(url) {
        Ok(ok) => ok,
        Err(why) => eprintln!(
            "Error when trying to open an URL in your browser: {:?}. \
             Please navigate here manually: {}",
            why, url
        ),
    }

    let addr = uri.replace("http://", "").replace("https://", "");
    let server = Server::http(addr).expect("Failed to bind server");
    let request = match server.recv() {
        Ok(rq) => rq,
        Err(e) => panic!("Failed to get request: {e}"),
    };

    let request_url = uri.to_owned() + request.url();
    let parsed_url = Url::parse(&request_url)
        .into_report()
        .change_context(VRCError::Url)?;

    let header = Header::from_bytes(&b"Content-Type"[..], &b"text/html"[..]).unwrap();
    let mut response;
    if parsed_url.query_pairs().count() == 2 {
        response = Response::from_string(
            "<h1>You may close this tab</h1> \
                <script>window.close()</script>",
        );
    } else {
        response = Response::from_string("<h1>An error has occured</h1>");
    }

    response.add_header(header);
    request.respond(response).expect("Failed to send response");

    let Some(code) = parsed_url.query_pairs().next() else {
        bail!(VRCError::None)
    };
    let Some(state) = parsed_url.query_pairs().nth(1) else {
        bail!(VRCError::None)
    };

    Ok((code.1.into(), state.1.into()))
}
ShayBox commented 1 year ago

Hmm, It seems using PKCE refresh_access_token blocks forever, it never finishes or panics

let authorize_url = incomplete_auth_code_client.get_authorize_url();
println!("AUTH: {}", authorize_url);

let (code, state) = get_query_from_user(&authorize_url, &config.spotify.callback_uri)?;
println!("CODE: {}", code);
println!("STATE: {}", state);

let user_client = incomplete_auth_code_client
    .finalize(code.trim(), state.trim())
    .into_report()
    .change_context(VRCError::Spotify)?;

println!("Pre refresh_access_token");
user_client.refresh_access_token().unwrap();
println!("Post refresh_access_token");
AUTH: https://accounts.spotify.com/authorize?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A2345&client_id=5e240a5b68bf49ad80163f9877b82f97&state=HmMoJLbncOflZ9Je&show_dialog=false&code_challenge_method=S256&code_challenge=2lJ7eMoGyCU95NFIx11Aj_gG5RC5ykLsWBhSG4GpEA4
CODE: AQCpz35Ui-ZWWg_3BCXg9JcpfyliBBss3PTpSOwZjTk9Es5osmx2mgPmk5K8W2wd6Yr6t2fPttnYRlp4PELbxyVxp-IBFnPEHR7n8yVvcBR1Z1CW36-Y-cEe8Xx3Tc8vrD119dvcEZrSIM83-wA7C79bq6WwfjNiMVD1SMuTIljUxXtIF2GyttjdK9sIfqG2FUZpaDlw0U7r53B9rn4rMPyx0_3s-S2loXZf7Q
STATE: HmMoJLbncOflZ9Je
Pre refresh_access_token

But it works without PKCE

EDIT: I found where to provide scopes, my bad but even with .scopes([Scope::UserReadPlaybackState]),

playback_state
    .currently_playing_item()
    .public_playing_item()

still returns None

Here's my full code

Spanfile commented 1 year ago

I've confirmed that the client freezing when refreshing an access token with PKCE is a bug, and have pushed an update. I'll release a patch release soon to crates.io. 0.2.1 is now up.

If .currently_playing_item() returns None, there is nothing playing in the user's account. If .public_playing_item() returns None, it most likely means there is something playing but the user has a private session enabled, so the Spotify API won't tell what the playing item is (hence the name public_playing_item).

Although, it's always a possibility my model doesn't match Spotify's response so the public playing item isn't deserialized properly, so it is left as None during the deserialization. I'd have to see what Spotify exactly responds to confirm. The library logs the API call response and deserialized body at the trace level.

ShayBox commented 1 year ago

Here's the tracing output, hopefully it helps output.txt

EDIT: The first currently_playing_item isn't None but public_playing_item and public_playing_track are None

Spanfile commented 1 year ago

Seems Spotify just doesn't respond with a public playing item. I'll have to see if something similar crops up elsewhere to try and figure out if my model is incorrect.

ShayBox commented 1 year ago

If it helps, this is how I get it with rspotify and it successfully gets the song https://github.com/ramsayleung/rspotify/blob/2fe50355312d90d9b93d3731ecdc1dda3c569a03/src/clients/oauth.rs#L499

EDIT: This makes no sense, I re-ran the code today and it's working again, I literally haven't changed anything, I tested it last night and it wasn't working, this morning it's working...

EDIT: OH I figured it out! It only works if it's a public PLAYLIST, my liked songs are private and don't work, a different playlist works

ShayBox commented 1 year ago

I'm adding refresh token storing and I can't access SyncClient because it's private, to send an incomplete_auth_code_client to a function since both pkce/non-pkce result in the same code, is there a better way to do this or can Sync/AsyncClient be made public?
https://pastebin.com/bP2JikJj

Spanfile commented 1 year ago

The internal AsyncClient and SyncClient traits are hidden on purpose, since their functionality isn't relevant to the library consumer. To alleviate it, each client (including their builders and incomplete clients) have corresponding type aliases that let you use the structs without the generic type parameter. The type alias for AuthorizationCodeUserClient<SyncClient> is SyncAuthorizationCodeUserClient.

ShayBox commented 1 year ago

That worked, thanks 👍

I found the endpoint I've been using me/player/currently-playing in Ferrispot
https://github.com/Spanfile/Ferrispot/blob/97b7e98afd9f8e0968d5d59ff993101b38aaf48a/src/client.rs#L157
But I don't see anywhere that it's used in the library, so I think public_playing_item uses a different endpoint that only reports when playing from public playlists

EDIT: I found what uses it

let Some(track) = user_client
    .currently_playing_track()
    .into_report()
    .change_context(VrcError::Spotify)?
else {
    continue;
};

let Some(item) = track.public_playing_item() else {
    continue;
};

let PlayingType::Track(full_track) = item.item() else {
    continue;
};

user_client.currently_playing_track() seems to use me/player/currently-playing though it still only works for public playlists, I don't know if there's a difference between that and playback_state.currently_playing_item()

EDIT: I've updated my program to use Ferrispot https://github.com/ShayBox/VRC-OSC

Spanfile commented 1 year ago

.playback_state() returns a PlaybackState struct which is a superset of CurrentlyPlayingItem returned by .currently_playing_track() (renamed to .currently_playing_item() in the upcoming version, you can check out what's to come in the changelog). In addition to the playing item, the playback state also includes information about the device that's playing and the player's shuffle and repeat states.

It's great to see my library is useful to someone else than just me :blush: The sync API seems to be working fine now, so I'll close this issue. If you need more help with something, or maybe need some endpoints implemented, feel free to open a new issue!