1pub mod manager {
2 use super::token::PandoraTokenTracker;
3 use crate::{
4 protocol::tracks::{
5 LoadResult, PlaylistData, PlaylistInfo, SearchResult, Track, TrackInfo,
6 },
7 sources::{SourcePlugin, playable_track::BoxedTrack},
8 };
9 use async_trait::async_trait;
10 use regex::Regex;
11 use reqwest::header::{ACCEPT, CONTENT_TYPE, USER_AGENT};
12 use serde_json::{Value, json};
13 use std::sync::{Arc, OnceLock};
14 use tracing::{debug, warn};
15 const BASE_URL: &str = "https://www.pandora.com";
16 const ENDPOINT_SEARCH: &str = "/api/v3/sod/search";
17 const ENDPOINT_ANNOTATE: &str = "/api/v4/catalog/annotateObjects";
18 const ENDPOINT_DETAILS: &str = "/api/v4/catalog/getDetails";
19 const ENDPOINT_PLAYLIST_TRACKS: &str = "/api/v7/playlists/getTracks";
20 const ENDPOINT_ARTIST_ALL_TRACKS: &str = "/api/v4/catalog/getAllArtistTracksWithCollaborations";
21 fn url_regex() -> &'static Regex {
22 static RE: OnceLock<Regex> = OnceLock::new();
23 RE.get_or_init(|| {
24 Regex::new(r"https?://(?:www\.)?pandora\.com/(?:playlist/(?P<id>PL:[\d:]+)|artist/(?:[\w\-]+/)*(?P<id2>(?:TR|AL|AR)[A-Za-z0-9]+))").unwrap()
25 })
26 }
27 pub struct PandoraSource {
28 client: Arc<reqwest::Client>,
29 token_tracker: Arc<PandoraTokenTracker>,
30 search_limit: usize,
31 }
32 impl PandoraSource {
33 pub fn new(
34 config: Option<crate::config::PandoraConfig>,
35 client: Arc<reqwest::Client>,
36 ) -> Result<Self, String> {
37 let (s_limit, csrf_override) = config
38 .map(|c| (c.search_limit, c.csrf_token))
39 .unwrap_or((10, None));
40 let token_tracker = Arc::new(PandoraTokenTracker::new(client.clone(), csrf_override));
41 token_tracker.clone().init();
42 Ok(Self {
43 client,
44 token_tracker,
45 search_limit: s_limit,
46 })
47 }
48 async fn api_request(&self, path: &str, body: Value) -> Option<Value> {
49 for is_retry in [false, true] {
50 let tokens = self.token_tracker.get_tokens().await?;
51 let url = format!("{BASE_URL}{path}");
52 let resp = match self
53 .base_request(self.client.post(&url))
54 .header(ACCEPT, "application/json, text/plain, */*")
55 .header(CONTENT_TYPE, "application/json")
56 .header("origin", BASE_URL)
57 .header("X-Csrftoken", &tokens.csrf_token_parsed)
58 .header("X-Authtoken", &tokens.auth_token)
59 .header("Cookie", &tokens.csrf_token_raw)
60 .json(&body)
61 .send()
62 .await
63 {
64 Ok(r) => r,
65 Err(e) => {
66 warn!("Pandora API request error for {path}: {e}");
67 return None;
68 }
69 };
70 let status = resp.status();
71 let body_res: Value = resp.json::<Value>().await.ok()?;
72 let error_code = body_res
73 .get("errorCode")
74 .and_then(|v| v.as_i64())
75 .unwrap_or(-1);
76 let error_string = body_res
77 .get("errorString")
78 .and_then(|v| v.as_str())
79 .unwrap_or("");
80 let is_error = !status.is_success()
81 || (body_res.get("errorCode").is_some()
82 && !body_res.get("errorCode").unwrap().is_null());
83 if is_error {
84 if !is_retry
85 && (error_code == 1001 || error_string.contains("could not be validated"))
86 {
87 debug!(
88 "Auth token error (code: {error_code}, message: {error_string}), refreshing..."
89 );
90 self.token_tracker.force_refresh().await;
91 continue;
92 }
93 warn!(
94 "Pandora API error for {path}: status {status}, code {error_code}, message {error_string}"
95 );
96 return None;
97 }
98 return Some(body_res);
99 }
100 None
101 }
102 fn get_artwork_url(&self, node: &Value) -> Option<String> {
103 if let Some(icon) = node.get("icon").filter(|v| !v.is_null())
104 && let Some(art_id) = icon
105 .get("artId")
106 .and_then(|v| v.as_str())
107 .filter(|s| !s.is_empty())
108 {
109 return Some(format!(
110 "https://content-images.p-cdn.com/{art_id}_1080W_1080H.jpg"
111 ));
112 }
113 if let Some(thor_layers) = node
114 .get("thorLayers")
115 .and_then(|v| v.as_str())
116 .filter(|s| !s.is_empty())
117 {
118 if let Some(grid) = thor_layers.strip_prefix("_;grid") {
119 let encoded = urlencoding::encode(grid);
120 return Some(format!(
121 "https://dyn-images.p-cdn.com/?l=_;grid{encoded}&w=1080&h=1080"
122 ));
123 }
124 return Some(format!(
125 "https://content-images.p-cdn.com/{thor_layers}_1080W_1080H.jpg"
126 ));
127 }
128 None
129 }
130 fn map_track(&self, track: &Value, annotations: &Value) -> Option<Track> {
131 let title = track.get("name").and_then(|v| v.as_str())?;
132 let author = track
133 .get("artistName")
134 .and_then(|v| v.as_str())
135 .unwrap_or("Unknown Artist");
136 let duration = track.get("duration").and_then(|v| v.as_i64()).unwrap_or(0) * 1000;
137 if duration == 0 {
138 return None;
139 }
140 let id = track.get("pandoraId").and_then(|v| v.as_str())?;
141 let isrc = track
142 .get("isrc")
143 .and_then(|v| v.as_str())
144 .map(|s| s.to_owned());
145 let album_id = track.get("albumId").and_then(|v| v.as_str()).unwrap_or("");
146 let album = annotations.get(album_id).filter(|v| !v.is_null());
147 let album_name = album.and_then(|v| v.get("name")).and_then(|v| v.as_str());
148 let album_url = album
149 .and_then(|v| v.get("shareableUrlPath"))
150 .and_then(|v| v.as_str());
151 let artist_id = track.get("artistId").and_then(|v| v.as_str()).unwrap_or("");
152 let artist = annotations.get(artist_id).filter(|v| !v.is_null());
153 let artist_url = artist
154 .and_then(|v| v.get("shareableUrlPath"))
155 .and_then(|v| v.as_str());
156 let artist_artwork_url = artist.and_then(|v| self.get_artwork_url(v));
157 let original_url = track
158 .get("shareableUrlPath")
159 .and_then(|v| v.as_str())
160 .map(|p| format!("{BASE_URL}{p}"));
161 let artwork_url = self.get_artwork_url(track);
162 let info = TrackInfo {
163 title: title.to_owned(),
164 author: author.to_owned(),
165 length: duration as u64,
166 identifier: id.to_owned(),
167 is_stream: false,
168 uri: original_url,
169 artwork_url,
170 isrc,
171 source_name: "pandora".to_owned(),
172 is_seekable: true,
173 position: 0,
174 };
175 let mut t = Track::new(info);
176 t.plugin_info = json!({
177 "albumName": album_name,
178 "albumUrl": album_url.map(|p| format!("{BASE_URL}{p}")),
179 "artistUrl": artist_url.map(|p| format!("{BASE_URL}{p}")),
180 "artistArtworkUrl": artist_artwork_url,
181 "previewUrl": null,
182 "isPreview": false
183 });
184 Some(t)
185 }
186 fn build_annotate_request(&self, ids: &[String]) -> Value {
187 json!({ "pandoraIds": ids })
188 }
189 fn find_by_url_suffix(&self, tail: &str, annotations: &Value) -> Value {
190 if let Some(obj) = annotations.as_object() {
191 for value in obj.values() {
192 if let Some(path) = value.get("shareableUrlPath").and_then(|v| v.as_str())
193 && path.ends_with(&format!("/{}", tail))
194 {
195 return value.clone();
196 }
197 if let Some(slug) = value.get("slugPlusPandoraId").and_then(|v| v.as_str())
198 && (slug.ends_with(tail) || slug.contains(tail))
199 {
200 return value.clone();
201 }
202 }
203 }
204 Value::Null
205 }
206 async fn fetch_track(&self, id: &str) -> LoadResult {
207 let data = match self
208 .api_request(ENDPOINT_DETAILS, json!({ "pandoraId": id }))
209 .await
210 {
211 Some(d) => d,
212 None => return LoadResult::Empty {},
213 };
214 let annotations = data.get("annotations").unwrap_or(&Value::Null);
215 let track = self.find_by_url_suffix(id, annotations);
216 if track.is_null() {
217 return LoadResult::Empty {};
218 }
219 self.map_track(&track, annotations)
220 .map(LoadResult::Track)
221 .unwrap_or(LoadResult::Empty {})
222 }
223 async fn get_album(&self, id: &str) -> LoadResult {
224 let data = match self
225 .api_request(ENDPOINT_DETAILS, json!({ "pandoraId": id }))
226 .await
227 {
228 Some(d) => d,
229 None => return LoadResult::Empty {},
230 };
231 let annotations = data.get("annotations").unwrap_or(&Value::Null);
232 let album_node = self.find_by_url_suffix(id, annotations);
233 if album_node.is_null() {
234 return LoadResult::Empty {};
235 }
236 let name = album_node
237 .get("name")
238 .and_then(|v| v.as_str())
239 .unwrap_or("Unknown Album");
240 let mut tracks = Vec::new();
241 if let Some(track_ids) = album_node.get("tracks").and_then(|v| v.as_array()) {
242 for tid in track_ids.iter().filter_map(|v| v.as_str()) {
243 let t_node = annotations.get(tid).unwrap_or(&Value::Null);
244 if !t_node.is_null()
245 && let Some(t) = self.map_track(t_node, annotations)
246 {
247 tracks.push(t);
248 }
249 }
250 }
251 let url = album_node
252 .get("shareableUrlPath")
253 .and_then(|v| v.as_str())
254 .map(|p| format!("{BASE_URL}{p}"));
255 let artwork = self.get_artwork_url(&album_node);
256 LoadResult::Playlist(PlaylistData {
257 info: PlaylistInfo {
258 name: name.to_owned(),
259 selected_track: -1,
260 },
261 plugin_info: json!({
262 "url": url,
263 "type": "album",
264 "artworkUrl": artwork,
265 "totalTracks": tracks.len(),
266 "author": album_node.get("artistName").and_then(|v| v.as_str())
267 }),
268 tracks,
269 })
270 }
271 async fn get_artist(&self, id: &str) -> LoadResult {
272 let data = match self
273 .api_request(ENDPOINT_DETAILS, json!({ "pandoraId": id }))
274 .await
275 {
276 Some(d) => d,
277 None => return LoadResult::Empty {},
278 };
279 let annotations = data.get("annotations").unwrap_or(&Value::Null);
280 let artist_node = self.find_by_url_suffix(id, annotations);
281 if artist_node.is_null() {
282 return LoadResult::Empty {};
283 }
284 let name = artist_node
285 .get("name")
286 .and_then(|v| v.as_str())
287 .unwrap_or("Artist");
288 let top_tracks = data
289 .get("artistDetails")
290 .and_then(|v| v.get("topTracks"))
291 .and_then(|v| v.as_array());
292 let mut tracks = Vec::new();
293 if let Some(ids) = top_tracks {
294 for tid in ids.iter().filter_map(|v| v.as_str()) {
295 let t_node = annotations.get(tid).unwrap_or(&Value::Null);
296 if !t_node.is_null()
297 && let Some(t) = self.map_track(t_node, annotations)
298 {
299 tracks.push(t);
300 }
301 }
302 }
303 let url = artist_node
304 .get("shareableUrlPath")
305 .and_then(|v| v.as_str())
306 .map(|p| format!("{BASE_URL}{p}"));
307 let artwork = self.get_artwork_url(&artist_node);
308 LoadResult::Playlist(PlaylistData {
309 info: PlaylistInfo {
310 name: format!("{name}'s Top Tracks"),
311 selected_track: -1,
312 },
313 plugin_info: json!({
314 "url": url,
315 "type": "artist",
316 "artworkUrl": artwork,
317 "totalTracks": tracks.len(),
318 "author": name
319 }),
320 tracks,
321 })
322 }
323 async fn get_playlist(&self, id: &str) -> LoadResult {
324 let body = json!({
325 "request": {
326 "pandoraId": id,
327 "playlistVersion": 0,
328 "offset": 0,
329 "limit": 5000,
330 "annotationLimit": 100,
331 "allowedTypes": ["TR"],
332 "bypassPrivacyRules": true
333 }
334 });
335 let json = match self.api_request(ENDPOINT_PLAYLIST_TRACKS, body).await {
336 Some(j) => j,
337 None => return LoadResult::Empty {},
338 };
339 let annotations = json.get("annotations").unwrap_or(&Value::Null);
340 let tracks_node = json.get("tracks").and_then(|v| v.as_array());
341 let mut merged = serde_json::Map::new();
342 if let Some(obj) = annotations.as_object() {
343 for (k, v) in obj {
344 merged.insert(k.clone(), v.clone());
345 }
346 }
347 let mut missing = Vec::new();
348 if let Some(ts) = tracks_node {
349 for t in ts {
350 if let Some(pid) = t.get("pandoraId").and_then(|v| v.as_str())
351 && !merged.contains_key(pid)
352 {
353 missing.push(pid.to_owned());
354 }
355 }
356 }
357 if !missing.is_empty()
358 && let Some(extra) = self
359 .api_request(ENDPOINT_ANNOTATE, self.build_annotate_request(&missing))
360 .await
361 && let Some(obj) = extra.as_object()
362 {
363 for (k, v) in obj {
364 merged.insert(k.clone(), v.clone());
365 }
366 }
367 let mut tracks = Vec::new();
368 let merged_val = Value::Object(merged);
369 if let Some(ts) = tracks_node {
370 for t in ts {
371 if let Some(pid) = t.get("pandoraId").and_then(|v| v.as_str())
372 && let Some(ann) = merged_val.get(pid)
373 && let Some(tr) = self.map_track(ann, &merged_val)
374 {
375 tracks.push(tr);
376 }
377 }
378 }
379 let name = json
380 .get("name")
381 .and_then(|v| v.as_str())
382 .unwrap_or("Playlist");
383 let url = json
384 .get("shareableUrlPath")
385 .and_then(|v| v.as_str())
386 .map(|p| format!("{BASE_URL}{p}"));
387 let artwork = self.get_artwork_url(&json);
388 let mut author_name = None;
389 if let Some(l_id) = json.get("listenerPandoraId").and_then(|v| v.as_str())
390 && let Some(author) = annotations.get(l_id)
391 {
392 author_name = author.get("fullname").and_then(|v| v.as_str());
393 }
394 LoadResult::Playlist(PlaylistData {
395 info: PlaylistInfo {
396 name: name.to_owned(),
397 selected_track: -1,
398 },
399 plugin_info: json!({
400 "url": url,
401 "type": "playlist",
402 "artworkUrl": artwork,
403 "totalTracks": tracks.len(),
404 "author": author_name
405 }),
406 tracks,
407 })
408 }
409 async fn get_recommendations(&self, id: &str) -> LoadResult {
410 let details = match self
411 .api_request(ENDPOINT_DETAILS, json!({ "pandoraId": id }))
412 .await
413 {
414 Some(d) => d,
415 None => return LoadResult::Empty {},
416 };
417 let similar = details
418 .get("trackDetails")
419 .and_then(|v| v.get("similarTracks"))
420 .and_then(|v| v.as_array());
421 let id_list: Vec<String> = similar
422 .map(|s| {
423 s.iter()
424 .filter_map(|v| v.as_str().map(|s| s.to_owned()))
425 .collect()
426 })
427 .unwrap_or_default();
428 if id_list.is_empty() {
429 return LoadResult::Empty {};
430 }
431 let annotations = match self
432 .api_request(ENDPOINT_ANNOTATE, self.build_annotate_request(&id_list))
433 .await
434 {
435 Some(a) => a,
436 None => return LoadResult::Empty {},
437 };
438 let mut tracks = Vec::new();
439 for tid in id_list {
440 if let Some(item) = annotations.get(&tid).filter(|v| !v.is_null())
441 && let Some(t) = self.map_track(item, &annotations)
442 {
443 tracks.push(t);
444 }
445 }
446 LoadResult::Playlist(PlaylistData {
447 info: PlaylistInfo {
448 name: "Pandora recommendations".to_owned(),
449 selected_track: -1,
450 },
451 plugin_info: json!({
452 "type": "recommendations",
453 "totalTracks": tracks.len()
454 }),
455 tracks,
456 })
457 }
458 async fn get_artist_all_songs(&self, id: &str) -> LoadResult {
459 let body = json!({ "artistPandoraId": id, "annotationLimit": 100 });
460 let json = match self.api_request(ENDPOINT_ARTIST_ALL_TRACKS, body).await {
461 Some(j) => j,
462 None => return LoadResult::Empty {},
463 };
464 let annotations = json.get("annotations").unwrap_or(&Value::Null);
465 let tracks_node = json.get("tracks").and_then(|v| v.as_array());
466 if tracks_node.as_ref().map(|n| n.is_empty()).unwrap_or(true) {
467 return LoadResult::Empty {};
468 }
469 let mut merged = serde_json::Map::new();
470 if let Some(obj) = annotations.as_object() {
471 for (k, v) in obj {
472 merged.insert(k.clone(), v.clone());
473 }
474 }
475 let all_ids: Vec<String> = tracks_node
476 .unwrap()
477 .iter()
478 .filter_map(|v| v.as_str().map(|s| s.to_owned()))
479 .collect();
480 let mut missing = Vec::new();
481 for tid in &all_ids {
482 if !merged.contains_key(tid) {
483 missing.push(tid.clone());
484 }
485 }
486 if !missing.is_empty()
487 && let Some(extra) = self
488 .api_request(ENDPOINT_ANNOTATE, self.build_annotate_request(&missing))
489 .await
490 && let Some(obj) = extra.as_object()
491 {
492 for (k, v) in obj {
493 merged.insert(k.clone(), v.clone());
494 }
495 }
496 let merged_val = Value::Object(merged);
497 let mut tracks = Vec::new();
498 for tid in all_ids {
499 if let Some(ann) = merged_val.get(&tid)
500 && let Some(tr) = self.map_track(ann, &merged_val)
501 {
502 tracks.push(tr);
503 }
504 }
505 let mut artist_node = self.find_by_url_suffix(id, annotations);
506 if artist_node.is_null()
507 && let Some(details) = self
508 .api_request(ENDPOINT_DETAILS, json!({ "pandoraId": id }))
509 .await
510 {
511 let details_ann = details.get("annotations").unwrap_or(&Value::Null);
512 let match_node = self.find_by_url_suffix(id, details_ann);
513 if !match_node.is_null() {
514 artist_node = match_node;
515 }
516 }
517 let name = artist_node
518 .get("name")
519 .and_then(|v| v.as_str())
520 .map(|n| format!("{n} - All Songs"))
521 .unwrap_or_else(|| "All Songs".to_owned());
522 let url = artist_node
523 .get("shareableUrlPath")
524 .and_then(|v| v.as_str())
525 .map(|p| format!("{BASE_URL}{p}"));
526 let artwork = self.get_artwork_url(&artist_node);
527 LoadResult::Playlist(PlaylistData {
528 info: PlaylistInfo {
529 name,
530 selected_track: -1,
531 },
532 plugin_info: json!({
533 "url": url,
534 "type": "artist",
535 "artworkUrl": artwork,
536 "totalTracks": tracks.len(),
537 "author": artist_node.get("name").and_then(|v| v.as_str())
538 }),
539 tracks,
540 })
541 }
542 async fn get_search(&self, query: &str) -> LoadResult {
543 let body = json!({
544 "query": query,
545 "types": ["TR"],
546 "listener": null,
547 "start": 0,
548 "count": 100,
549 "annotate": true,
550 "annotationRecipe": "CLASS_OF_2019"
551 });
552 let json = match self.api_request(ENDPOINT_SEARCH, body).await {
553 Some(j) => j,
554 None => return LoadResult::Empty {},
555 };
556 let annotations = json.get("annotations").unwrap_or(&Value::Null);
557 let results = json.get("results").and_then(|v| v.as_array());
558 if results.as_ref().map(|r| r.is_empty()).unwrap_or(true) {
559 return LoadResult::Empty {};
560 }
561 let mut tracks = Vec::new();
562 for v in results.unwrap() {
563 if let Some(id) = v.as_str()
564 && let Some(item) = annotations.get(id)
565 && item.get("type").and_then(|v| v.as_str()) == Some("TR")
566 && let Some(tr) = self.map_track(item, annotations)
567 {
568 tracks.push(tr);
569 if tracks.len() >= self.search_limit {
570 break;
571 }
572 }
573 }
574 if tracks.is_empty() {
575 LoadResult::Empty {}
576 } else {
577 LoadResult::Search(tracks)
578 }
579 }
580 pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
581 builder.header(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36")
582 }
583 async fn get_autocomplete(&self, query: &str, types: &[String]) -> Option<SearchResult> {
584 let mut type_keys = Vec::new();
585 if types.is_empty() {
586 type_keys.extend_from_slice(&["TR", "AL", "AR", "PL"]);
587 } else {
588 for t in types {
589 match t.as_str() {
590 "track" => type_keys.push("TR"),
591 "album" => type_keys.push("AL"),
592 "artist" => type_keys.push("AR"),
593 "playlist" => type_keys.push("PL"),
594 _ => {}
595 }
596 }
597 }
598 let body = json!({
599 "query": query,
600 "types": type_keys,
601 "listener": null,
602 "start": 0,
603 "count": 100,
604 "annotate": true,
605 "annotationRecipe": "CLASS_OF_2019"
606 });
607 let json = self.api_request(ENDPOINT_SEARCH, body).await?;
608 let annotations = json.get("annotations").unwrap_or(&Value::Null);
609 let results = json.get("results").and_then(|v| v.as_array())?;
610 let mut albums = Vec::new();
611 let mut artists = Vec::new();
612 let mut playlists = Vec::new();
613 let mut tracks = Vec::new();
614 for id_node in results {
615 let id = id_node.as_str()?;
616 let item = annotations.get(id).filter(|v| !v.is_null())?;
617 let type_str = item.get("type").and_then(|v| v.as_str())?;
618 match type_str {
619 "TR" => {
620 if let Some(tr) = self.map_track(item, annotations) {
621 tracks.push(tr);
622 }
623 }
624 "AL" => {
625 let name = item
626 .get("name")
627 .and_then(|v| v.as_str())
628 .unwrap_or("Unknown Album");
629 let path = item.get("shareableUrlPath").and_then(|v| v.as_str());
630 let artwork = self.get_artwork_url(item);
631 let artist_name = item.get("artistName").and_then(|v| v.as_str());
632 albums.push(PlaylistData {
633 info: PlaylistInfo {
634 name: name.to_owned(),
635 selected_track: -1,
636 },
637 plugin_info: json!({
638 "url": path.map(|p| format!("{BASE_URL}{p}")),
639 "type": "album",
640 "artworkUrl": artwork,
641 "totalTracks": item.get("trackCount").and_then(|v| v.as_u64()).unwrap_or(0),
642 "author": artist_name
643 }),
644 tracks: Vec::new(),
645 });
646 }
647 "AR" => {
648 let name = item
649 .get("name")
650 .and_then(|v| v.as_str())
651 .unwrap_or("Artist");
652 let path = item.get("shareableUrlPath").and_then(|v| v.as_str());
653 let artwork = self.get_artwork_url(item);
654 artists.push(PlaylistData {
655 info: PlaylistInfo {
656 name: format!("{name}'s Top Tracks"),
657 selected_track: -1,
658 },
659 plugin_info: json!({
660 "url": path.map(|p| format!("{BASE_URL}{p}")),
661 "type": "artist",
662 "artworkUrl": artwork,
663 "totalTracks": 0,
664 "author": name
665 }),
666 tracks: Vec::new(),
667 });
668 }
669 "PL" => {
670 let name = item
671 .get("name")
672 .and_then(|v| v.as_str())
673 .unwrap_or("Playlist");
674 let path = item.get("shareableUrlPath").and_then(|v| v.as_str());
675 let artwork = self.get_artwork_url(item);
676 let mut author_name = None;
677 if let Some(l_id) = item.get("listenerPandoraId").and_then(|v| v.as_str())
678 && let Some(author) = annotations.get(l_id)
679 {
680 author_name = author.get("fullname").and_then(|v| v.as_str());
681 }
682 playlists.push(PlaylistData {
683 info: PlaylistInfo {
684 name: name.to_owned(),
685 selected_track: -1,
686 },
687 plugin_info: json!({
688 "url": path.map(|p| format!("{BASE_URL}{p}")),
689 "type": "playlist",
690 "artworkUrl": artwork,
691 "totalTracks": item.get("totalTracks").and_then(|v| v.as_u64()).unwrap_or(0),
692 "author": author_name
693 }),
694 tracks: Vec::new(),
695 });
696 }
697 _ => {}
698 }
699 }
700 if tracks.len() > self.search_limit {
701 tracks.truncate(self.search_limit);
702 }
703 Some(SearchResult {
704 tracks,
705 albums,
706 artists,
707 playlists,
708 texts: Vec::new(),
709 plugin: json!({}),
710 })
711 }
712 }
713 #[async_trait]
714 impl SourcePlugin for PandoraSource {
715 fn name(&self) -> &str {
716 "pandora"
717 }
718 fn can_handle(&self, identifier: &str) -> bool {
719 self.search_prefixes()
720 .iter()
721 .any(|p| identifier.starts_with(p))
722 || self
723 .rec_prefixes()
724 .iter()
725 .any(|p| identifier.starts_with(p))
726 || url_regex().is_match(identifier)
727 }
728 fn search_prefixes(&self) -> Vec<&str> {
729 vec!["pdsearch:"]
730 }
731 fn is_mirror(&self) -> bool {
732 true
733 }
734 fn rec_prefixes(&self) -> Vec<&str> {
735 vec!["pdrec:"]
736 }
737 async fn load(
738 &self,
739 identifier: &str,
740 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
741 ) -> LoadResult {
742 if let Some(prefix) = self
743 .search_prefixes()
744 .into_iter()
745 .find(|p| identifier.starts_with(p))
746 {
747 let query = identifier.strip_prefix(prefix).unwrap();
748 if query.is_empty() {
749 return LoadResult::Empty {};
750 }
751 return self.get_search(query).await;
752 }
753 if let Some(prefix) = self
754 .rec_prefixes()
755 .into_iter()
756 .find(|p| identifier.starts_with(p))
757 {
758 let id = identifier.strip_prefix(prefix).unwrap();
759 if id.is_empty() {
760 return LoadResult::Empty {};
761 }
762 return self.get_recommendations(id).await;
763 }
764 let input = identifier.trim();
765 if let Some(caps) = url_regex().captures(input)
766 && let Some(id_match) = caps.name("id").or_else(|| caps.name("id2"))
767 {
768 let id = id_match.as_str();
769 if id.is_empty() {
770 return LoadResult::Empty {};
771 }
772 if let Some(tr_id) = id.strip_prefix("TR")
773 && !tr_id.is_empty()
774 {
775 return self.fetch_track(id).await;
776 }
777 if let Some(al_id) = id.strip_prefix("AL")
778 && !al_id.is_empty()
779 {
780 return self.get_album(id).await;
781 }
782 if let Some(ar_id) = id.strip_prefix("AR")
783 && !ar_id.is_empty()
784 {
785 if input.contains("/artist/all-songs/") {
786 return self.get_artist_all_songs(id).await;
787 }
788 return self.get_artist(id).await;
789 }
790 if let Some(pl_id) = id.strip_prefix("PL:")
791 && !pl_id.is_empty()
792 {
793 return self.get_playlist(id).await;
794 }
795 }
796 LoadResult::Empty {}
797 }
798 async fn load_search(
799 &self,
800 query: &str,
801 types: &[String],
802 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
803 ) -> Option<SearchResult> {
804 self.get_autocomplete(query, types).await
805 }
806 async fn get_track(
807 &self,
808 _identifier: &str,
809 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
810 ) -> Option<BoxedTrack> {
811 None
812 }
813 }
814}
815pub mod token {
816 use serde_json::Value;
817 use std::{
818 sync::Arc,
819 time::{Duration, Instant},
820 };
821 use tokio::sync::RwLock;
822 use tracing::{debug, error, info};
823 #[derive(Debug, Clone)]
824 pub struct PandoraTokens {
825 pub auth_token: String,
826 pub csrf_token_raw: String,
827 pub csrf_token_parsed: String,
828 pub expires_at: Instant,
829 }
830 pub struct PandoraTokenTracker {
831 client: Arc<reqwest::Client>,
832 tokens: Arc<RwLock<Option<PandoraTokens>>>,
833 csrf_override: Option<String>,
834 }
835 impl PandoraTokenTracker {
836 pub fn new(client: Arc<reqwest::Client>, csrf_override: Option<String>) -> Self {
837 Self {
838 client,
839 tokens: Arc::new(RwLock::new(None)),
840 csrf_override,
841 }
842 }
843 pub async fn get_tokens(&self) -> Option<PandoraTokens> {
844 {
845 let tokens = self.tokens.read().await;
846 if let Some(t) = &*tokens
847 && t.expires_at > Instant::now()
848 {
849 return Some(t.clone());
850 }
851 }
852 self.refresh_tokens().await
853 }
854 pub async fn force_refresh(&self) -> Option<PandoraTokens> {
855 self.perform_refresh(true).await
856 }
857 pub async fn refresh_tokens(&self) -> Option<PandoraTokens> {
858 self.perform_refresh(false).await
859 }
860 async fn perform_refresh(&self, force: bool) -> Option<PandoraTokens> {
861 let mut tokens_lock = self.tokens.write().await;
862 if !force
863 && let Some(t) = &*tokens_lock
864 && t.expires_at > Instant::now()
865 {
866 return Some(t.clone());
867 }
868 debug!("Refreshing Pandora tokens...");
869 let (csrf_raw, csrf_parsed) = if let Some(csrf) = &self.csrf_override {
870 (
871 format!("csrftoken={csrf};Path=/;Domain=.pandora.com;Secure"),
872 csrf.clone(),
873 )
874 } else {
875 match self.fetch_csrf_token().await {
876 Ok(res) => res,
877 Err(e) => {
878 error!("Failed to fetch Pandora CSRF token: {e}");
879 return None;
880 }
881 }
882 };
883 let auth_token = match self.perform_anonymous_login(&csrf_raw, &csrf_parsed).await {
884 Ok(token) => token,
885 Err(e) => {
886 error!("Failed to perform Pandora anonymous login: {e}");
887 return None;
888 }
889 };
890 let new_tokens = PandoraTokens {
891 auth_token,
892 csrf_token_raw: csrf_raw,
893 csrf_token_parsed: csrf_parsed,
894 expires_at: Instant::now() + Duration::from_secs(12 * 3600),
895 };
896 *tokens_lock = Some(new_tokens.clone());
897 info!("Successfully refreshed Pandora tokens");
898 Some(new_tokens)
899 }
900 async fn fetch_csrf_token(&self) -> Result<(String, String), String> {
901 let resp = self
902 .client
903 .head("https://www.pandora.com")
904 .send()
905 .await
906 .map_err(|e| e.to_string())?;
907 let cookies = resp.headers().get_all(reqwest::header::SET_COOKIE);
908 let regex = regex::Regex::new(r"csrftoken=([a-f0-9]{16})").unwrap();
909 for cookie in cookies {
910 let cookie_str = cookie.to_str().unwrap_or("");
911 if let Some(raw) = cookie_str.split(';').next()
912 && raw.starts_with("csrftoken=")
913 && let Some(captures) = regex.captures(raw)
914 && let Some(parsed_match) = captures.get(1)
915 {
916 return Ok((raw.to_owned(), parsed_match.as_str().to_owned()));
917 }
918 }
919 Err("CSRF token not found in cookies".to_owned())
920 }
921 async fn perform_anonymous_login(
922 &self,
923 csrf_raw: &str,
924 csrf_parsed: &str,
925 ) -> Result<String, String> {
926 let resp = self
927 .client
928 .post("https://www.pandora.com/api/v1/auth/anonymousLogin")
929 .header("Cookie", csrf_raw)
930 .header("X-CsrfToken", csrf_parsed)
931 .header("Content-Type", "application/json")
932 .header("Accept", "*/*")
933 .body("")
934 .send()
935 .await
936 .map_err(|e| e.to_string())?;
937 if !resp.status().is_success() {
938 return Err(format!(
939 "Anonymous login failed with status: {}",
940 resp.status()
941 ));
942 }
943 let body: Value = resp.json().await.map_err(|e| e.to_string())?;
944 if let Some(error_code) = body.get("errorCode")
945 && error_code.as_i64() == Some(0)
946 {
947 return Err("Anonymous login returned error code 0".to_owned());
948 }
949 body.get("authToken")
950 .and_then(|t| t.as_str())
951 .map(|s| s.to_owned())
952 .ok_or_else(|| "Auth token not found in response".to_owned())
953 }
954 pub fn init(self: Arc<Self>) {
955 let this = self.clone();
956 tokio::spawn(async move {
957 this.get_tokens().await;
958 });
959 }
960 }
961}
962pub use manager::PandoraSource;