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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::Regex,
698 recommendations_limit: usize,
699 search_limit: usize,
700 isrc_binary_regex: ®ex::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: ®ex::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: ®ex::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}