lastfm_edit/scrobble_edit_context.rs
1use crate::{LastFmClient, Result, ScrobbleEdit};
2
3/// Context object that bridges track listing data with edit functionality.
4///
5/// This structure provides a high-level interface for editing scrobbles by combining
6/// track discovery data with edit operations. It handles the complexity of choosing
7/// between bulk edits and specific scrobble edits.
8///
9/// # Examples
10///
11/// ```rust,no_run
12/// use lastfm_edit::{ScrobbleEditContext, EditStrategy, IntoEditContext};
13/// use lastfm_edit::{LastFmClient, AsyncPaginatedIterator};
14///
15/// # tokio_test::block_on(async {
16/// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
17/// // client.login(...).await?;
18///
19/// // Find tracks and convert to edit contexts
20/// let mut tracks = client.artist_tracks("Radiohead");
21/// let first_track = tracks.next().await?.unwrap();
22/// let edit_context = first_track.into_edit_context();
23///
24/// // Execute a bulk edit
25/// let success = edit_context.execute_edit(
26/// &mut client,
27/// "Corrected Track Name".to_string(),
28/// None
29/// ).await?;
30///
31/// if success {
32/// println!("Edit completed successfully");
33/// }
34/// # Ok::<(), Box<dyn std::error::Error>>(())
35/// # });
36/// ```
37#[derive(Debug, Clone)]
38pub struct ScrobbleEditContext {
39 /// The track name to be edited
40 pub track_name: String,
41 /// The artist name
42 pub artist_name: String,
43
44 /// Edit strategy - determines how to perform the edit
45 pub strategy: EditStrategy,
46
47 /// Optional album information if available
48 pub album_name: Option<String>,
49
50 /// Playcount from track listing (informational)
51 ///
52 /// This gives an estimate of how many scrobbles might be affected
53 /// when using [`EditStrategy::EditAll`].
54 pub playcount: u32,
55}
56
57/// Strategy for performing scrobble edits.
58///
59/// This enum determines whether to edit all matching scrobbles or only
60/// specific instances identified by timestamps.
61///
62/// # Examples
63///
64/// ```rust
65/// use lastfm_edit::EditStrategy;
66///
67/// // Edit all instances - good for fixing systematic errors
68/// let bulk_strategy = EditStrategy::EditAll;
69///
70/// // Edit specific scrobbles - good for precision edits
71/// let specific_strategy = EditStrategy::SpecificScrobbles(vec![1640995200, 1641000000]);
72/// ```
73#[derive(Debug, Clone)]
74pub enum EditStrategy {
75 /// Edit all instances of this track/artist combination.
76 ///
77 /// Uses Last.fm's bulk edit functionality to update all scrobbles
78 /// with matching metadata. This is efficient but affects all instances.
79 EditAll,
80
81 /// Edit specific scrobbles with known timestamps.
82 ///
83 /// Targets only the scrobbles identified by the provided timestamps.
84 /// This requires fetching actual scrobble data first but provides
85 /// more precise control.
86 SpecificScrobbles(Vec<u64>),
87}
88
89impl ScrobbleEditContext {
90 /// Create an edit context from track listing data.
91 ///
92 /// This is the primary bridge between track discovery and edit functionality.
93 /// The context defaults to [`EditStrategy::EditAll`] for simplicity.
94 ///
95 /// # Arguments
96 ///
97 /// * `track_name` - The track name from track listings
98 /// * `artist_name` - The artist name
99 /// * `playcount` - Play count (gives estimate of affected scrobbles)
100 /// * `album_name` - Optional album name if available
101 ///
102 /// # Examples
103 ///
104 /// ```rust
105 /// use lastfm_edit::ScrobbleEditContext;
106 ///
107 /// let context = ScrobbleEditContext::from_track_listing(
108 /// "Paranoid Android".to_string(),
109 /// "Radiohead".to_string(),
110 /// 42,
111 /// Some("OK Computer".to_string())
112 /// );
113 /// ```
114 pub fn from_track_listing(
115 track_name: String,
116 artist_name: String,
117 playcount: u32,
118 album_name: Option<String>,
119 ) -> Self {
120 Self {
121 track_name,
122 artist_name,
123 album_name,
124 playcount,
125 strategy: EditStrategy::EditAll, // Default to edit_all for simplicity
126 }
127 }
128
129 /// Create a scrobble edit request from this context.
130 ///
131 /// This method generates a [`ScrobbleEdit`] based on the context's strategy
132 /// and the provided new values.
133 ///
134 /// # Arguments
135 ///
136 /// * `new_track_name` - The corrected track name
137 /// * `new_album_name` - Optional corrected album name
138 ///
139 /// # Examples
140 ///
141 /// ```rust
142 /// # use lastfm_edit::ScrobbleEditContext;
143 /// let context = ScrobbleEditContext::from_track_listing(
144 /// "Wrong Name".to_string(),
145 /// "Artist".to_string(),
146 /// 10,
147 /// None
148 /// );
149 ///
150 /// let edit = context.create_edit(
151 /// "Correct Name".to_string(),
152 /// Some("Album".to_string())
153 /// );
154 /// ```
155 pub fn create_edit(
156 &self,
157 new_track_name: String,
158 new_album_name: Option<String>,
159 ) -> ScrobbleEdit {
160 let original_album = self.album_name.as_deref().unwrap_or(&self.track_name);
161 let target_album = new_album_name.as_deref().unwrap_or(&new_track_name);
162
163 match &self.strategy {
164 EditStrategy::EditAll => {
165 ScrobbleEdit::from_track_info(
166 &self.track_name,
167 original_album,
168 &self.artist_name,
169 0, // No timestamp needed for edit_all
170 )
171 .with_track_name(&new_track_name)
172 .with_album_name(target_album)
173 .with_edit_all(true)
174 }
175 EditStrategy::SpecificScrobbles(timestamps) => {
176 // For now, just use the first timestamp
177 // In a full implementation, you'd want to handle multiple timestamps
178 let timestamp = timestamps.first().copied().unwrap_or(0);
179
180 ScrobbleEdit::from_track_info(
181 &self.track_name,
182 original_album,
183 &self.artist_name,
184 timestamp,
185 )
186 .with_track_name(&new_track_name)
187 .with_album_name(target_album)
188 .with_edit_all(false)
189 }
190 }
191 }
192
193 /// Execute the edit using the provided client.
194 ///
195 /// This method automatically handles the complexity of finding real scrobble
196 /// data when needed and submitting the edit request to Last.fm.
197 ///
198 /// # Arguments
199 ///
200 /// * `client` - Authenticated Last.fm client
201 /// * `new_track_name` - The corrected track name
202 /// * `new_album_name` - Optional corrected album name
203 ///
204 /// # Returns
205 ///
206 /// Returns `Ok(true)` if the edit was successful, `Ok(false)` if it failed,
207 /// or `Err(...)` if there was a network or other error.
208 ///
209 /// # Examples
210 ///
211 /// ```rust,no_run
212 /// # use lastfm_edit::{ScrobbleEditContext, LastFmClient};
213 /// # tokio_test::block_on(async {
214 /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
215 /// let context = ScrobbleEditContext::from_track_listing(
216 /// "Wrong Name".to_string(),
217 /// "Artist".to_string(),
218 /// 5,
219 /// None
220 /// );
221 ///
222 /// let success = context.execute_edit(
223 /// &mut client,
224 /// "Correct Name".to_string(),
225 /// None
226 /// ).await?;
227 /// # Ok::<(), Box<dyn std::error::Error>>(())
228 /// # });
229 /// ```
230 pub async fn execute_edit(
231 &self,
232 client: &mut LastFmClient,
233 new_track_name: String,
234 new_album_name: Option<String>,
235 ) -> Result<bool> {
236 // For EditAll strategy, try to get a real scrobble timestamp first
237 let edit = match &self.strategy {
238 EditStrategy::EditAll => {
239 // Try to find a recent scrobble to get real timestamp data
240 match client
241 .find_recent_scrobble_for_track(&self.track_name, &self.artist_name, 3)
242 .await?
243 {
244 Some(recent_scrobble) => {
245 if let Some(timestamp) = recent_scrobble.timestamp {
246 // Use real scrobble data for better compatibility
247 ScrobbleEdit::from_track_info(
248 &self.track_name,
249 &self.track_name, // Use track name as album fallback
250 &self.artist_name,
251 timestamp,
252 )
253 .with_track_name(&new_track_name)
254 .with_album_name(new_album_name.as_deref().unwrap_or(&new_track_name))
255 .with_edit_all(true)
256 } else {
257 // Fallback to original approach if no timestamp
258 self.create_edit(new_track_name.clone(), new_album_name.clone())
259 }
260 }
261 None => {
262 // No recent scrobble found, use original approach
263 self.create_edit(new_track_name.clone(), new_album_name.clone())
264 }
265 }
266 }
267 EditStrategy::SpecificScrobbles(_) => {
268 // For specific scrobbles, use the provided timestamps
269 self.create_edit(new_track_name.clone(), new_album_name.clone())
270 }
271 };
272
273 let response = client.edit_scrobble(&edit).await?;
274 Ok(response.success)
275 }
276
277 /// Execute the edit with real scrobble data lookup.
278 ///
279 /// This convenience method ensures that real scrobble timestamps are used
280 /// by explicitly searching the user's recent scrobbles first. This can be
281 /// more reliable than relying on track listing data.
282 ///
283 /// # Arguments
284 ///
285 /// * `client` - Authenticated Last.fm client
286 /// * `new_track_name` - The corrected track name
287 /// * `new_album_name` - Optional corrected album name
288 ///
289 /// # Returns
290 ///
291 /// Returns `Ok(true)` if successful, or an error if no recent scrobble
292 /// could be found or if the edit failed.
293 ///
294 /// # Examples
295 ///
296 /// ```rust,no_run
297 /// # use lastfm_edit::{ScrobbleEditContext, LastFmClient};
298 /// # tokio_test::block_on(async {
299 /// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
300 /// let context = ScrobbleEditContext::from_track_listing(
301 /// "Misspelled Track".to_string(),
302 /// "Artist".to_string(),
303 /// 1,
304 /// None
305 /// );
306 ///
307 /// // This will search recent scrobbles for real timestamp data
308 /// let success = context.execute_edit_with_real_data(
309 /// &mut client,
310 /// "Correctly Spelled Track".to_string(),
311 /// None
312 /// ).await?;
313 /// # Ok::<(), Box<dyn std::error::Error>>(())
314 /// # });
315 /// ```
316 pub async fn execute_edit_with_real_data(
317 &self,
318 client: &mut LastFmClient,
319 new_track_name: String,
320 new_album_name: Option<String>,
321 ) -> Result<bool> {
322 // First, try to find the most recent scrobble for this track
323 match client
324 .find_recent_scrobble_for_track(&self.track_name, &self.artist_name, 5)
325 .await?
326 {
327 Some(recent_scrobble) => {
328 if let Some(timestamp) = recent_scrobble.timestamp {
329 // Create edit with real scrobble data
330 let edit = ScrobbleEdit::from_track_info(
331 &recent_scrobble.name,
332 &recent_scrobble.name, // Use track name as album fallback
333 &recent_scrobble.artist,
334 timestamp,
335 )
336 .with_track_name(&new_track_name)
337 .with_album_name(new_album_name.as_deref().unwrap_or(&new_track_name))
338 .with_edit_all(true);
339
340 let response = client.edit_scrobble(&edit).await?;
341 Ok(response.success)
342 } else {
343 Err(crate::LastFmError::Parse(
344 "Found recent scrobble but no timestamp available".to_string(),
345 ))
346 }
347 }
348 None => Err(crate::LastFmError::Parse(format!(
349 "No recent scrobble found for '{}' by '{}'",
350 self.track_name, self.artist_name
351 ))),
352 }
353 }
354
355 /// Get a human-readable description of what this edit will do.
356 ///
357 /// This is useful for logging or user confirmation before executing edits.
358 ///
359 /// # Arguments
360 ///
361 /// * `new_track_name` - The target track name for the edit
362 ///
363 /// # Examples
364 ///
365 /// ```rust
366 /// # use lastfm_edit::ScrobbleEditContext;
367 /// let context = ScrobbleEditContext::from_track_listing(
368 /// "Old Name".to_string(),
369 /// "Artist".to_string(),
370 /// 15,
371 /// None
372 /// );
373 ///
374 /// let description = context.describe_edit("New Name");
375 /// println!("{}", description);
376 /// // Output: "Will edit ALL instances of 'Old Name' by 'Artist' to 'New Name' (approximately 15 scrobbles)"
377 /// ```
378 pub fn describe_edit(&self, new_track_name: &str) -> String {
379 match &self.strategy {
380 EditStrategy::EditAll => {
381 format!(
382 "Will edit ALL instances of '{}' by '{}' to '{}' (approximately {} scrobbles)",
383 self.track_name, self.artist_name, new_track_name, self.playcount
384 )
385 }
386 EditStrategy::SpecificScrobbles(timestamps) => {
387 format!(
388 "Will edit {} specific scrobbles of '{}' by '{}' to '{}'",
389 timestamps.len(),
390 self.track_name,
391 self.artist_name,
392 new_track_name
393 )
394 }
395 }
396 }
397}
398
399/// Helper trait to convert track data into edit contexts.
400///
401/// This trait provides a convenient way to transform track listing data
402/// into editable contexts, bridging the gap between discovery and editing.
403///
404/// # Examples
405///
406/// ```rust,no_run
407/// use lastfm_edit::{LastFmClient, AsyncPaginatedIterator, IntoEditContext};
408///
409/// # tokio_test::block_on(async {
410/// let mut client = LastFmClient::new(Box::new(http_client::native::NativeClient::new()));
411/// // client.login(...).await?;
412///
413/// let mut tracks = client.artist_tracks("Radiohead");
414/// if let Some(track) = tracks.next().await? {
415/// let edit_context = track.into_edit_context();
416///
417/// // Now ready to edit
418/// let success = edit_context.execute_edit(
419/// &mut client,
420/// "Fixed Track Name".to_string(),
421/// None
422/// ).await?;
423/// }
424/// # Ok::<(), Box<dyn std::error::Error>>(())
425/// # });
426/// ```
427pub trait IntoEditContext {
428 /// Convert this item into a [`ScrobbleEditContext`].
429 fn into_edit_context(self) -> ScrobbleEditContext;
430}
431
432impl IntoEditContext for crate::Track {
433 /// Convert a [`Track`](crate::Track) into a [`ScrobbleEditContext`].
434 ///
435 /// The resulting context uses [`EditStrategy::EditAll`] by default and
436 /// doesn't include album information since track listings don't have
437 /// reliable album data.
438 fn into_edit_context(self) -> ScrobbleEditContext {
439 ScrobbleEditContext::from_track_listing(
440 self.name,
441 self.artist,
442 self.playcount,
443 None, // Track listing doesn't have reliable album data
444 )
445 }
446}