Skip to main content

lavende_core/sources/
lastfm.rs

1pub mod helpers {
2    use serde_json::Value;
3    use std::sync::Arc;
4    pub async fn get_json(client: &Arc<reqwest::Client>, url: &str) -> Option<Value> {
5        let res = match client.get(url)
6        .header(reqwest::header::ACCEPT, "application/json")
7        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
8        .send()
9        .await {
10            Ok(r) => r,
11            Err(e) => {
12                let redacted = if let Some(pos) = url.find("api_key=") {
13                    let end = url[pos..].find('&').map(|e| pos + e).unwrap_or(url.len());
14                    let mut s = url.to_owned();
15                    s.replace_range(pos + 8..end, "REDACTED");
16                    s
17                } else {
18                    url.to_owned()
19                };
20                tracing::debug!("Last.fm: API request failed for {}: {}", redacted, e);
21                return None;
22            }
23        };
24        if !res.status().is_success() {
25            let redacted = if let Some(pos) = url.find("api_key=") {
26                let end = url[pos..].find('&').map(|e| pos + e).unwrap_or(url.len());
27                let mut s = url.to_owned();
28                s.replace_range(pos + 8..end, "REDACTED");
29                s
30            } else {
31                url.to_owned()
32            };
33            tracing::debug!(
34                "Last.fm: API returned error status {} for {}",
35                res.status(),
36                redacted
37            );
38            return None;
39        }
40        res.json().await.ok()
41    }
42    pub fn unescape_html(input: &str) -> String {
43        let mut result = input.to_owned();
44        loop {
45            let next = result
46                .replace("&amp;", "&")
47                .replace("&lt;", "<")
48                .replace("&gt;", ">")
49                .replace("&quot;", "\"")
50                .replace("&#39;", "'")
51                .replace("&apos;", "'")
52                .replace("&#x27;", "'");
53            if next == result {
54                break;
55            }
56            result = next;
57        }
58        result
59    }
60    pub fn parse_duration_to_ms(duration: &str) -> u64 {
61        let parts: Vec<&str> = duration.split(':').collect();
62        if parts.len() == 2 {
63            let minutes = parts[0].trim().parse::<u64>().unwrap_or(0);
64            let seconds = parts[1].trim().parse::<u64>().unwrap_or(0);
65            (minutes * 60 + seconds) * 1000
66        } else if parts.len() == 3 {
67            let hours = parts[0].trim().parse::<u64>().unwrap_or(0);
68            let minutes = parts[1].trim().parse::<u64>().unwrap_or(0);
69            let seconds = parts[2].trim().parse::<u64>().unwrap_or(0);
70            (hours * 3600 + minutes * 60 + seconds) * 1000
71        } else {
72            0
73        }
74    }
75}
76pub mod metadata {
77    use super::{
78        LastFMSource,
79        helpers::{get_json, parse_duration_to_ms, unescape_html},
80    };
81    use crate::protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo};
82    use regex::Regex;
83    impl LastFMSource {
84        pub async fn resolve_url(&self, url: &str) -> LoadResult {
85            let caps = match crate::sources::lastfm::path_regex().captures(url) {
86                Some(c) => c,
87                None => {
88                    tracing::debug!("Last.fm: URL path failed to match regex: {}", url);
89                    return LoadResult::Empty {};
90                }
91            };
92            let type_ = caps.get(1).map(|m| m.as_str()).unwrap_or("");
93            let p1 = urlencoding::decode(caps.get(2).map(|m| m.as_str()).unwrap_or(""))
94                .unwrap_or_default()
95                .to_string();
96            let p2 = urlencoding::decode(caps.get(3).map(|m| m.as_str()).unwrap_or(""))
97                .unwrap_or_default()
98                .to_string();
99            let p3 = urlencoding::decode(caps.get(4).map(|m| m.as_str()).unwrap_or(""))
100                .unwrap_or_default()
101                .to_string();
102            if type_ == "user" {
103                return self.resolve_user(&p1, url).await;
104            }
105            if p3.is_empty() {
106                if p2 == "_" || p2.is_empty() {
107                    self.resolve_artist(&p1, url).await
108                } else {
109                    self.resolve_album(&p1, &p2, url).await
110                }
111            } else {
112                self.resolve_track(&p1, &p3, url).await
113            }
114        }
115        pub async fn resolve_track(&self, artist: &str, title: &str, url: &str) -> LoadResult {
116            let mut artwork_url = None;
117            let mut length = 0;
118            if let Some(ref key) = self.api_key {
119                let api_url = format!(
120                    "https://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key={}&artist={}&track={}&format=json",
121                    key,
122                    urlencoding::encode(artist),
123                    urlencoding::encode(title)
124                );
125                if let Some(json) = get_json(&self.http, &api_url).await {
126                    artwork_url = json["track"]["album"]["image"]
127                        .as_array()
128                        .or_else(|| json["track"]["image"].as_array())
129                        .and_then(|images| images.last())
130                        .and_then(|img| img["#text"].as_str())
131                        .filter(|s| !s.is_empty())
132                        .map(|s| s.replace("/34s/", "/300x300/"));
133                    length = json["track"]["duration"]
134                        .as_str()
135                        .and_then(|s| s.parse::<u64>().ok())
136                        .or_else(|| json["track"]["duration"].as_u64())
137                        .unwrap_or(0);
138                }
139            }
140            if (artwork_url.is_none() || length == 0)
141                && let Ok(res) = self.http.get(url).send().await
142                && let Ok(body) = res.text().await
143            {
144                if artwork_url.is_none()
145                && let Some(caps) = Regex::new(
146                    r#"(?i)<img[^>]*?class="[^"]*header-new-background-image[^"]*"[^>]*?src="([^"]+)""#,
147                )
148                .ok()
149                .and_then(|r| r.captures(&body))
150            {
151                artwork_url = caps
152                    .get(1)
153                    .map(|m| m.as_str().replace("/64s/", "/300x300/"));
154            }
155                if length == 0
156                && let Some(caps) = Regex::new(
157                    r#"(?i)<dt[^>]*?>\s*Length\s*</dt>\s*<dd[^>]*?class="[^"]*catalogue-metadata-description[^"]*"[^>]*?>\s*(\d+:\d+(?::\d+)?)\s*</dd>"#,
158                )
159                .ok()
160                .and_then(|r| r.captures(&body))
161            {
162                length = parse_duration_to_ms(
163                    caps.get(1).map(|m| m.as_str()).unwrap_or("0:00"),
164                );
165            }
166            }
167            let canonical_url = crate::sources::lastfm::construct_track_url(artist, title);
168            LoadResult::Track(Track::new(TrackInfo {
169                identifier: canonical_url.clone(),
170                is_seekable: true,
171                author: artist.to_owned(),
172                title: title.to_owned(),
173                length,
174                uri: Some(canonical_url),
175                artwork_url,
176                source_name: "lastfm".to_owned(),
177                ..Default::default()
178            }))
179        }
180        pub async fn resolve_album(&self, artist: &str, album: &str, url: &str) -> LoadResult {
181            if let Some(ref key) = self.api_key {
182                let api_url = format!(
183                    "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={}&artist={}&album={}&format=json",
184                    key,
185                    urlencoding::encode(artist),
186                    urlencoding::encode(album)
187                );
188                if let Some(json) = get_json(&self.http, &api_url).await
189                    && let Some(tracks) = json["album"]["tracks"]["track"].as_array()
190                {
191                    let artwork_url = json["album"]["image"]
192                        .as_array()
193                        .and_then(|images| images.last())
194                        .and_then(|img| img["#text"].as_str())
195                        .filter(|s| !s.is_empty())
196                        .map(|s| s.replace("/34s/", "/300x300/"));
197                    let mut results = Vec::new();
198                    for t in tracks {
199                        let title = t["name"].as_str().unwrap_or("Unknown").to_owned();
200                        let t_url = crate::sources::lastfm::construct_track_url(artist, &title);
201                        let length = t["duration"]
202                            .as_str()
203                            .and_then(|s| s.parse::<u64>().ok())
204                            .or_else(|| t["duration"].as_u64())
205                            .unwrap_or(0)
206                            * 1000;
207                        results.push(Track::new(TrackInfo {
208                            identifier: t_url.clone(),
209                            is_seekable: true,
210                            author: artist.to_owned(),
211                            title,
212                            length,
213                            uri: Some(t_url),
214                            artwork_url: artwork_url.clone(),
215                            source_name: "lastfm".to_owned(),
216                            ..Default::default()
217                        }));
218                    }
219                    return LoadResult::Playlist(PlaylistData {
220                        info: PlaylistInfo {
221                            name: format!("{} - {}", artist, album),
222                            selected_track: -1,
223                        },
224                        plugin_info: serde_json::json!({}),
225                        tracks: results,
226                    });
227                }
228            }
229            let body = match self.http.get(url).send().await {
230                Ok(r) => r.text().await.unwrap_or_else(|e| {
231                    tracing::debug!(
232                        "Last.fm: failed to get response text for album {}: {}",
233                        url,
234                        e
235                    );
236                    Default::default()
237                }),
238                Err(e) => {
239                    tracing::debug!("Last.fm: album scraping request failed for {}: {}", url, e);
240                    return LoadResult::Empty {};
241                }
242            };
243            let mut results = Vec::new();
244            for caps in crate::sources::lastfm::search_regex().captures_iter(&body) {
245                let artwork_url = caps
246                    .get(1)
247                    .map(|m| m.as_str().replace("/64s/", "/300x300/"));
248                let title = unescape_html(caps.get(2).map(|m| m.as_str()).unwrap_or("Unknown"));
249                let full_url = crate::sources::lastfm::construct_track_url(artist, &title);
250                results.push(Track::new(TrackInfo {
251                    identifier: full_url.clone(),
252                    is_seekable: true,
253                    author: artist.to_owned(),
254                    title: title.to_owned(),
255                    uri: Some(full_url),
256                    artwork_url,
257                    source_name: "lastfm".to_owned(),
258                    ..Default::default()
259                }));
260            }
261            if results.is_empty() {
262                tracing::debug!(
263                    "Last.fm: album/artist search yielded no tracks on page {}, trying track fallback",
264                    url
265                );
266                self.resolve_track(artist, album, url).await
267            } else {
268                LoadResult::Playlist(PlaylistData {
269                    info: PlaylistInfo {
270                        name: format!("{} - {}", artist, album),
271                        selected_track: -1,
272                    },
273                    plugin_info: serde_json::json!({}),
274                    tracks: results,
275                })
276            }
277        }
278        pub async fn resolve_artist(&self, artist: &str, url: &str) -> LoadResult {
279            if let Some(ref key) = self.api_key {
280                let api_url = format!(
281                    "https://ws.audioscrobbler.com/2.0/?method=artist.gettoptracks&api_key={}&artist={}&limit=50&format=json",
282                    key,
283                    urlencoding::encode(artist)
284                );
285                if let Some(json) = get_json(&self.http, &api_url).await
286                    && let Some(tracks) = json["toptracks"]["track"].as_array()
287                {
288                    let mut results = Vec::new();
289                    for t in tracks {
290                        let title = t["name"].as_str().unwrap_or("Unknown").to_owned();
291                        let t_url = crate::sources::lastfm::construct_track_url(artist, &title);
292                        let artwork_url = t["image"]
293                            .as_array()
294                            .and_then(|images| images.last())
295                            .and_then(|img| img["#text"].as_str())
296                            .filter(|s| !s.is_empty())
297                            .map(|s| s.replace("/34s/", "/300x300/"));
298                        let length = t["duration"]
299                            .as_str()
300                            .and_then(|s| s.parse::<u64>().ok())
301                            .or_else(|| t["duration"].as_u64())
302                            .unwrap_or(0)
303                            * 1000;
304                        results.push(Track::new(TrackInfo {
305                            identifier: t_url.clone(),
306                            is_seekable: true,
307                            author: artist.to_owned(),
308                            title,
309                            length,
310                            uri: Some(t_url),
311                            artwork_url,
312                            source_name: "lastfm".to_owned(),
313                            ..Default::default()
314                        }));
315                    }
316                    return LoadResult::Playlist(PlaylistData {
317                        info: PlaylistInfo {
318                            name: format!("{} - Top Tracks", artist),
319                            selected_track: -1,
320                        },
321                        plugin_info: serde_json::json!({}),
322                        tracks: results,
323                    });
324                }
325            }
326            let body = match self.http.get(url).send().await {
327                Ok(r) => r.text().await.unwrap_or_else(|e| {
328                    tracing::debug!(
329                        "Last.fm: failed to get response text for artist tracks {}: {}",
330                        url,
331                        e
332                    );
333                    Default::default()
334                }),
335                Err(e) => {
336                    tracing::debug!(
337                        "Last.fm: artist tracks scraping request failed for {}: {}",
338                        url,
339                        e
340                    );
341                    return LoadResult::Empty {};
342                }
343            };
344            let mut results = Vec::new();
345            for caps in crate::sources::lastfm::search_regex().captures_iter(&body) {
346                let artwork_url = caps
347                    .get(1)
348                    .map(|m| m.as_str().replace("/64s/", "/300x300/"));
349                let title = unescape_html(caps.get(2).map(|m| m.as_str()).unwrap_or("Unknown"));
350                let full_url = crate::sources::lastfm::construct_track_url(artist, &title);
351                results.push(Track::new(TrackInfo {
352                    identifier: full_url.clone(),
353                    is_seekable: true,
354                    author: artist.to_owned(),
355                    title: title.to_owned(),
356                    uri: Some(full_url),
357                    artwork_url,
358                    source_name: "lastfm".to_owned(),
359                    ..Default::default()
360                }));
361            }
362            if results.is_empty() {
363                LoadResult::Empty {}
364            } else {
365                LoadResult::Playlist(PlaylistData {
366                    info: PlaylistInfo {
367                        name: format!("{} - Top Tracks", artist),
368                        selected_track: -1,
369                    },
370                    plugin_info: serde_json::json!({}),
371                    tracks: results,
372                })
373            }
374        }
375        pub async fn resolve_user(&self, username: &str, url: &str) -> LoadResult {
376            if let Some(ref key) = self.api_key {
377                let api_url = format!(
378                    "https://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user={}&api_key={}&limit=50&format=json",
379                    urlencoding::encode(username),
380                    key
381                );
382                if let Some(json) = get_json(&self.http, &api_url).await
383                    && let Some(tracks) = json["toptracks"]["track"].as_array()
384                {
385                    let mut results = Vec::new();
386                    for t in tracks {
387                        let title = t["name"].as_str().unwrap_or("Unknown").to_owned();
388                        let artist = t["artist"]["name"].as_str().unwrap_or("Unknown").to_owned();
389                        let t_url = crate::sources::lastfm::construct_track_url(&artist, &title);
390                        let artwork_url = t["image"]
391                            .as_array()
392                            .and_then(|images| images.last())
393                            .and_then(|img| img["#text"].as_str())
394                            .filter(|s| !s.is_empty())
395                            .map(|s| s.replace("/34s/", "/300x300/"));
396                        let length = t["duration"]
397                            .as_str()
398                            .and_then(|s| s.parse::<u64>().ok())
399                            .or_else(|| t["duration"].as_u64())
400                            .unwrap_or(0)
401                            * 1000;
402                        results.push(Track::new(TrackInfo {
403                            identifier: t_url.clone(),
404                            is_seekable: true,
405                            author: artist,
406                            title,
407                            length,
408                            uri: Some(t_url),
409                            artwork_url,
410                            source_name: "lastfm".to_owned(),
411                            ..Default::default()
412                        }));
413                    }
414                    return LoadResult::Playlist(PlaylistData {
415                        info: PlaylistInfo {
416                            name: format!("{}'s Top Tracks", username),
417                            selected_track: -1,
418                        },
419                        plugin_info: serde_json::json!({}),
420                        tracks: results,
421                    });
422                }
423            }
424            let body = match self.http.get(url).send().await {
425                Ok(r) => r.text().await.unwrap_or_else(|e| {
426                    tracing::debug!(
427                        "Last.fm: failed to get response text for user profile {}: {}",
428                        url,
429                        e
430                    );
431                    Default::default()
432                }),
433                Err(e) => {
434                    tracing::debug!(
435                        "Last.fm: user profile scraping request failed for {}: {}",
436                        url,
437                        e
438                    );
439                    return LoadResult::Empty {};
440                }
441            };
442            let mut results = Vec::new();
443            for caps in crate::sources::lastfm::search_regex().captures_iter(&body) {
444                let artwork_url = caps
445                    .get(1)
446                    .map(|m| m.as_str().replace("/64s/", "/300x300/"));
447                let title = unescape_html(caps.get(2).map(|m| m.as_str()).unwrap_or("Unknown"));
448                let artist = unescape_html(caps.get(4).map(|m| m.as_str()).unwrap_or("Unknown"));
449                let full_url = crate::sources::lastfm::construct_track_url(&artist, &title);
450                results.push(Track::new(TrackInfo {
451                    identifier: full_url.clone(),
452                    is_seekable: true,
453                    author: artist,
454                    title,
455                    uri: Some(full_url),
456                    artwork_url,
457                    source_name: "lastfm".to_owned(),
458                    ..Default::default()
459                }));
460            }
461            if results.is_empty() {
462                LoadResult::Empty {}
463            } else {
464                LoadResult::Playlist(PlaylistData {
465                    info: PlaylistInfo {
466                        name: format!("{}'s Recent Tracks", username),
467                        selected_track: -1,
468                    },
469                    plugin_info: serde_json::json!({}),
470                    tracks: results,
471                })
472            }
473        }
474    }
475}
476pub mod search {
477    use super::{
478        LastFMSource,
479        helpers::{get_json, unescape_html},
480    };
481    use crate::protocol::tracks::{LoadResult, Track, TrackInfo};
482    impl LastFMSource {
483        pub async fn search_tracks(&self, query: &str) -> LoadResult {
484            if let Some(ref key) = self.api_key {
485                self.search_api(query, key).await
486            } else {
487                self.search_scraping(query).await
488            }
489        }
490        async fn search_api(&self, query: &str, api_key: &str) -> LoadResult {
491            let url = format!(
492                "https://ws.audioscrobbler.com/2.0/?method=track.search&track={}&api_key={}&limit={}&format=json",
493                urlencoding::encode(query),
494                api_key,
495                self.search_limit
496            );
497            let json = match get_json(&self.http, &url).await {
498                Some(j) => j,
499                None => return LoadResult::Empty {},
500            };
501            let tracks = match json["results"]["trackmatches"]["track"].as_array() {
502                Some(t) => t,
503                None => {
504                    tracing::debug!(
505                        "Last.fm: API response missing trackmatches for search '{}'",
506                        query
507                    );
508                    return LoadResult::Empty {};
509                }
510            };
511            let results: Vec<Track> = tracks
512                .iter()
513                .map(|t| {
514                    let title = t["name"].as_str().unwrap_or("Unknown").to_owned();
515                    let artist = t["artist"].as_str().unwrap_or("Unknown").to_owned();
516                    let uri = crate::sources::lastfm::construct_track_url(&artist, &title);
517                    let artwork_url = t["image"]
518                        .as_array()
519                        .and_then(|images| images.last())
520                        .and_then(|img| img["#text"].as_str())
521                        .filter(|s| !s.is_empty())
522                        .map(|s| s.replace("/34s/", "/300x300/"));
523                    Track::new(TrackInfo {
524                        identifier: uri.clone(),
525                        is_seekable: true,
526                        author: artist,
527                        title,
528                        uri: Some(uri),
529                        artwork_url,
530                        source_name: "lastfm".to_owned(),
531                        ..Default::default()
532                    })
533                })
534                .collect();
535            if results.is_empty() {
536                tracing::debug!("Last.fm: API search returned no tracks for '{}'", query);
537                LoadResult::Empty {}
538            } else {
539                LoadResult::Search(results)
540            }
541        }
542        async fn search_scraping(&self, query: &str) -> LoadResult {
543            let url = format!(
544                "https://www.last.fm/search/tracks?q={}",
545                urlencoding::encode(query)
546            );
547            let body = match self.http.get(&url).send().await {
548                Ok(r) => r.text().await.unwrap_or_else(|e| {
549                    tracing::debug!(
550                        "Last.fm: failed to get response text for search scraping '{}': {}",
551                        query,
552                        e
553                    );
554                    Default::default()
555                }),
556                Err(e) => {
557                    tracing::debug!(
558                        "Last.fm: search scraping request failed for '{}': {}",
559                        query,
560                        e
561                    );
562                    return LoadResult::Empty {};
563                }
564            };
565            let mut results = Vec::new();
566            for caps in crate::sources::lastfm::search_regex().captures_iter(&body) {
567                let artwork_url = caps
568                    .get(1)
569                    .map(|m| m.as_str().replace("/64s/", "/300x300/"));
570                let title = unescape_html(caps.get(2).map(|m| m.as_str()).unwrap_or("Unknown"));
571                let artist = unescape_html(caps.get(4).map(|m| m.as_str()).unwrap_or("Unknown"));
572                let full_url = crate::sources::lastfm::construct_track_url(&artist, &title);
573                results.push(Track::new(TrackInfo {
574                    identifier: full_url.clone(),
575                    is_seekable: true,
576                    author: artist,
577                    title,
578                    uri: Some(full_url),
579                    artwork_url,
580                    source_name: "lastfm".to_owned(),
581                    ..Default::default()
582                }));
583                if results.len() >= self.search_limit {
584                    break;
585                }
586            }
587            if results.is_empty() {
588                tracing::debug!(
589                    "Last.fm: search scraping found no tracks for '{}' on page {}",
590                    query,
591                    url
592                );
593                LoadResult::Empty {}
594            } else {
595                LoadResult::Search(results)
596            }
597        }
598    }
599}
600use crate::{
601    protocol::tracks::LoadResult,
602    sources::{SourcePlugin, playable_track::BoxedTrack},
603};
604use async_trait::async_trait;
605use regex::Regex;
606use std::sync::{Arc, OnceLock};
607pub fn path_regex() -> &'static Regex {
608    static REGEX: OnceLock<Regex> = OnceLock::new();
609    REGEX.get_or_init(|| {
610        Regex::new(r"https?://(?:www\.)?last\.fm/(?:[a-z]{2}/)?(music|user)/([^/]+)(?:/([^/]+)(?:/([^/]+))?)?")
611            .expect("lastfm path regex is a valid literal")
612    })
613}
614pub fn search_regex() -> &'static Regex {
615    static REGEX: OnceLock<Regex> = OnceLock::new();
616    REGEX.get_or_init(|| {
617        Regex::new(r#"(?i)<tr[^>]*?>[\s\S]*?<img[^>]*?src="([^"]+)"[\s\S]*?data-track-name="([^"]+)"[\s\S]*?data-track-url="([^"]+)"[\s\S]*?data-artist-name="([^"]+)"#)
618            .expect("lastfm search regex is a valid literal")
619    })
620}
621pub fn encode_path_segment(segment: &str) -> String {
622    urlencoding::encode(segment).replace("%20", "+")
623}
624pub fn construct_track_url(artist: &str, track: &str) -> String {
625    format!(
626        "https://www.last.fm/music/{}/_/{}",
627        encode_path_segment(artist),
628        encode_path_segment(track)
629    )
630}
631pub struct LastFMSource {
632    pub http: Arc<reqwest::Client>,
633    pub api_key: Option<String>,
634    pub search_limit: usize,
635}
636impl LastFMSource {
637    pub fn new(
638        config: Option<crate::config::sources::LastFmConfig>,
639        http: Arc<reqwest::Client>,
640    ) -> Result<Self, String> {
641        let (api_key, search_limit) = if let Some(c) = config {
642            (c.api_key, c.search_limit)
643        } else {
644            (None, 10)
645        };
646        Ok(Self {
647            http,
648            api_key,
649            search_limit,
650        })
651    }
652}
653#[async_trait]
654impl SourcePlugin for LastFMSource {
655    fn name(&self) -> &str {
656        "lastfm"
657    }
658    fn search_prefixes(&self) -> Vec<&str> {
659        vec!["lfsearch:", "lfmsearch:"]
660    }
661    fn can_handle(&self, identifier: &str) -> bool {
662        self.search_prefixes()
663            .iter()
664            .any(|p| identifier.starts_with(p))
665            || path_regex().is_match(identifier)
666    }
667    fn is_mirror(&self) -> bool {
668        true
669    }
670    async fn load(
671        &self,
672        identifier: &str,
673        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
674    ) -> LoadResult {
675        if let Some(prefix) = self
676            .search_prefixes()
677            .into_iter()
678            .find(|p| identifier.starts_with(p))
679        {
680            let query = &identifier[prefix.len()..];
681            self.search_tracks(query).await
682        } else {
683            self.resolve_url(identifier).await
684        }
685    }
686    async fn get_track(
687        &self,
688        _identifier: &str,
689        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
690    ) -> Option<BoxedTrack> {
691        None
692    }
693}