lastfm_edit/lib.rs
1//! # lastfm-edit
2//!
3//! A Rust crate for programmatic access to Last.fm's scrobble editing functionality via web scraping.
4//!
5//! This crate provides a high-level interface for authenticating with Last.fm, browsing user libraries,
6//! and performing bulk edits on scrobbled tracks. It uses web scraping to access functionality not
7//! available through Last.fm's public API.
8//!
9//! ## Features
10//!
11//! - **Authentication**: Login to Last.fm with username/password
12//! - **Library browsing**: Paginated access to tracks, albums, and recent scrobbles
13//! - **Bulk editing**: Edit track names, artist names, and album information
14//! - **Async iterators**: Stream large datasets efficiently
15//! - **HTTP client abstraction**: Works with any HTTP client implementation
16//!
17//! ## Quick Start
18//!
19//! ```rust,no_run
20//! use lastfm_edit::{LastFmEditClient, LastFmEditClientImpl, AsyncPaginatedIterator, Result};
21//!
22//! #[tokio::main]
23//! async fn main() -> Result<()> {
24//! // Create client with any HTTP implementation
25//! let http_client = http_client::native::NativeClient::new();
26//! let mut client = LastFmEditClientImpl::new(Box::new(http_client));
27//!
28//! // Login to Last.fm
29//! client.login("username", "password").await?;
30//!
31//! // Browse recent tracks
32//! let mut recent_tracks = client.recent_tracks();
33//! while let Some(track) = recent_tracks.next().await? {
34//! println!("{} - {}", track.artist, track.name);
35//! }
36//!
37//! Ok(())
38//! }
39//! ```
40//!
41//! ## Core Components
42//!
43//! - [`LastFmClient`] - Main client for interacting with Last.fm
44//! - [`Track`], [`Album`] - Data structures for music metadata
45//! - [`AsyncPaginatedIterator`] - Trait for streaming paginated data
46//! - [`ScrobbleEdit`] - Represents track edit operations
47//! - [`LastFmError`] - Error types for the crate
48//!
49//! ## Installation
50//!
51//! Add this to your `Cargo.toml`:
52//! ```toml
53//! [dependencies]
54//! lastfm-edit = "0.1.0"
55//! http-client = { version = "6.5", features = ["curl_client"] }
56//! tokio = { version = "1.0", features = ["full"] }
57//! ```
58//!
59//! ## Usage Patterns
60//!
61//! ### Basic Library Browsing
62//!
63//! ```rust,no_run
64//! use lastfm_edit::{LastFmEditClient, LastFmEditClientImpl, AsyncPaginatedIterator, Result};
65//!
66//! #[tokio::main]
67//! async fn main() -> Result<()> {
68//! let http_client = http_client::native::NativeClient::new();
69//! let client = LastFmEditClientImpl::new(Box::new(http_client));
70//!
71//! client.login("username", "password").await?;
72//!
73//! // Get all tracks by an artist
74//! let mut tracks = client.artist_tracks("Radiohead");
75//! while let Some(track) = tracks.next().await? {
76//! println!("{} - {}", track.artist, track.name);
77//! }
78//!
79//! Ok(())
80//! }
81//! ```
82//!
83//! ### Bulk Track Editing
84//!
85//! ```rust,no_run
86//! use lastfm_edit::{LastFmEditClient, LastFmEditClientImpl, ScrobbleEdit, AsyncPaginatedIterator, Result};
87//!
88//! #[tokio::main]
89//! async fn main() -> Result<()> {
90//! let http_client = http_client::native::NativeClient::new();
91//! let client = LastFmEditClientImpl::new(Box::new(http_client));
92//!
93//! client.login("username", "password").await?;
94//!
95//! // Find and edit tracks
96//! let tracks = client.artist_tracks("Artist Name").collect_all().await?;
97//! for track in tracks {
98//! if track.name.contains("(Remaster)") {
99//! let new_name = track.name.replace(" (Remaster)", "");
100//!
101//! // Create edit for this track
102//! let edit = ScrobbleEdit::from_track_info(
103//! &track.name,
104//! &track.name, // Use track name as album fallback
105//! &track.artist,
106//! 0 // No timestamp needed for bulk edit
107//! )
108//! .with_track_name(&new_name)
109//! .with_edit_all(true);
110//!
111//! let response = client.edit_scrobble(&edit).await?;
112//! if response.success() {
113//! println!("Successfully edited: {} -> {}", track.name, new_name);
114//! }
115//! }
116//! }
117//!
118//! Ok(())
119//! }
120//! ```
121//!
122//! ### Recent Tracks Monitoring
123//!
124//! ```rust,no_run
125//! use lastfm_edit::{LastFmEditClient, LastFmEditClientImpl, AsyncPaginatedIterator, Result};
126//!
127//! #[tokio::main]
128//! async fn main() -> Result<()> {
129//! let http_client = http_client::native::NativeClient::new();
130//! let client = LastFmEditClientImpl::new(Box::new(http_client));
131//!
132//! client.login("username", "password").await?;
133//!
134//! // Get recent tracks (first 100)
135//! let recent_tracks = client.recent_tracks().take(100).await?;
136//! println!("Found {} recent tracks", recent_tracks.len());
137//!
138//! Ok(())
139//! }
140//! ```
141//!
142//! ### Mocking for Testing
143//!
144//! Enable the `mock` feature to use `MockLastFmEditClient` for testing:
145//!
146//! ```toml
147//! [dev-dependencies]
148//! lastfm-edit = { version = "1.0.0", features = ["mock"] }
149//! mockall = "0.13"
150//! ```
151//!
152//! ```rust,ignore
153//! #[cfg(feature = "mock")]
154//! mod tests {
155//! use lastfm_edit::{LastFmEditClient, MockLastFmEditClient, Result, EditResponse, ScrobbleEdit};
156//! use mockall::predicate::*;
157//!
158//! #[tokio::test]
159//! async fn test_edit_workflow() -> Result<()> {
160//! let mut mock_client = MockLastFmEditClient::new();
161//!
162//! // Set up expectations
163//! mock_client
164//! .expect_login()
165//! .with(eq("testuser"), eq("testpass"))
166//! .times(1)
167//! .returning(|_, _| Ok(()));
168//!
169//! mock_client
170//! .expect_edit_scrobble()
171//! .times(1)
172//! .returning(|_| Ok(EditResponse {
173//! success: true,
174//! message: Some("Edit successful".to_string()),
175//! }));
176//!
177//! // Use as trait object
178//! let client: &dyn LastFmEditClient = &mock_client;
179//!
180//! client.login("testuser", "testpass").await?;
181//!
182//! let edit = ScrobbleEdit::new(
183//! Some("Old Track".to_string()),
184//! Some("Old Album".to_string()),
185//! Some("Old Artist".to_string()),
186//! Some("Old Artist".to_string()),
187//! "New Track".to_string(),
188//! "New Album".to_string(),
189//! "New Artist".to_string(),
190//! "New Artist".to_string(),
191//! 1640995200,
192//! false,
193//! );
194//!
195//! let response = client.edit_scrobble(&edit).await?;
196//! assert!(response.success);
197//!
198//! Ok(())
199//! }
200//! }
201//! ```
202//!
203//! ## License
204//!
205//! MIT
206
207pub mod album;
208pub mod client;
209pub mod discovery;
210pub mod discovery_iterator;
211pub mod edit;
212pub mod error;
213pub mod iterator;
214pub mod parsing;
215pub mod session;
216pub mod track;
217pub mod r#trait;
218
219pub use album::{Album, AlbumPage};
220pub use client::LastFmEditClientImpl;
221pub use discovery::{
222 AlbumTracksDiscovery, ArtistTracksDiscovery, ExactMatchDiscovery, TrackVariationsDiscovery,
223};
224pub use discovery_iterator::AsyncDiscoveryIterator;
225pub use r#trait::LastFmEditClient;
226
227// Re-export the mock when the mock feature is enabled
228pub use edit::{EditResponse, ExactScrobbleEdit, ScrobbleEdit, SingleEditResponse};
229pub use error::LastFmError;
230pub use iterator::{
231 AlbumTracksIterator, ArtistAlbumsIterator, ArtistTracksIterator, AsyncPaginatedIterator,
232 RecentTracksIterator,
233};
234#[cfg(feature = "mock")]
235pub use r#trait::MockLastFmEditClient;
236
237// Iterator-based convenience methods for the client
238impl LastFmEditClientImpl {
239 /// Create an iterator for browsing an artist's tracks from the user's library.
240 pub fn artist_tracks(&self, artist: &str) -> ArtistTracksIterator {
241 ArtistTracksIterator::new(self.clone(), artist.to_string())
242 }
243
244 /// Create an iterator for browsing an artist's albums from the user's library.
245 pub fn artist_albums(&self, artist: &str) -> ArtistAlbumsIterator {
246 ArtistAlbumsIterator::new(self.clone(), artist.to_string())
247 }
248
249 /// Create an iterator for browsing tracks in a specific album from the user's library.
250 pub fn album_tracks(&self, album_name: &str, artist_name: &str) -> AlbumTracksIterator {
251 AlbumTracksIterator::new(
252 self.clone(),
253 album_name.to_string(),
254 artist_name.to_string(),
255 )
256 }
257
258 /// Create an iterator for browsing the user's recent tracks/scrobbles.
259 pub fn recent_tracks(&self) -> RecentTracksIterator {
260 RecentTracksIterator::new(self.clone())
261 }
262
263 /// Create an iterator for browsing the user's recent tracks starting from a specific page.
264 pub fn recent_tracks_from_page(&self, starting_page: u32) -> RecentTracksIterator {
265 RecentTracksIterator::with_starting_page(self.clone(), starting_page)
266 }
267
268 /// Create an incremental discovery iterator for scrobble editing
269 ///
270 /// This returns the appropriate discovery iterator based on what fields are specified
271 /// in the ScrobbleEdit. The iterator yields `ExactScrobbleEdit` results incrementally,
272 /// which helps avoid rate limiting issues when discovering many scrobbles.
273 ///
274 /// Returns a `Box<dyn AsyncDiscoveryIterator<ExactScrobbleEdit>>` to handle the different
275 /// discovery strategies uniformly.
276 pub fn discover_scrobbles(
277 &self,
278 edit: ScrobbleEdit,
279 ) -> Box<dyn AsyncDiscoveryIterator<ExactScrobbleEdit>> {
280 let track_name = edit.track_name_original.clone();
281 let album_name = edit.album_name_original.clone();
282
283 match (&track_name, &album_name) {
284 // Case 1: Track+Album specified - exact match lookup
285 (Some(track_name), Some(album_name)) => Box::new(crate::ExactMatchDiscovery::new(
286 self.clone(),
287 edit,
288 track_name.clone(),
289 album_name.clone(),
290 )),
291
292 // Case 2: Track-specific discovery (discover all album variations of a specific track)
293 (Some(track_name), None) => Box::new(crate::TrackVariationsDiscovery::new(
294 self.clone(),
295 edit,
296 track_name.clone(),
297 )),
298
299 // Case 3: Album-specific discovery (discover all tracks in a specific album)
300 (None, Some(album_name)) => Box::new(crate::AlbumTracksDiscovery::new(
301 self.clone(),
302 edit,
303 album_name.clone(),
304 )),
305
306 // Case 4: Artist-specific discovery (discover all tracks by an artist)
307 (None, None) => Box::new(crate::ArtistTracksDiscovery::new(self.clone(), edit)),
308 }
309 }
310}
311
312// Re-export the mock iterator when the mock feature is enabled
313#[cfg(feature = "mock")]
314pub use iterator::MockAsyncPaginatedIterator;
315pub use session::LastFmEditSession;
316pub use track::{Track, TrackPage};
317
318// Re-export scraper types for testing
319pub use scraper::Html;
320
321/// A convenient type alias for [`Result`] with [`LastFmError`] as the error type.
322pub type Result<T> = std::result::Result<T, LastFmError>;