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("&", "&")
47 .replace("<", "<")
48 .replace(">", ">")
49 .replace(""", "\"")
50 .replace("'", "'")
51 .replace("'", "'")
52 .replace("'", "'");
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}