Skip to main content

lavende_core/sources/
pandora.rs

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;