ramsayleung / rspotify

Spotify Web API SDK implemented on Rust
MIT License
632 stars 121 forks source link

queryArtistOverview / artist stats (monthly listeners) #443

Open brandonros opened 10 months ago

brandonros commented 10 months ago

Is your feature request related to a problem? Please describe.

At the moment the API does not provide a way to achieve the same level of functionality/data the official Spotify app provides.

Describe the solution you'd like

Add some sort of wrapper around this API endpoint: https://api-partner.spotify.com/pathfinder/v1/query?operationName=queryArtistOverview&variables={"uri":"spotify:artist:ARTIST_ID","locale":"","includePrerelease":true}&extensions={"persistedQuery":{"version":1,"sha256Hash":"79a4a9d7c3a3781d801e62b62ef11c7ee56fce2626772eb26cd20c69f84b3f49"}}

Describe alternatives you've considered

None.

Additional context

https://github.com/search?q=spotify+queryArtistOverview&type=code

It returns a ton of data, such as:

"stats": {
                "followers": 606420,
                "monthlyListeners": 5744472,
                "worldRank": 0,
                "topCities": {
                    "items": [
                        {
                            "numberOfListeners": 142247,
                            "city": "Quezon City",
                            "country": "PH",
                            "region": "00"
                        },
                        {
                            "numberOfListeners": 97895,
                            "city": "Sydney",
                            "country": "AU",
                            "region": "NSW"
                        },
                        {
                            "numberOfListeners": 97663,
                            "city": "Jakarta",
                            "country": "ID",
                            "region": "JK"
                        },
                        {
                            "numberOfListeners": 87974,
                            "city": "São Paulo",
                            "country": "BR",
                            "region": "SP"
                        },
                        {
                            "numberOfListeners": 81795,
                            "city": "Melbourne",
                            "country": "AU",
                            "region": "VIC"
                        }
                    ]
                }
            },
brandonros commented 10 months ago

My guess is partner-api is different than web API which this package aims to cover and we shouldn't/won't pick this feature up.

brandonros commented 10 months ago

in case anybody wants this for fun to see how hipster their music tastes are in the mean time as a stop gap:

use std::{collections::HashSet, sync::Arc};
use serde::Deserialize;
use futures::stream::TryStreamExt;
use futures_util::lock::Mutex;
use rspotify::{prelude::*, scopes, AuthCodeSpotify, Credentials, OAuth};
use reqwest::{Url, StatusCode};

#[derive(Deserialize)]
pub struct Stats {
    #[serde(rename = "monthlyListeners")]
    pub monthly_listeners: Option<i64>,
}

#[derive(Deserialize)]
pub struct ArtistUnion {
    pub stats: Stats,
}

#[derive(Deserialize)]
struct Data {
    #[serde(rename = "artistUnion")]
    pub artist_union: ArtistUnion,
}

#[derive(Deserialize)]
struct ResponseRoot {
    pub data: Data
}

async fn get_artist_monthly_listeners(artist_id: &str) -> Option<i64> {
    let base_url = "https://api-partner.spotify.com/pathfinder/v1/query";
    let mut url = Url::parse(base_url).unwrap();
    let params = [
        ("operationName", "queryArtistOverview"),
        ("variables", &format!(r#"{{"uri":"{}","locale":"","includePrerelease":true}}"#, artist_id)),
        ("extensions", r#"{"persistedQuery":{"version":1,"sha256Hash":"79a4a9d7c3a3781d801e62b62ef11c7ee56fce2626772eb26cd20c69f84b3f49"}}"#),
    ];
    url.query_pairs_mut().extend_pairs(&params);
    let spotify_access_token = std::env::var("SPOTIFY_ACCESS_TOKEN").unwrap(); // can't use rspotify auth?
    let client = reqwest::Client::new();
    let response = client.get(url)
        .header("authority", "api-partner.spotify.com")
        .header("accept", "application/json")
        .header("accept-language", "en")
        .header("app-platform", "WebPlayer")
        .header("authorization", &format!("Bearer {spotify_access_token}"))
        .header("content-type", "application/json;charset=UTF-8")
        .header("dnt", "1")
        .header("origin", "https://open.spotify.com")
        .header("referer", "https://open.spotify.com/")
        .header("sec-ch-ua", "\"Chromium\";v=\"118\", \"Google Chrome\";v=\"118\", \"Not=A?Brand\";v=\"99\"")
        .header("sec-ch-ua-mobile", "?0")
        .header("sec-ch-ua-platform", "\"macOS\"")
        .header("sec-fetch-dest", "empty")
        .header("sec-fetch-mode", "cors")
        .header("sec-fetch-site", "same-site")
        .header("sec-gpc", "1")
        .header("spotify-app-version", "1.2.24.636.ga951e261")
        .header("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36")
        .send()
        .await.unwrap();
    assert!(response.status() == StatusCode::OK);
    let response_body: ResponseRoot = response.json().await.unwrap();
    let monthly_listeners = response_body.data.artist_union.stats.monthly_listeners;
    monthly_listeners
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    // init logger
    env_logger::init();
    // build spotify client
    let creds = Credentials::from_env().unwrap();
    let oauth = OAuth::from_env(scopes!("user-library-read")).unwrap();
    let spotify = AuthCodeSpotify::new(creds, oauth);
    let url = spotify.get_authorize_url(false).unwrap();
    spotify.prompt_for_token(&url).await.unwrap();
    // get current user saved tracks
    let stream = spotify.current_user_saved_tracks(None);
    // concurrently build unique hash_set of artists from current_user_saved_tracks
    let hash_set = Arc::new(Mutex::new(HashSet::new()));
    let hash_set_clone = hash_set.clone();
    stream
        .try_for_each_concurrent(10, move |item| {
            // Since we already cloned it before, we can just use it here.
            let local_hash_set = hash_set_clone.clone();
            async move {
                let mut guard = local_hash_set.lock().await;
                for artist in item.track.artists {
                    guard.insert((artist.name, artist.id.unwrap()));
                }
                Ok(())
            }
        })
        .await
        .unwrap();
    // iterate current_user_saved_tracks
    let guard = hash_set.lock().await;
    for (artist_name, artist_id) in &*guard {
        // get stats
        let monthly_listeners = get_artist_monthly_listeners(&format!("{}", artist_id)).await;
        println!("\"{artist_name}\",\"{artist_id}\",\"{monthly_listeners:?}\"");
    }
}