spotify_lyrics/
lib.rs

1#[macro_use]
2extern crate tracing;
3
4use std::{
5    sync::Arc,
6    time::{Duration, SystemTime, UNIX_EPOCH},
7};
8
9use anyhow::{bail, Result};
10#[cfg(feature = "is_sync")]
11use reqwest::blocking::Client;
12use reqwest::cookie::Jar;
13#[cfg(not(feature = "is_sync"))]
14use reqwest::Client;
15use serde::{Deserialize, Serialize};
16use serde_with::{serde_as, DisplayFromStr};
17use url::Url;
18
19const BASE_URL: &str = "https://spclient.wg.spotify.com";
20const COOKIE_DOMAIN: &str = ".spotify.com";
21const COOKIE_NAME: &str = "sp_dc";
22const TOKEN_URL: &str = "https://open.spotify.com/get_access_token";
23const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.3";
24/* ^ This could be fetched from a list at runtime but I don't suspect this will need to be changed ^ */
25
26lazy_static::lazy_static! {
27    static ref COOKIE_URL: Url = format!("https://open{COOKIE_DOMAIN}").parse().unwrap();
28}
29
30#[cfg(feature = "browser")]
31#[derive(Debug)]
32pub enum Browser {
33    All,
34    Brave,
35    #[cfg(target_os = "linux")]
36    Cachy,
37    Chrome,
38    Chromium,
39    Edge,
40    Firefox,
41    #[cfg(target_os = "windows")]
42    InternetExplorer,
43    LibreWolf,
44    Opera,
45    OperaGX,
46    #[cfg(target_os = "macos")]
47    Safari,
48    Vivaldi,
49}
50
51#[derive(Clone, Debug, Default)]
52pub struct SpotifyLyrics {
53    auth:   Authorization,
54    client: Client,
55}
56
57impl SpotifyLyrics {
58    /// Manually supply your own cookie
59    pub fn from_cookie(cookie: &str) -> Result<Self> {
60        let jar = Arc::new(Jar::default());
61        jar.add_cookie_str(cookie, &COOKIE_URL);
62
63        let client = Client::builder()
64            .cookie_store(true)
65            .cookie_provider(jar)
66            .user_agent(USER_AGENT)
67            .build()?;
68
69        Ok(Self {
70            client,
71            ..Default::default()
72        })
73    }
74
75    /// Try to get the cookie from the users web browser
76    #[cfg(feature = "browser")]
77    pub fn from_browser(browser: Browser) -> Result<Self> {
78        use rookie::common::enums::CookieToString;
79
80        let get_cookies = match browser {
81            Browser::All => rookie::load,
82            Browser::Brave => rookie::brave,
83            #[cfg(target_os = "linux")]
84            Browser::Cachy => rookie::cachy,
85            Browser::Chrome => rookie::chrome,
86            Browser::Chromium => rookie::chromium,
87            Browser::Edge => rookie::edge,
88            Browser::Firefox => rookie::firefox,
89            #[cfg(target_os = "windows")]
90            Browser::InternetExplorer => rookie::internet_explorer,
91            Browser::LibreWolf => rookie::librewolf,
92            Browser::Opera => rookie::opera,
93            Browser::OperaGX => rookie::opera_gx,
94            #[cfg(target_os = "macos")]
95            Browser::Safari => rookie::safari,
96            Browser::Vivaldi => rookie::vivaldi,
97        };
98
99        let domains = Some(vec![COOKIE_DOMAIN]);
100        let cookies = get_cookies(domains)?;
101        let cookie = cookies
102            .into_iter()
103            .filter(|cookie| cookie.name == COOKIE_NAME)
104            .collect::<Vec<_>>()
105            .to_string();
106
107        Self::from_cookie(&cookie)
108    }
109
110    #[maybe_async::maybe_async]
111    pub async fn refresh_authorization(&mut self) -> Result<()> {
112        let response = self.client.get(TOKEN_URL).send().await?;
113        self.auth = response.json().await?;
114
115        Ok(())
116    }
117
118    #[maybe_async::maybe_async]
119    pub async fn get_authorization(&mut self) -> Result<Authorization> {
120        let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?;
121        let expiration = Duration::from_millis(self.auth.expiration_ms);
122        if current_time > expiration {
123            info!("Refreshing authorization");
124            self.refresh_authorization().await?;
125        };
126
127        Ok(self.auth.clone())
128    }
129
130    #[maybe_async::maybe_async]
131    pub async fn get_color_lyrics(&mut self, track_id: &str) -> Result<ColorLyrics> {
132        let url = format!("{BASE_URL}/color-lyrics/v2/track/{track_id}?format=json");
133        let authorization = self.get_authorization().await?;
134        let access_token = format!("Bearer {}", authorization.access_token);
135        let response = self
136            .client
137            .get(url)
138            .header("Authorization", access_token)
139            .header("App-Platform", "WebPlayer")
140            .send()
141            .await?;
142
143        let status = response.status();
144        if !status.is_success() {
145            bail!("Couldn't get color lyrics: {status}")
146        };
147
148        Ok(response.json().await?)
149    }
150}
151
152/* Please feel free to create an issue or pull request to expand as needed */
153
154#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct Authorization {
157    pub client_id:     String,
158    pub access_token:  String,
159    #[serde(rename = "accessTokenExpirationTimestampMs")]
160    pub expiration_ms: u64,
161    pub is_anonymous:  bool,
162}
163
164#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct ColorLyrics {
167    pub lyrics:            Lyrics,
168    pub colors:            Colors,
169    pub has_vocal_removal: bool,
170}
171
172#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct Lyrics {
175    pub sync_type:             String,
176    pub lines:                 Vec<Line>,
177    pub provider:              String,
178    pub provider_lyrics_id:    String,
179    pub provider_display_name: String,
180    pub sync_lyrics_uri:       String,
181    pub is_dense_typeface:     bool,
182    pub alternatives:          Vec<String>,
183    pub language:              String,
184    pub is_rtl_language:       bool,
185    pub fullscreen_action:     String,
186    pub show_upsell:           bool,
187}
188
189#[serde_as]
190#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct Line {
193    #[serde_as(as = "DisplayFromStr")]
194    pub start_time_ms: u64,
195    pub words:         String,
196    pub syllables:     Vec<String>,
197    #[serde_as(as = "DisplayFromStr")]
198    pub end_time_ms:   u64,
199}
200
201#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct Colors {
204    pub background:     i64,
205    pub text:           i64,
206    pub highlight_text: i64,
207}