1use crate::analytics::AnalysisHandler;
2use crate::client::HttpClient;
3use crate::config::Config;
4use crate::error::Result;
5use crate::file_handler::{FileFormat, FileHandler};
6use crate::types::{
7 RecentTrack, RecentTrackExtended, Timestamped, TrackLimit, TrackList, UserRecentTracks,
8 UserRecentTracksExtended,
9};
10use crate::url_builder::QueryParams;
11
12use serde::de::DeserializeOwned;
13use std::fmt;
14use std::sync::Arc;
15
16use super::fetch_utils::{ProgressCallback, ResourceContainer, fetch};
17
18pub struct RecentTracksClient {
20 http: Arc<dyn HttpClient>,
21 config: Arc<Config>,
22}
23
24impl fmt::Debug for RecentTracksClient {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 f.debug_struct("RecentTracksClient")
27 .field("config", &self.config)
28 .finish_non_exhaustive()
29 }
30}
31
32impl RecentTracksClient {
33 pub fn new(http: Arc<dyn HttpClient>, config: Arc<Config>) -> Self {
35 Self { http, config }
36 }
37
38 pub fn builder(&self, username: impl Into<String>) -> RecentTracksRequestBuilder {
40 RecentTracksRequestBuilder::new(self.http.clone(), self.config.clone(), username.into())
41 }
42}
43
44pub struct RecentTracksRequestBuilder {
46 http: Arc<dyn HttpClient>,
47 config: Arc<Config>,
48 username: String,
49 limit: Option<u32>,
50 from: Option<i64>,
51 to: Option<i64>,
52 extended: bool,
53 progress_callback: Option<ProgressCallback>,
54}
55
56impl fmt::Debug for RecentTracksRequestBuilder {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.debug_struct("RecentTracksRequestBuilder")
59 .field("username", &self.username)
60 .field("limit", &self.limit)
61 .field("from", &self.from)
62 .field("to", &self.to)
63 .field("extended", &self.extended)
64 .finish_non_exhaustive()
65 }
66}
67
68impl RecentTracksRequestBuilder {
69 fn new(http: Arc<dyn HttpClient>, config: Arc<Config>, username: String) -> Self {
70 Self {
71 http,
72 config,
73 username,
74 limit: None,
75 from: None,
76 to: None,
77 extended: false,
78 progress_callback: None,
79 }
80 }
81
82 #[must_use]
88 pub const fn limit(mut self, limit: u32) -> Self {
89 self.limit = Some(limit);
90 self
91 }
92
93 #[must_use]
95 pub const fn unlimited(mut self) -> Self {
96 self.limit = None;
97 self
98 }
99
100 #[must_use]
115 pub const fn since(mut self, timestamp: i64) -> Self {
116 self.from = Some(timestamp);
117 self
118 }
119
120 #[must_use]
136 pub const fn between(mut self, from: i64, to: i64) -> Self {
137 self.from = Some(from);
138 self.to = Some(to);
139 self
140 }
141
142 #[must_use]
144 pub const fn extended(mut self, extended: bool) -> Self {
145 self.extended = extended;
146 self
147 }
148
149 #[must_use]
151 pub fn on_progress(mut self, callback: impl Fn(u32, u32) + Send + Sync + 'static) -> Self {
152 self.progress_callback = Some(Arc::new(callback));
153 self
154 }
155
156 pub async fn fetch(self) -> Result<TrackList<RecentTrack>> {
163 if let (Some(from), Some(to)) = (self.from, self.to)
165 && to <= from
166 {
167 return Err(crate::error::LastFmError::Config(format!(
168 "Invalid date range: 'to' timestamp ({to}) must be greater than 'from' timestamp ({from})"
169 )));
170 }
171
172 let mut params = self.build_params();
173
174 if self.extended {
175 params.insert("extended".to_string(), "1".to_string());
176 }
177
178 let limit = self
179 .limit
180 .map_or(TrackLimit::Unlimited, TrackLimit::Limited);
181
182 self.fetch_tracks::<UserRecentTracks>(limit, params)
183 .await
184 .map(TrackList::from)
185 }
186
187 pub async fn fetch_extended(self) -> Result<TrackList<RecentTrackExtended>> {
194 if let (Some(from), Some(to)) = (self.from, self.to)
196 && to <= from
197 {
198 return Err(crate::error::LastFmError::Config(format!(
199 "Invalid date range: 'to' timestamp ({to}) must be greater than 'from' timestamp ({from})"
200 )));
201 }
202
203 let mut params = self.build_params();
204 params.insert("extended".to_string(), "1".to_string());
205
206 let limit = self
207 .limit
208 .map_or(TrackLimit::Unlimited, TrackLimit::Limited);
209
210 self.fetch_tracks_extended::<UserRecentTracksExtended>(limit, params)
211 .await
212 .map(TrackList::from)
213 }
214
215 pub async fn fetch_and_save(self, format: FileFormat, filename_prefix: &str) -> Result<String> {
227 let tracks = self.fetch().await?;
228 tracing::info!("Saving {} recent tracks to file", tracks.len());
229 let filename = FileHandler::save(&tracks, &format, filename_prefix)
230 .map_err(crate::error::LastFmError::Io)?;
231 if let Some(latest_ts) = tracks
232 .first()
233 .and_then(crate::types::Timestamped::get_timestamp)
234 {
235 FileHandler::write_sidecar_timestamp(&filename, latest_ts)
236 .map_err(crate::error::LastFmError::Io)?;
237 }
238 Ok(filename)
239 }
240
241 pub async fn fetch_extended_and_save(
253 self,
254 format: FileFormat,
255 filename_prefix: &str,
256 ) -> Result<String> {
257 let tracks = self.fetch_extended().await?;
258 tracing::info!("Saving {} recent tracks (extended) to file", tracks.len());
259
260 let filename = FileHandler::save(&tracks, &format, filename_prefix)
261 .map_err(crate::error::LastFmError::Io)?;
262
263 if let Some(latest_ts) = tracks
264 .first()
265 .and_then(crate::types::Timestamped::get_timestamp)
266 {
267 FileHandler::write_sidecar_timestamp(&filename, latest_ts)
268 .map_err(crate::error::LastFmError::Io)?;
269 }
270 Ok(filename)
271 }
272
273 pub async fn fetch_and_update(self, file_path: &str) -> Result<usize> {
286 self.update_impl(file_path, Self::fetch).await
287 }
288
289 pub async fn fetch_extended_and_update(self, file_path: &str) -> Result<usize> {
303 self.update_impl(file_path, Self::fetch_extended).await
304 }
305
306 async fn update_impl<T, F, Fut>(self, file_path: &str, fetch_fn: F) -> Result<usize>
307 where
308 T: serde::de::DeserializeOwned + serde::Serialize + Clone + Timestamped,
309 F: FnOnce(Self) -> Fut,
310 Fut: std::future::Future<Output = Result<TrackList<T>>>,
311 {
312 let ext = std::path::Path::new(file_path)
313 .extension()
314 .and_then(|e| e.to_str())
315 .map(str::to_ascii_lowercase);
316 let is_csv = ext.as_deref() == Some("csv");
317 let is_ndjson = ext.as_deref() == Some("ndjson");
318
319 let since_timestamp = if let Some(ts) = FileHandler::read_sidecar_timestamp(file_path) {
320 Some(ts)
322 } else if !is_csv && !is_ndjson && std::path::Path::new(file_path).exists() {
323 let existing: Vec<T> =
326 FileHandler::load(file_path).map_err(crate::error::LastFmError::Io)?;
327 let ts = existing.iter().filter_map(Timestamped::get_timestamp).max();
328 if let Some(t) = ts {
329 FileHandler::write_sidecar_timestamp(file_path, t)
330 .map_err(crate::error::LastFmError::Io)?;
331 }
332 ts
333 } else {
334 None
335 };
336
337 let builder = match since_timestamp {
338 Some(ts) => self.since(i64::from(ts) + 1),
339 None => self,
340 };
341
342 let new_tracks = fetch_fn(builder).await?;
343 let count = new_tracks.len();
344
345 if !new_tracks.is_empty() {
346 if let Some(latest_ts) = new_tracks.first().and_then(Timestamped::get_timestamp) {
347 FileHandler::write_sidecar_timestamp(file_path, latest_ts)
348 .map_err(crate::error::LastFmError::Io)?;
349 }
350 if is_csv {
351 FileHandler::append_or_create_csv(&new_tracks, file_path)
352 .map_err(crate::error::LastFmError::Io)?;
353 } else if is_ndjson {
354 FileHandler::append_or_create_ndjson(&new_tracks, file_path)
355 .map_err(crate::error::LastFmError::Io)?;
356 } else {
357 FileHandler::prepend_json(&new_tracks, file_path)
358 .map_err(crate::error::LastFmError::Io)?;
359 }
360 }
361
362 Ok(count)
363 }
364
365 #[cfg(feature = "sqlite")]
376 pub async fn fetch_and_save_sqlite(
377 self,
378 filename_prefix: &str,
379 ) -> crate::error::Result<String> {
380 let tracks = self.fetch().await?;
381 tracing::info!("Saving {} recent tracks to SQLite", tracks.len());
382 crate::file_handler::FileHandler::save_sqlite(&tracks, filename_prefix)
383 .map_err(crate::error::LastFmError::Io)
384 }
385
386 #[cfg(feature = "sqlite")]
402 pub async fn fetch_and_update_sqlite(self, db_path: &str) -> crate::error::Result<usize> {
403 let since_timestamp = crate::file_handler::FileHandler::read_sqlite_max_timestamp(
404 db_path,
405 <RecentTrack as crate::sqlite::SqliteExportable>::table_name(),
406 );
407
408 let builder = match since_timestamp {
409 Some(ts) => self.since(i64::from(ts) + 1),
410 None => self,
411 };
412
413 let new_tracks = builder.fetch().await?;
414 let count = new_tracks.len();
415
416 if !new_tracks.is_empty() {
417 crate::file_handler::FileHandler::append_or_create_sqlite(&new_tracks, db_path)
418 .map_err(crate::error::LastFmError::Io)?;
419 }
420
421 Ok(count)
422 }
423
424 #[cfg(feature = "sqlite")]
435 pub async fn fetch_extended_and_save_sqlite(
436 self,
437 filename_prefix: &str,
438 ) -> crate::error::Result<String> {
439 let tracks = self.fetch_extended().await?;
440 tracing::info!("Saving {} extended recent tracks to SQLite", tracks.len());
441 crate::file_handler::FileHandler::save_sqlite(&tracks, filename_prefix)
442 .map_err(crate::error::LastFmError::Io)
443 }
444
445 #[cfg(feature = "sqlite")]
461 pub async fn fetch_extended_and_update_sqlite(
462 self,
463 db_path: &str,
464 ) -> crate::error::Result<usize> {
465 let since_timestamp = crate::file_handler::FileHandler::read_sqlite_max_timestamp(
466 db_path,
467 <RecentTrackExtended as crate::sqlite::SqliteExportable>::table_name(),
468 );
469
470 let builder = match since_timestamp {
471 Some(ts) => self.since(i64::from(ts) + 1),
472 None => self,
473 };
474
475 let new_tracks = builder.fetch_extended().await?;
476 let count = new_tracks.len();
477
478 if !new_tracks.is_empty() {
479 crate::file_handler::FileHandler::append_or_create_sqlite(&new_tracks, db_path)
480 .map_err(crate::error::LastFmError::Io)?;
481 }
482
483 Ok(count)
484 }
485
486 pub async fn analyze(self, threshold: usize) -> Result<crate::analytics::TrackStats> {
499 let tracks = self.fetch().await?;
500 Ok(AnalysisHandler::analyze_tracks(&tracks, threshold))
501 }
502
503 pub async fn analyze_and_print(self, threshold: usize) -> Result<()> {
512 let stats = self.analyze(threshold).await?;
513 AnalysisHandler::print_analysis(&stats);
514 Ok(())
515 }
516
517 pub async fn check_currently_playing(self) -> Result<Option<RecentTrack>> {
525 let tracks = self.limit(1).fetch().await?;
526
527 Ok(tracks.first().and_then(|track| {
529 if track
530 .attr
531 .as_ref()
532 .is_some_and(|val| val.nowplaying == "true")
533 {
534 Some(track.clone())
535 } else {
536 None
537 }
538 }))
539 }
540
541 fn build_params(&self) -> QueryParams {
542 let mut params = QueryParams::new();
543
544 if let Some(from_timestamp) = self.from {
545 params.insert("from".to_string(), from_timestamp.to_string());
546 }
547
548 if let Some(to_timestamp) = self.to {
549 params.insert("to".to_string(), to_timestamp.to_string());
550 }
551
552 params
553 }
554
555 async fn fetch_tracks<T>(
556 &self,
557 limit: TrackLimit,
558 additional_params: QueryParams,
559 ) -> Result<Vec<RecentTrack>>
560 where
561 T: DeserializeOwned + ResourceContainer<ItemType = RecentTrack>,
562 {
563 fetch::<RecentTrack, T>(
564 self.http.clone(),
565 self.config.clone(),
566 self.username.clone(),
567 "user.getrecenttracks",
568 limit,
569 additional_params,
570 self.progress_callback.as_ref(),
571 )
572 .await
573 }
574
575 async fn fetch_tracks_extended<T>(
576 &self,
577 limit: TrackLimit,
578 additional_params: QueryParams,
579 ) -> Result<Vec<RecentTrackExtended>>
580 where
581 T: DeserializeOwned + ResourceContainer<ItemType = RecentTrackExtended>,
582 {
583 fetch::<RecentTrackExtended, T>(
584 self.http.clone(),
585 self.config.clone(),
586 self.username.clone(),
587 "user.getrecenttracks",
588 limit,
589 additional_params,
590 self.progress_callback.as_ref(),
591 )
592 .await
593 }
594}
595
596impl ResourceContainer for UserRecentTracks {
597 type ItemType = RecentTrack;
598
599 fn total(&self) -> u32 {
600 self.recenttracks.attr.total
601 }
602
603 fn items(self) -> Vec<Self::ItemType> {
604 self.recenttracks.track
605 }
606}
607
608impl ResourceContainer for UserRecentTracksExtended {
609 type ItemType = RecentTrackExtended;
610
611 fn total(&self) -> u32 {
612 self.recenttracks.attr.total
613 }
614
615 fn items(self) -> Vec<Self::ItemType> {
616 self.recenttracks.track
617 }
618}