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";
24lazy_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 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 #[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#[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}