Skip to main content

lavende_core/sources/
spotify.rs

1pub mod helpers {
2    use crate::sources::spotify::token::SpotifyTokenTracker;
3    use futures::future::join_all;
4    use serde_json::{Value, json};
5    use std::sync::Arc;
6    use tokio::sync::Semaphore;
7    use tracing::warn;
8    const PARTNER_API_URL: &str = "https://api-partner.spotify.com/pathfinder/v2/query";
9    pub struct SpotifyHelpers;
10    impl SpotifyHelpers {
11        pub fn base62_to_hex(id: &str) -> String {
12            const ALPHABET: &str = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
13            let mut bn = 0u128;
14            for c in id.chars() {
15                if let Some(idx) = ALPHABET.find(c) {
16                    bn = bn.wrapping_mul(62).wrapping_add(idx as u128);
17                }
18            }
19            format!("{:032x}", bn)
20        }
21        pub async fn partner_api_request(
22            client: &reqwest::Client,
23            token_tracker: &Arc<SpotifyTokenTracker>,
24            operation: &str,
25            variables: Value,
26            sha256_hash: &str,
27        ) -> Option<Value> {
28            let token = token_tracker.get_token().await?;
29            let body = json!({
30                "variables": variables,
31                "operationName": operation,
32                "extensions": {
33                    "persistedQuery": {
34                        "version": 1,
35                        "sha256Hash": sha256_hash
36                    }
37                }
38            });
39            let resp = client
40                .post(PARTNER_API_URL)
41                .bearer_auth(token)
42                .header("App-Platform", "WebPlayer")
43                .header("Spotify-App-Version", "1.2.81.104.g225ec0e6")
44                .json(&body)
45                .send()
46                .await
47                .ok()?;
48            if !resp.status().is_success() {
49                warn!("Partner API returned {} for {}", resp.status(), operation);
50                return None;
51            }
52            resp.json().await.ok()
53        }
54        #[allow(clippy::too_many_arguments)]
55        pub async fn fetch_paginated_items(
56            client: &reqwest::Client,
57            token_tracker: &Arc<SpotifyTokenTracker>,
58            operation: &str,
59            sha256_hash: &str,
60            base_vars: Value,
61            items_pointer: &str,
62            total_count: u64,
63            page_limit: u64,
64            concurrency: usize,
65        ) -> Vec<Value> {
66            let pages_needed = total_count.saturating_sub(page_limit);
67            if pages_needed == 0 {
68                return Vec::new();
69            }
70            let offsets: Vec<u64> = (1..=((total_count - 1) / page_limit)).collect();
71            let semaphore = Arc::new(Semaphore::new(concurrency));
72            let futs: Vec<_> = offsets
73                .into_iter()
74                .map(|page_idx| {
75                    let semaphore = semaphore.clone();
76                    let mut vars = base_vars.clone();
77                    if let Some(obj) = vars.as_object_mut() {
78                        obj.insert("offset".to_owned(), json!(page_idx * page_limit));
79                        obj.insert("limit".to_owned(), json!(page_limit));
80                    }
81                    let op = operation.to_owned();
82                    let h = sha256_hash.to_owned();
83                    let c = client.clone();
84                    let tt = token_tracker.clone();
85                    async move {
86                        let _permit = semaphore.acquire().await.unwrap();
87                        Self::partner_api_request(&c, &tt, &op, vars, &h).await
88                    }
89                })
90                .collect();
91            let results = join_all(futs).await;
92            results
93                .into_iter()
94                .flatten()
95                .filter_map(|result| {
96                    result
97                        .pointer(items_pointer)
98                        .and_then(|v| v.as_array())
99                        .cloned()
100                })
101                .flatten()
102                .collect()
103        }
104    }
105}
106pub mod metadata {
107    use crate::{
108        protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
109        sources::spotify::{
110            helpers::SpotifyHelpers, parser::SpotifyParser, token::SpotifyTokenTracker,
111        },
112    };
113    use futures::future::join_all;
114    use serde_json::{Value, json};
115    use std::sync::Arc;
116    use tokio::sync::Semaphore;
117    pub struct SpotifyMetadata;
118    impl SpotifyMetadata {
119        pub async fn fetch_metadata_isrc(
120            client: &reqwest::Client,
121            token_tracker: &Arc<SpotifyTokenTracker>,
122            id: &str,
123            isrc_binary_regex: &regex::Regex,
124        ) -> Option<String> {
125            let token = token_tracker.get_token().await?;
126            let hex_id = SpotifyHelpers::base62_to_hex(id);
127            let url = format!(
128                "https://spclient.wg.spotify.com/metadata/4/track/{hex_id}?market=from_token"
129            );
130            let resp = client
131                .get(&url)
132                .bearer_auth(token)
133                .header("App-Platform", "WebPlayer")
134                .header("Spotify-App-Version", "1.2.81.104.g225ec0e6")
135                .send()
136                .await
137                .ok()?;
138            if !resp.status().is_success() {
139                return None;
140            }
141            let body_bytes = resp.bytes().await.ok()?;
142            let isrc_marker = b"isrc";
143            if let Some(pos) = body_bytes.windows(4).position(|w| w == isrc_marker) {
144                let end = std::cmp::min(pos + 64, body_bytes.len());
145                let chunk_str = String::from_utf8_lossy(&body_bytes[pos..end]);
146                if let Some(mat) = isrc_binary_regex.find(&chunk_str) {
147                    return Some(mat.as_str().to_owned());
148                }
149            }
150            if let Ok(json_str) = std::str::from_utf8(&body_bytes)
151                && let Ok(json) = serde_json::from_str::<Value>(json_str)
152                && let Some(isrc) = json
153                    .get("external_id")
154                    .and_then(|ids| ids.as_array())
155                    .and_then(|items| {
156                        items
157                            .iter()
158                            .find(|i| i.get("type").and_then(|v| v.as_str()) == Some("isrc"))
159                    })
160                    .and_then(|i| i.get("id"))
161                    .and_then(|v| v.as_str())
162            {
163                return Some(isrc.to_owned());
164            }
165            None
166        }
167        pub async fn parse_generic_track(
168            client: &reqwest::Client,
169            token_tracker: &Arc<SpotifyTokenTracker>,
170            track_val: &Value,
171            artwork_url: Option<String>,
172            isrc_binary_regex: &regex::Regex,
173        ) -> Option<TrackInfo> {
174            let mut track_info = SpotifyParser::parse_track_inner(track_val, artwork_url)?;
175            if track_info.isrc.is_none() {
176                let isrc = Self::fetch_metadata_isrc(
177                    client,
178                    token_tracker,
179                    &track_info.identifier,
180                    isrc_binary_regex,
181                )
182                .await;
183                track_info.isrc = isrc;
184            }
185            Some(track_info)
186        }
187        pub async fn fetch_track(
188            client: &reqwest::Client,
189            token_tracker: &Arc<SpotifyTokenTracker>,
190            id: &str,
191            isrc_binary_regex: &regex::Regex,
192        ) -> Option<TrackInfo> {
193            let variables = json!({
194                "uri": format!("spotify:track:{id}")
195            });
196            let hash = "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294";
197            let data = SpotifyHelpers::partner_api_request(
198                client,
199                token_tracker,
200                "getTrack",
201                variables,
202                hash,
203            )
204            .await?;
205            let track = data.pointer("/data/trackUnion")?;
206            Self::parse_generic_track(client, token_tracker, track, None, isrc_binary_regex).await
207        }
208        pub async fn fetch_album(
209            client: &reqwest::Client,
210            token_tracker: &Arc<SpotifyTokenTracker>,
211            id: &str,
212            album_load_limit: usize,
213            album_page_load_concurrency: usize,
214            track_resolve_concurrency: usize,
215            isrc_binary_regex: &regex::Regex,
216        ) -> LoadResult {
217            const HASH: &str = "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10";
218            const PAGE_LIMIT: u64 = 50;
219            let base_vars = json!({
220                "uri": format!("spotify:album:{id}"),
221                "locale": "en",
222                "offset": 0,
223                "limit": PAGE_LIMIT
224            });
225            let data = match SpotifyHelpers::partner_api_request(
226                client,
227                token_tracker,
228                "getAlbum",
229                base_vars.clone(),
230                HASH,
231            )
232            .await
233            {
234                Some(d) => d,
235                None => return LoadResult::Empty {},
236            };
237            let album = match data.pointer("/data/albumUnion") {
238                Some(a) => a,
239                None => return LoadResult::Empty {},
240            };
241            let name = album
242                .get("name")
243                .and_then(|v| v.as_str())
244                .unwrap_or("Unknown Album")
245                .to_owned();
246            let total_count = album
247                .pointer("/tracksV2/totalCount")
248                .and_then(|v| v.as_u64())
249                .unwrap_or(0);
250            let album_artwork = album
251                .pointer("/coverArt/sources")
252                .and_then(|s| s.as_array())
253                .and_then(|s| s.first())
254                .and_then(|i| i.get("url"))
255                .and_then(|v| v.as_str())
256                .map(|s| s.to_owned());
257            let mut all_items: Vec<Value> = album
258                .pointer("/tracksV2/items")
259                .and_then(|i| i.as_array())
260                .cloned()
261                .unwrap_or_default();
262            if total_count > PAGE_LIMIT {
263                let max_tracks = if album_load_limit == 0 {
264                    u64::MAX
265                } else {
266                    album_load_limit as u64 * PAGE_LIMIT
267                };
268                let effective_total = total_count.min(max_tracks);
269                if effective_total > PAGE_LIMIT {
270                    let extra = SpotifyHelpers::fetch_paginated_items(
271                        client,
272                        token_tracker,
273                        "getAlbum",
274                        HASH,
275                        base_vars,
276                        "/data/albumUnion/tracksV2/items",
277                        effective_total,
278                        PAGE_LIMIT,
279                        album_page_load_concurrency,
280                    )
281                    .await;
282                    all_items.extend(extra);
283                }
284            }
285            let semaphore = Arc::new(Semaphore::new(track_resolve_concurrency));
286            let futs: Vec<_> = all_items
287                .into_iter()
288                .take(if album_load_limit > 0 {
289                    (PAGE_LIMIT * album_load_limit as u64) as usize
290                } else {
291                    usize::MAX
292                })
293                .filter_map(|item| {
294                    let track_data = item.get("track")?.clone();
295                    let semaphore = semaphore.clone();
296                    let artwork = album_artwork.clone();
297                    let c = client.clone();
298                    let tt = token_tracker.clone();
299                    let re = isrc_binary_regex.clone();
300                    Some(async move {
301                        let _permit = semaphore.acquire().await.unwrap();
302                        Self::parse_generic_track(&c, &tt, &track_data, artwork, &re).await
303                    })
304                })
305                .collect();
306            let results = join_all(futs).await;
307            let tracks: Vec<Track> = results.into_iter().flatten().map(Track::new).collect();
308            if tracks.is_empty() {
309                LoadResult::Empty {}
310            } else {
311                LoadResult::Playlist(PlaylistData {
312                    info: PlaylistInfo {
313                        name,
314                        selected_track: -1,
315                    },
316                    plugin_info: json!({ "type": "album", "url": format!("https://open.spotify.com/album/{id}"), "artworkUrl": album_artwork, "author": album.pointer("/artists/items/0/profile/name").and_then(|v| v.as_str()), "totalTracks": total_count }),
317                    tracks,
318                })
319            }
320        }
321        pub async fn fetch_playlist(
322            client: &reqwest::Client,
323            token_tracker: &Arc<SpotifyTokenTracker>,
324            id: &str,
325            playlist_load_limit: usize,
326            playlist_page_load_concurrency: usize,
327            track_resolve_concurrency: usize,
328            isrc_binary_regex: &regex::Regex,
329        ) -> LoadResult {
330            const HASH: &str = "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77";
331            const PAGE_LIMIT: u64 = 100;
332            let base_vars = json!({
333                "uri": format!("spotify:playlist:{id}"),
334                "offset": 0,
335                "limit": PAGE_LIMIT,
336                "enableWatchFeedEntrypoint": false
337            });
338            let data = match SpotifyHelpers::partner_api_request(
339                client,
340                token_tracker,
341                "fetchPlaylist",
342                base_vars.clone(),
343                HASH,
344            )
345            .await
346            {
347                Some(d) => d,
348                None => return LoadResult::Empty {},
349            };
350            let playlist = match data.pointer("/data/playlistV2") {
351                Some(p) => p,
352                None => return LoadResult::Empty {},
353            };
354            let name = playlist
355                .get("name")
356                .and_then(|v| v.as_str())
357                .unwrap_or("Unknown Playlist")
358                .to_owned();
359            let total_count = playlist
360                .pointer("/content/totalCount")
361                .and_then(|v| v.as_u64())
362                .unwrap_or(0);
363            let mut all_items: Vec<Value> = playlist
364                .pointer("/content/items")
365                .and_then(|i| i.as_array())
366                .cloned()
367                .unwrap_or_default();
368            if total_count > PAGE_LIMIT {
369                let max_tracks = if playlist_load_limit == 0 {
370                    u64::MAX
371                } else {
372                    playlist_load_limit as u64 * PAGE_LIMIT
373                };
374                let effective_total = total_count.min(max_tracks);
375                if effective_total > PAGE_LIMIT {
376                    let extra = SpotifyHelpers::fetch_paginated_items(
377                        client,
378                        token_tracker,
379                        "fetchPlaylist",
380                        HASH,
381                        base_vars,
382                        "/data/playlistV2/content/items",
383                        effective_total,
384                        PAGE_LIMIT,
385                        playlist_page_load_concurrency,
386                    )
387                    .await;
388                    all_items.extend(extra);
389                }
390            }
391            let semaphore = Arc::new(Semaphore::new(track_resolve_concurrency));
392            let futs: Vec<_> = all_items
393                .into_iter()
394                .take(if playlist_load_limit > 0 {
395                    (PAGE_LIMIT * playlist_load_limit as u64) as usize
396                } else {
397                    usize::MAX
398                })
399                .filter_map(|item| {
400                    let track_data = item
401                        .pointer("/item/data")
402                        .or_else(|| item.pointer("/itemV2/data"))?
403                        .clone();
404                    let semaphore = semaphore.clone();
405                    let c = client.clone();
406                    let tt = token_tracker.clone();
407                    let re = isrc_binary_regex.clone();
408                    Some(async move {
409                        let _permit = semaphore.acquire().await.unwrap();
410                        Self::parse_generic_track(&c, &tt, &track_data, None, &re).await
411                    })
412                })
413                .collect();
414            let results = join_all(futs).await;
415            let tracks: Vec<Track> = results.into_iter().flatten().map(Track::new).collect();
416            if tracks.is_empty() {
417                LoadResult::Empty {}
418            } else {
419                LoadResult::Playlist(PlaylistData {
420                    info: PlaylistInfo {
421                        name: name.clone(),
422                        selected_track: -1,
423                    },
424                    plugin_info: json!({
425                      "type": "playlist",
426                      "url": format!("https://open.spotify.com/playlist/{id}"),
427                      "artworkUrl": playlist.pointer("/images/items/0/sources/0/url").and_then(|v| v.as_str()),
428                      "author": playlist.get("ownerV2").and_then(|v| v.get("name")).and_then(|v| v.as_str()).or_else(|| (id.starts_with("37i9dQZ")).then_some("Spotify")),
429                      "totalTracks": total_count
430                    }),
431                    tracks,
432                })
433            }
434        }
435        pub async fn fetch_artist(
436            client: &reqwest::Client,
437            token_tracker: &Arc<SpotifyTokenTracker>,
438            id: &str,
439            isrc_binary_regex: &regex::Regex,
440        ) -> LoadResult {
441            let variables = json!({
442                "uri": format!("spotify:artist:{id}"),
443                "locale": "en",
444                "includePrerelease": true
445            });
446            let hash = "35648a112beb1794e39ab931365f6ae4a8d45e65396d641eeda94e4003d41497";
447            let data = match SpotifyHelpers::partner_api_request(
448                client,
449                token_tracker,
450                "queryArtistOverview",
451                variables,
452                hash,
453            )
454            .await
455            {
456                Some(d) => d,
457                None => return LoadResult::Empty {},
458            };
459            let artist = match data.pointer("/data/artistUnion") {
460                Some(a) => a,
461                None => return LoadResult::Empty {},
462            };
463            let name = artist
464                .get("profile")
465                .and_then(|p| p.get("name"))
466                .and_then(|v| v.as_str())
467                .unwrap_or("Unknown Artist")
468                .to_owned();
469            let mut tracks = Vec::new();
470            if let Some(items) = artist
471                .pointer("/discography/topTracks/items")
472                .and_then(|i| i.as_array())
473            {
474                for item in items {
475                    if let Some(track_data) = item.get("track") {
476                        let c = client.clone();
477                        let tt = token_tracker.clone();
478                        let re = isrc_binary_regex.to_owned();
479                        if let Some(track_info) =
480                            Self::parse_generic_track(&c, &tt, track_data, None, &re).await
481                        {
482                            tracks.push(Track::new(track_info));
483                        }
484                    }
485                }
486            }
487            if tracks.is_empty() {
488                LoadResult::Empty {}
489            } else {
490                LoadResult::Playlist(PlaylistData {
491                    info: PlaylistInfo {
492                        name: name.clone(),
493                        selected_track: -1,
494                    },
495                    plugin_info: json!({
496                      "type": "artist",
497                      "url": format!("https://open.spotify.com/artist/{id}"),
498                      "artworkUrl": artist.pointer("/visuals/avatar/sources/0/url").and_then(|v| v.as_str()),
499                      "author": name,
500                      "totalTracks": tracks.len()
501                    }),
502                    tracks,
503                })
504            }
505        }
506    }
507}
508pub mod parser {
509    use crate::protocol::tracks::TrackInfo;
510    use serde_json::Value;
511    use tracing::debug;
512    pub struct SpotifyParser;
513    impl SpotifyParser {
514        pub fn parse_track_inner(
515            track_val: &Value,
516            artwork_url: Option<String>,
517        ) -> Option<TrackInfo> {
518            let track = if track_val.get("uri").is_some() {
519                track_val
520            } else if let Some(inner) = track_val.get("track") {
521                inner
522            } else if let Some(inner) = track_val.get("item") {
523                inner
524            } else if let Some(inner) = track_val.get("data") {
525                inner
526            } else {
527                debug!(
528                    "Track data missing uri and no nested track property: {:?}",
529                    track_val
530                );
531                return None;
532            };
533            let uri = track.get("uri").and_then(|v| v.as_str())?;
534            let id = uri.split(':').next_back()?.to_owned();
535            let title = track.get("name").and_then(|v| v.as_str())?.to_owned();
536            let author = Self::extract_author(track);
537            let length = track
538                .get("duration_ms")
539                .or_else(|| {
540                    track
541                        .get("duration")
542                        .or_else(|| track.get("trackDuration"))
543                        .and_then(|d| d.get("totalMilliseconds"))
544                })
545                .and_then(|v| v.as_u64())
546                .unwrap_or(0);
547            let final_artwork = artwork_url.or_else(|| {
548                track
549                    .get("albumOfTrack")
550                    .and_then(|a| a.get("coverArt"))
551                    .and_then(|c| c.get("sources"))
552                    .and_then(|s| s.as_array())
553                    .and_then(|s| s.first())
554                    .and_then(|i| i.get("url"))
555                    .and_then(|v| v.as_str())
556                    .map(|s| s.to_owned())
557                    .or_else(|| {
558                        track
559                            .get("album")
560                            .and_then(|a| a.get("images"))
561                            .and_then(|i| i.as_array())
562                            .and_then(|i| i.first())
563                            .and_then(|i| i.get("url"))
564                            .and_then(|v| v.as_str())
565                            .map(|s| s.to_owned())
566                    })
567            });
568            let isrc = Self::extract_isrc_inline(track);
569            Some(TrackInfo {
570                title,
571                author,
572                length,
573                identifier: id.clone(),
574                is_stream: false,
575                uri: Some(format!("https://open.spotify.com/track/{id}")),
576                artwork_url: final_artwork,
577                isrc,
578                source_name: "spotify".to_owned(),
579                is_seekable: true,
580                position: 0,
581            })
582        }
583        pub fn extract_author(track: &Value) -> String {
584            if let Some(artists) = track
585                .get("artists")
586                .and_then(|a| a.get("items"))
587                .and_then(|i| i.as_array())
588            {
589                let names: Vec<_> = artists
590                    .iter()
591                    .filter_map(|a| {
592                        a.get("profile")
593                            .and_then(|p| p.get("name"))
594                            .or_else(|| a.get("name"))
595                            .and_then(|v| v.as_str())
596                    })
597                    .collect();
598                if !names.is_empty() {
599                    return names.join(", ");
600                }
601            }
602            if let Some(first_artist) = track
603                .get("firstArtist")
604                .and_then(|a| a.get("items"))
605                .and_then(|i| i.as_array())
606                .and_then(|i| i.first())
607            {
608                let first_name = first_artist
609                    .get("profile")
610                    .and_then(|p| p.get("name"))
611                    .or_else(|| first_artist.get("name"))
612                    .and_then(|v| v.as_str())
613                    .unwrap_or("Unknown");
614                let mut all_artists = vec![first_name];
615                if let Some(others) = track
616                    .get("otherArtists")
617                    .and_then(|a| a.get("items"))
618                    .and_then(|i| i.as_array())
619                {
620                    for artist in others {
621                        if let Some(name) = artist
622                            .get("profile")
623                            .and_then(|p| p.get("name"))
624                            .or_else(|| artist.get("name"))
625                            .and_then(|v| v.as_str())
626                        {
627                            all_artists.push(name);
628                        }
629                    }
630                }
631                return all_artists.join(", ");
632            }
633            if let Some(artists) = track.get("artists").and_then(|a| a.as_array()) {
634                let names: Vec<_> = artists
635                    .iter()
636                    .filter_map(|a| {
637                        a.get("name")
638                            .or_else(|| a.get("profile").and_then(|p| p.get("name")))
639                            .and_then(|v| v.as_str())
640                    })
641                    .collect();
642                if !names.is_empty() {
643                    return names.join(", ");
644                }
645            }
646            track
647                .get("artist")
648                .and_then(|a| a.get("name"))
649                .and_then(|v| v.as_str())
650                .unwrap_or("Unknown Artist")
651                .to_owned()
652        }
653        pub fn extract_isrc_inline(track: &Value) -> Option<String> {
654            track
655                .get("externalIds")
656                .or_else(|| track.get("external_ids"))
657                .and_then(|ids| {
658                    if let Some(isrc) = ids
659                        .get("isrc")
660                        .and_then(|v| v.as_str())
661                        .filter(|s| !s.is_empty())
662                    {
663                        return Some(isrc.to_owned());
664                    }
665                    ids.get("items")
666                        .and_then(|items| items.as_array())
667                        .and_then(|items| {
668                            items
669                                .iter()
670                                .find(|i| i.get("type").and_then(|v| v.as_str()) == Some("isrc"))
671                        })
672                        .and_then(|i| i.get("id"))
673                        .and_then(|v| v.as_str())
674                        .filter(|s| !s.is_empty())
675                        .map(|s| s.to_owned())
676                })
677        }
678    }
679}
680pub mod recommendations {
681    use crate::{
682        protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track},
683        sources::spotify::{
684            helpers::SpotifyHelpers, parser::SpotifyParser, search::SpotifySearch,
685            token::SpotifyTokenTracker,
686        },
687    };
688    use futures::future::join_all;
689    use serde_json::{Value, json};
690    use std::sync::Arc;
691    pub struct SpotifyRecommendations;
692    impl SpotifyRecommendations {
693        pub async fn fetch_recommendations(
694            client: &reqwest::Client,
695            token_tracker: &Arc<SpotifyTokenTracker>,
696            query: &str,
697            mix_regex: &regex::Regex,
698            recommendations_limit: usize,
699            search_limit: usize,
700            isrc_binary_regex: &regex::Regex,
701        ) -> Result<LoadResult, String> {
702            let mut seed = query.to_owned();
703            if let Some(caps) = mix_regex.captures(query) {
704                let mut seed_type = caps.get(1).unwrap().as_str().to_owned();
705                seed = caps.get(2).unwrap().as_str().to_owned();
706                if seed_type == "isrc" {
707                    if let Some(res) = SpotifySearch::search_full(
708                        client,
709                        token_tracker,
710                        &format!("isrc:{seed}"),
711                        &["track".to_owned()],
712                        search_limit,
713                        isrc_binary_regex,
714                    )
715                    .await
716                    {
717                        if let Some(track) = res.tracks.first() {
718                            seed = track.info.identifier.clone();
719                            seed_type = "track".to_string();
720                        } else {
721                            return Ok(LoadResult::Empty {});
722                        }
723                    } else {
724                        return Ok(LoadResult::Empty {});
725                    }
726                }
727                let token = match token_tracker.get_token().await {
728                    Some(t) => t,
729                    None => return Ok(LoadResult::Empty {}),
730                };
731                let url = format!(
732                    "https://spclient.wg.spotify.com/inspiredby-mix/v2/seed_to_playlist/spotify:{seed_type}:{seed}?response-format=json"
733                );
734                let resp = client
735                    .get(&url)
736                    .bearer_auth(token)
737                    .header("App-Platform", "WebPlayer")
738                    .header("Spotify-App-Version", "1.2.81.104.g225ec0e6")
739                    .send()
740                    .await
741                    .ok();
742                if let Some(resp) = resp
743                    && resp.status().is_success()
744                    && let Ok(json) = resp.json::<Value>().await
745                    && let Some(playlist_uri) =
746                        json.pointer("/mediaItems/0/uri").and_then(|v| v.as_str())
747                    && let Some(id) = playlist_uri.split(':').next_back()
748                {
749                    return Err(id.to_owned());
750                }
751            }
752            let track_id = seed.strip_prefix("track:").unwrap_or(&seed);
753            Ok(Self::fetch_pathfinder_recommendations(
754                client,
755                token_tracker,
756                track_id,
757                recommendations_limit,
758            )
759            .await)
760        }
761        pub async fn fetch_pathfinder_recommendations(
762            client: &reqwest::Client,
763            token_tracker: &Arc<SpotifyTokenTracker>,
764            id: &str,
765            recommendations_limit: usize,
766        ) -> LoadResult {
767            let variables = json!({
768                "uri": format!("spotify:track:{id}"),
769                "limit": recommendations_limit
770            });
771            let hash = "c77098ee9d6ee8ad3eb844938722db60570d040b49f41f5ec6e7be9160a7c86b";
772            let data = match SpotifyHelpers::partner_api_request(
773                client,
774                token_tracker,
775                "internalLinkRecommenderTrack",
776                variables,
777                hash,
778            )
779            .await
780            {
781                Some(d) => d,
782                None => return LoadResult::Empty {},
783            };
784            let items = data
785                .pointer("/data/internalLinkRecommenderTrack/relatedTracks/items")
786                .or_else(|| data.pointer("/data/seoRecommendedTrack/items"))
787                .and_then(|i| i.as_array())
788                .cloned()
789                .unwrap_or_default();
790            if items.is_empty() {
791                return LoadResult::Empty {};
792            }
793            let mut tracks = Vec::new();
794            let futs: Vec<_> = items
795                .into_iter()
796                .map(|item| async move { SpotifyParser::parse_track_inner(&item, None) })
797                .collect();
798            let results = join_all(futs).await;
799            for track_info in results.into_iter().flatten() {
800                tracks.push(Track::new(track_info));
801            }
802            if tracks.is_empty() {
803                return LoadResult::Empty {};
804            }
805            tracks.truncate(recommendations_limit);
806            LoadResult::Playlist(PlaylistData {
807                info: PlaylistInfo {
808                    name: "Spotify Recommendations".to_owned(),
809                    selected_track: -1,
810                },
811                plugin_info: json!({
812                  "type": "recommendations",
813                  "totalTracks": tracks.len()
814                }),
815                tracks,
816            })
817        }
818    }
819}
820pub mod search {
821    use crate::{
822        protocol::tracks::{PlaylistData, PlaylistInfo, SearchResult, Track},
823        sources::spotify::{
824            helpers::SpotifyHelpers, metadata::SpotifyMetadata, parser::SpotifyParser,
825            token::SpotifyTokenTracker,
826        },
827    };
828    use serde_json::json;
829    use std::{sync::Arc, time::Duration};
830    use tokio::time::timeout;
831    pub struct SpotifySearch;
832    impl SpotifySearch {
833        pub async fn get_autocomplete(
834            client: &reqwest::Client,
835            token_tracker: &Arc<SpotifyTokenTracker>,
836            query: &str,
837            types: &[String],
838            search_limit: usize,
839            isrc_binary_regex: &regex::Regex,
840        ) -> Option<SearchResult> {
841            Self::search_full(
842                client,
843                token_tracker,
844                query,
845                types,
846                search_limit,
847                isrc_binary_regex,
848            )
849            .await
850        }
851        pub async fn search_full(
852            client: &reqwest::Client,
853            token_tracker: &Arc<SpotifyTokenTracker>,
854            query: &str,
855            types: &[String],
856            search_limit: usize,
857            isrc_binary_regex: &regex::Regex,
858        ) -> Option<SearchResult> {
859            let variables = json!({
860                "searchTerm": query,
861                "offset": 0,
862                "limit": search_limit,
863                "numberOfTopResults": 5,
864                "includeAudiobooks": false,
865                "includeArtistHasConcertsField": false,
866                "includePreReleases": false
867            });
868            let hash = "fcad5a3e0d5af727fb76966f06971c19cfa2275e6ff7671196753e008611873c";
869            let data = match SpotifyHelpers::partner_api_request(
870                client,
871                token_tracker,
872                "searchDesktop",
873                variables,
874                hash,
875            )
876            .await
877            {
878                Some(d) => d,
879                None => {
880                    return None;
881                }
882            };
883            let mut tracks = Vec::new();
884            let mut albums = Vec::new();
885            let mut artists = Vec::new();
886            let mut playlists = Vec::new();
887            let all_types = types.is_empty();
888            if (all_types || types.contains(&"track".to_owned()))
889                && let Some(items) = data
890                    .pointer("/data/searchV2/tracksV2/items")
891                    .or_else(|| data.pointer("/data/searchV2/tracks/items"))
892                    .and_then(|v| v.as_array())
893            {
894                for item in items {
895                    if let Some(track_data) = item
896                        .get("item")
897                        .or_else(|| item.get("itemV2"))
898                        .and_then(|v| v.get("data"))
899                        .or_else(|| item.get("data"))
900                        && let Some(track_info) = SpotifyParser::parse_track_inner(track_data, None)
901                    {
902                        let mut track = Track::new(track_info);
903                        let album_name = track_data
904                            .pointer("/albumOfTrack/name")
905                            .and_then(|v| v.as_str())
906                            .map(|s| s.to_owned());
907                        let album_url = track_data
908                            .pointer("/albumOfTrack/uri")
909                            .and_then(|v| v.as_str())
910                            .map(|s| {
911                                let id = s.split(':').next_back().unwrap_or("");
912                                format!("https://open.spotify.com/album/{id}")
913                            });
914                        let artist_url = track_data
915                            .pointer("/artists/items/0/uri")
916                            .and_then(|v| v.as_str())
917                            .map(|s| {
918                                let id = s.split(':').next_back().unwrap_or("");
919                                format!("https://open.spotify.com/artist/{id}")
920                            });
921                        track.plugin_info = json!({
922                            "albumName": album_name,
923                            "albumUrl": album_url,
924                            "artistUrl": artist_url,
925                            "artistArtworkUrl": null,
926                            "previewUrl": null,
927                            "isPreview": false
928                        });
929                        if track.info.isrc.is_none()
930                            && let Ok(Some(isrc)) = timeout(
931                                Duration::from_secs(2),
932                                SpotifyMetadata::fetch_metadata_isrc(
933                                    client,
934                                    token_tracker,
935                                    &track.info.identifier,
936                                    isrc_binary_regex,
937                                ),
938                            )
939                            .await
940                        {
941                            track.info.isrc = Some(isrc);
942                        }
943                        tracks.push(track);
944                    }
945                }
946            }
947            if (all_types || types.contains(&"album".to_owned()))
948                && let Some(items) = data
949                    .pointer("/data/searchV2/albumsV2/items")
950                    .or_else(|| data.pointer("/data/searchV2/albums/items"))
951                    .and_then(|v| v.as_array())
952            {
953                for item in items {
954                    if let Some(album_data) = item
955                        .get("item")
956                        .or_else(|| item.get("itemV2"))
957                        .and_then(|v| v.get("data"))
958                        .or_else(|| item.get("data"))
959                    {
960                        let name = album_data
961                            .get("name")
962                            .and_then(|v| v.as_str())
963                            .unwrap_or("Unknown Album");
964                        let uri = album_data.get("uri").and_then(|v| v.as_str()).unwrap_or("");
965                        let id = uri.split(':').next_back().unwrap_or("");
966                        let artwork = album_data
967                            .pointer("/coverArt/sources/0/url")
968                            .and_then(|v| v.as_str());
969                        let author = album_data
970                            .pointer("/artists/items/0/profile/name")
971                            .and_then(|v| v.as_str())
972                            .unwrap_or("Unknown Artist");
973                        albums.push(PlaylistData {
974                            info: PlaylistInfo {
975                                name: name.to_owned(),
976                                selected_track: -1,
977                            },
978                            plugin_info: json!({
979                              "type": "album",
980                              "url": format!("https://open.spotify.com/album/{id}"),
981                              "artworkUrl": artwork,
982                              "author": author,
983                              "totalTracks": 0
984                            }),
985                            tracks: Vec::new(),
986                        });
987                    }
988                }
989            }
990            if (all_types || types.contains(&"artist".to_owned()))
991                && let Some(items) = data
992                    .pointer("/data/searchV2/artistsV2/items")
993                    .or_else(|| data.pointer("/data/searchV2/artists/items"))
994                    .or_else(|| data.pointer("/data/searchV2/profilesV2/items"))
995                    .or_else(|| data.pointer("/data/searchV2/profiles/items"))
996                    .and_then(|v| v.as_array())
997            {
998                for item in items {
999                    if let Some(artist_data) = item
1000                        .get("item")
1001                        .or_else(|| item.get("itemV2"))
1002                        .and_then(|v| v.get("data"))
1003                        .or_else(|| item.get("data"))
1004                    {
1005                        let name = artist_data
1006                            .pointer("/profile/name")
1007                            .or_else(|| artist_data.get("name"))
1008                            .and_then(|v| v.as_str())
1009                            .unwrap_or("Unknown Artist");
1010                        let uri = artist_data
1011                            .get("uri")
1012                            .and_then(|v| v.as_str())
1013                            .unwrap_or("");
1014                        let id = uri.split(':').next_back().unwrap_or("");
1015                        let artwork = artist_data
1016                            .pointer("/visuals/avatarImage/sources/0/url")
1017                            .or_else(|| artist_data.pointer("/images/items/0/sources/0/url"))
1018                            .and_then(|v| v.as_str());
1019                        artists.push(PlaylistData {
1020                            info: PlaylistInfo {
1021                                name: format!("{name}'s Top Tracks"),
1022                                selected_track: -1,
1023                            },
1024                            plugin_info: json!({
1025                              "type": "artist",
1026                              "url": format!("https://open.spotify.com/artist/{id}"),
1027                              "artworkUrl": artwork,
1028                              "author": name,
1029                              "totalTracks": 0
1030                            }),
1031                            tracks: Vec::new(),
1032                        });
1033                    }
1034                }
1035            }
1036            if all_types || types.contains(&"playlist".to_owned()) {
1037                let playlist_paths = [
1038                    "/data/searchV2/playlistsV2/items",
1039                    "/data/searchV2/playlists/items",
1040                ];
1041                for path in playlist_paths {
1042                    if let Some(items) = data.pointer(path).and_then(|v| v.as_array()) {
1043                        for item in items {
1044                            if let Some(playlist_data) = item
1045                                .get("item")
1046                                .or_else(|| item.get("itemV2"))
1047                                .and_then(|v| v.get("data"))
1048                                .or_else(|| item.get("data"))
1049                            {
1050                                let name = playlist_data
1051                                    .get("name")
1052                                    .and_then(|v| v.as_str())
1053                                    .unwrap_or("Unknown");
1054                                let uri = playlist_data
1055                                    .get("uri")
1056                                    .and_then(|v| v.as_str())
1057                                    .unwrap_or("");
1058                                let parts: Vec<&str> = uri.split(':').collect();
1059                                let type_str = parts.get(1).unwrap_or(&"playlist");
1060                                let id = parts.last().unwrap_or(&"");
1061                                let artwork = playlist_data
1062                                    .pointer("/images/items/0/sources/0/url")
1063                                    .or_else(|| playlist_data.pointer("/coverArt/sources/0/url"))
1064                                    .and_then(|v| v.as_str());
1065                                let author = playlist_data
1066                                    .pointer("/ownerV2/data/name")
1067                                    .or_else(|| playlist_data.pointer("/ownerV2/name"))
1068                                    .and_then(|v| v.as_str());
1069                                playlists.push(PlaylistData {
1070                                    info: PlaylistInfo {
1071                                        name: name.to_owned(),
1072                                        selected_track: -1,
1073                                    },
1074                                    plugin_info: json!({
1075                                      "type": type_str,
1076                                      "url": format!("https://open.spotify.com/{type_str}/{id}"),
1077                                      "artworkUrl": artwork,
1078                                      "author": author,
1079                                      "totalTracks": 0
1080                                    }),
1081                                    tracks: Vec::new(),
1082                                });
1083                            }
1084                        }
1085                    }
1086                }
1087            }
1088            Some(SearchResult {
1089                tracks,
1090                albums,
1091                artists,
1092                playlists,
1093                texts: Vec::new(),
1094                plugin: json!({}),
1095            })
1096        }
1097    }
1098}
1099pub mod token {
1100    use crate::common::types::SharedRw;
1101    use regex::Regex;
1102    use std::sync::{Arc, OnceLock};
1103    use tokio::sync::RwLock;
1104    use tracing::{debug, error};
1105    const EMBED_URL: &str = "https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT";
1106    fn token_regex() -> &'static Regex {
1107        static REGEX: OnceLock<Regex> = OnceLock::new();
1108        REGEX.get_or_init(|| {
1109            Regex::new(r#""accessToken":"([^"]+)""#)
1110                .expect("spotify token regex is a valid literal")
1111        })
1112    }
1113    fn expiry_regex() -> &'static Regex {
1114        static REGEX: OnceLock<Regex> = OnceLock::new();
1115        REGEX.get_or_init(|| {
1116            Regex::new(r#""accessTokenExpirationTimestampMs":(\d+)"#)
1117                .expect("spotify expiry regex is a valid literal")
1118        })
1119    }
1120    #[derive(Clone, Debug)]
1121    pub struct SpotifyToken {
1122        pub access_token: String,
1123        pub expiry_ms: u64,
1124    }
1125    pub struct SpotifyTokenTracker {
1126        client: Arc<reqwest::Client>,
1127        token: SharedRw<Option<SpotifyToken>>,
1128    }
1129    impl SpotifyTokenTracker {
1130        pub fn new(client: Arc<reqwest::Client>) -> Self {
1131            Self {
1132                client,
1133                token: Arc::new(RwLock::new(None)),
1134            }
1135        }
1136        pub async fn get_token(&self) -> Option<String> {
1137            {
1138                let token_lock = self.token.read().await;
1139                if let Some(token) = &*token_lock {
1140                    let now = std::time::SystemTime::now()
1141                        .duration_since(std::time::UNIX_EPOCH)
1142                        .unwrap()
1143                        .as_millis() as u64;
1144                    if token.expiry_ms > now + 5_000 {
1145                        return Some(token.access_token.clone());
1146                    }
1147                }
1148            }
1149            self.refresh_token().await
1150        }
1151        async fn refresh_token(&self) -> Option<String> {
1152            debug!("Refreshing Spotify token from embed...");
1153            let request = self
1154            .client
1155            .get(EMBED_URL)
1156            .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.178 Spotify/1.2.65.255 Safari/537.36")
1157            .header("Accept-Language", "en-US,en;q=0.9")
1158            .header("Sec-Fetch-Dest", "iframe")
1159            .header("Sec-Fetch-Mode", "navigate")
1160            .header("Sec-Fetch-Site", "cross-site");
1161            let resp = match request.send().await {
1162                Ok(r) => r,
1163                Err(e) => {
1164                    error!("Failed to fetch Spotify embed page: {e}");
1165                    return None;
1166                }
1167            };
1168            if !resp.status().is_success() {
1169                error!("Embed page returned status {}", resp.status());
1170                return None;
1171            }
1172            let html = match resp.text().await {
1173                Ok(t) => t,
1174                Err(e) => {
1175                    error!("Failed to read Spotify embed HTML: {e}");
1176                    return None;
1177                }
1178            };
1179            let token_caps = token_regex().captures(&html);
1180            let expiry_caps = expiry_regex().captures(&html);
1181            if token_caps.is_none() || expiry_caps.is_none() {
1182                error!("Token or expiry not found in embed page");
1183                return None;
1184            }
1185            let token = match token_caps.and_then(|c| c.get(1)) {
1186                Some(m) => m.as_str().to_owned(),
1187                None => {
1188                    error!("Successfully found token caps but group 1 was missing");
1189                    return None;
1190                }
1191            };
1192            let expiry_ms = match expiry_caps.and_then(|c| c.get(1)) {
1193                Some(m) => m.as_str().parse::<u64>().ok()?,
1194                None => {
1195                    error!("Successfully found expiry caps but group 1 was missing");
1196                    return None;
1197                }
1198            };
1199            let mut token_lock = self.token.write().await;
1200            *token_lock = Some(SpotifyToken {
1201                access_token: token.clone(),
1202                expiry_ms,
1203            });
1204            debug!("Successfully refreshed Spotify token. Expiry: {expiry_ms}");
1205            Some(token)
1206        }
1207        pub fn init(self: Arc<Self>) {
1208            let this = self.clone();
1209            tokio::spawn(async move {
1210                this.get_token().await;
1211            });
1212        }
1213    }
1214}
1215use crate::{
1216    protocol::tracks::{LoadResult, Track},
1217    sources::{SourcePlugin, playable_track::BoxedTrack, spotify::token::SpotifyTokenTracker},
1218};
1219use async_trait::async_trait;
1220use regex::Regex;
1221use std::sync::{Arc, OnceLock};
1222fn url_regex() -> &'static Regex {
1223    static REGEX: OnceLock<Regex> = OnceLock::new();
1224    REGEX.get_or_init(|| {
1225        Regex::new(
1226            r"https?://(?:open\.)?spotify\.com/(?:intl-[a-z]{2}/)?(track|album|playlist|artist)/([a-zA-Z0-9]+)",
1227        ).expect("spotify URL regex is a valid literal")
1228    })
1229}
1230fn mix_regex() -> &'static Regex {
1231    static REGEX: OnceLock<Regex> = OnceLock::new();
1232    REGEX.get_or_init(|| {
1233        Regex::new(r"mix:(album|artist|track|isrc):([a-zA-Z0-9\-_]+)")
1234            .expect("spotify mix regex is a valid literal")
1235    })
1236}
1237fn isrc_binary_regex() -> &'static Regex {
1238    static REGEX: OnceLock<Regex> = OnceLock::new();
1239    REGEX.get_or_init(|| {
1240        Regex::new(r"[A-Z0-9]{12}").expect("spotify ISRC binary regex is a valid literal")
1241    })
1242}
1243pub struct SpotifySource {
1244    client: Arc<reqwest::Client>,
1245    token_tracker: Arc<SpotifyTokenTracker>,
1246    playlist_load_limit: usize,
1247    album_load_limit: usize,
1248    search_limit: usize,
1249    recommendations_limit: usize,
1250    playlist_page_load_concurrency: usize,
1251    album_page_load_concurrency: usize,
1252    track_resolve_concurrency: usize,
1253}
1254impl SpotifySource {
1255    pub fn new(
1256        config: Option<crate::config::SpotifyConfig>,
1257        client: Arc<reqwest::Client>,
1258    ) -> Result<Self, String> {
1259        let (
1260            playlist_load_limit,
1261            album_load_limit,
1262            search_limit,
1263            recommendations_limit,
1264            playlist_page_load_concurrency,
1265            album_page_load_concurrency,
1266            track_resolve_concurrency,
1267        ) = if let Some(c) = config {
1268            (
1269                c.playlist_load_limit,
1270                c.album_load_limit,
1271                c.search_limit,
1272                c.recommendations_limit,
1273                c.playlist_page_load_concurrency,
1274                c.album_page_load_concurrency,
1275                c.track_resolve_concurrency,
1276            )
1277        } else {
1278            (6, 6, 10, 10, 10, 5, 50)
1279        };
1280        let token_tracker = Arc::new(SpotifyTokenTracker::new(client.clone()));
1281        token_tracker.clone().init();
1282        Ok(Self {
1283            client,
1284            token_tracker,
1285            playlist_load_limit,
1286            album_load_limit,
1287            search_limit,
1288            recommendations_limit,
1289            playlist_page_load_concurrency,
1290            album_page_load_concurrency,
1291            track_resolve_concurrency,
1292        })
1293    }
1294    pub async fn get_autocomplete(
1295        &self,
1296        query: &str,
1297        types: &[String],
1298    ) -> Option<crate::protocol::tracks::SearchResult> {
1299        search::SpotifySearch::get_autocomplete(
1300            &self.client,
1301            &self.token_tracker,
1302            query,
1303            types,
1304            self.search_limit,
1305            isrc_binary_regex(),
1306        )
1307        .await
1308    }
1309    pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1310        builder.header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.178 Spotify/1.2.65.255 Safari/537.36")
1311    }
1312}
1313#[async_trait]
1314impl SourcePlugin for SpotifySource {
1315    fn name(&self) -> &str {
1316        "spotify"
1317    }
1318    fn can_handle(&self, identifier: &str) -> bool {
1319        self.search_prefixes()
1320            .iter()
1321            .any(|p| identifier.starts_with(p))
1322            || self
1323                .rec_prefixes()
1324                .iter()
1325                .any(|p| identifier.starts_with(p))
1326            || url_regex().is_match(identifier)
1327    }
1328    fn search_prefixes(&self) -> Vec<&str> {
1329        vec!["spsearch:"]
1330    }
1331    fn is_mirror(&self) -> bool {
1332        true
1333    }
1334    fn rec_prefixes(&self) -> Vec<&str> {
1335        vec!["sprec:"]
1336    }
1337    async fn load(
1338        &self,
1339        identifier: &str,
1340        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
1341    ) -> LoadResult {
1342        if let Some(prefix) = self
1343            .search_prefixes()
1344            .into_iter()
1345            .find(|p| identifier.starts_with(p))
1346        {
1347            let query = &identifier[prefix.len()..];
1348            return match self.get_autocomplete(query, &["track".to_owned()]).await {
1349                Some(res) => {
1350                    if res.tracks.is_empty() {
1351                        LoadResult::Empty {}
1352                    } else {
1353                        LoadResult::Search(res.tracks)
1354                    }
1355                }
1356                None => LoadResult::Empty {},
1357            };
1358        }
1359        if let Some(prefix) = self
1360            .rec_prefixes()
1361            .into_iter()
1362            .find(|p| identifier.starts_with(p))
1363        {
1364            let query = &identifier[prefix.len()..];
1365            return match recommendations::SpotifyRecommendations::fetch_recommendations(
1366                &self.client,
1367                &self.token_tracker,
1368                query,
1369                mix_regex(),
1370                self.recommendations_limit,
1371                self.search_limit,
1372                isrc_binary_regex(),
1373            )
1374            .await
1375            {
1376                Ok(res) => res,
1377                Err(playlist_id) => {
1378                    metadata::SpotifyMetadata::fetch_playlist(
1379                        &self.client,
1380                        &self.token_tracker,
1381                        &playlist_id,
1382                        self.playlist_load_limit,
1383                        self.playlist_page_load_concurrency,
1384                        self.track_resolve_concurrency,
1385                        isrc_binary_regex(),
1386                    )
1387                    .await
1388                }
1389            };
1390        }
1391        if let Some(caps) = url_regex().captures(identifier) {
1392            let type_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
1393            let id = caps.get(2).map(|m| m.as_str()).unwrap_or("");
1394            match type_str {
1395                "track" => {
1396                    if let Some(track_info) = metadata::SpotifyMetadata::fetch_track(
1397                        &self.client,
1398                        &self.token_tracker,
1399                        id,
1400                        isrc_binary_regex(),
1401                    )
1402                    .await
1403                    {
1404                        return LoadResult::Track(Track::new(track_info));
1405                    }
1406                }
1407                "album" => {
1408                    return metadata::SpotifyMetadata::fetch_album(
1409                        &self.client,
1410                        &self.token_tracker,
1411                        id,
1412                        self.album_load_limit,
1413                        self.album_page_load_concurrency,
1414                        self.track_resolve_concurrency,
1415                        isrc_binary_regex(),
1416                    )
1417                    .await;
1418                }
1419                "playlist" => {
1420                    return metadata::SpotifyMetadata::fetch_playlist(
1421                        &self.client,
1422                        &self.token_tracker,
1423                        id,
1424                        self.playlist_load_limit,
1425                        self.playlist_page_load_concurrency,
1426                        self.track_resolve_concurrency,
1427                        isrc_binary_regex(),
1428                    )
1429                    .await;
1430                }
1431                "artist" => {
1432                    return metadata::SpotifyMetadata::fetch_artist(
1433                        &self.client,
1434                        &self.token_tracker,
1435                        id,
1436                        isrc_binary_regex(),
1437                    )
1438                    .await;
1439                }
1440                _ => {}
1441            }
1442        }
1443        LoadResult::Empty {}
1444    }
1445    async fn load_search(
1446        &self,
1447        query: &str,
1448        types: &[String],
1449        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
1450    ) -> Option<crate::protocol::tracks::SearchResult> {
1451        let mut q = query;
1452        for prefix in self.search_prefixes() {
1453            if let Some(stripped) = q.strip_prefix(prefix) {
1454                q = stripped;
1455                break;
1456            }
1457        }
1458        self.get_autocomplete(q, types).await
1459    }
1460    async fn get_track(
1461        &self,
1462        _identifier: &str,
1463        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
1464    ) -> Option<BoxedTrack> {
1465        None
1466    }
1467}