lastfm-edit 7.0.1

Rust crate for programmatic access to Last.fm's scrobble editing functionality via web scraping
Documentation
use crate::{LastFmEditClient, Track};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteManifest {
    pub version: u32,
    pub generated_at_unix: u64,
    pub source: DeleteManifestSource,
    pub scrobbles: Vec<DeleteManifestEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteManifestSource {
    pub kind: String,
    pub range: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteManifestEntry {
    pub index: usize,
    pub offset: Option<u64>,
    pub artist: String,
    pub track: String,
    pub album: Option<String>,
    pub timestamp: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteTarget {
    pub offset: Option<u64>,
    pub artist: String,
    pub track: String,
    pub album: Option<String>,
    pub timestamp: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeleteAttemptResult {
    Deleted,
    NotDeleted { message: String },
    Error { message: String },
}

impl DeleteAttemptResult {
    pub fn success(&self) -> bool {
        matches!(self, Self::Deleted)
    }

    pub fn message(&self) -> Option<&str> {
        match self {
            Self::Deleted => None,
            Self::NotDeleted { message } | Self::Error { message } => Some(message),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteExecutionSummary {
    pub total_found: usize,
    pub successful_deletions: usize,
    pub failed_deletions: usize,
}

pub fn target_from_track(track: &Track, offset: Option<u64>, timestamp: u64) -> DeleteTarget {
    DeleteTarget {
        offset,
        artist: track.artist.clone(),
        track: track.name.clone(),
        album: track.album.clone(),
        timestamp,
    }
}

impl DeleteManifest {
    pub fn new(source: DeleteManifestSource, targets: &[DeleteTarget]) -> crate::Result<Self> {
        let generated_at_unix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|e| crate::LastFmError::Parse(e.to_string()))?
            .as_secs();

        Ok(Self {
            version: 1,
            generated_at_unix,
            source,
            scrobbles: targets
                .iter()
                .enumerate()
                .map(|(i, target)| DeleteManifestEntry {
                    index: i + 1,
                    offset: target.offset,
                    artist: target.artist.clone(),
                    track: target.track.clone(),
                    album: target.album.clone(),
                    timestamp: target.timestamp,
                })
                .collect(),
        })
    }

    pub fn targets(&self) -> Vec<DeleteTarget> {
        self.scrobbles
            .iter()
            .map(|entry| DeleteTarget {
                offset: entry.offset,
                artist: entry.artist.clone(),
                track: entry.track.clone(),
                album: entry.album.clone(),
                timestamp: entry.timestamp,
            })
            .collect()
    }
}

pub fn read_manifest(path: &Path) -> crate::Result<DeleteManifest> {
    let contents = fs::read_to_string(path)?;
    let manifest: DeleteManifest =
        serde_json::from_str(&contents).map_err(|e| crate::LastFmError::Parse(e.to_string()))?;

    if manifest.version != 1 {
        return Err(crate::LastFmError::Parse(format!(
            "Unsupported delete manifest version {} in '{}'",
            manifest.version,
            path.display()
        )));
    }

    Ok(manifest)
}

pub fn write_manifest(
    path: &Path,
    source: DeleteManifestSource,
    targets: &[DeleteTarget],
) -> crate::Result<()> {
    let manifest = DeleteManifest::new(source, targets)?;
    let json = serde_json::to_string_pretty(&manifest)
        .map_err(|e| crate::LastFmError::Parse(e.to_string()))?;
    fs::write(path, format!("{json}\n"))?;
    Ok(())
}

pub async fn execute_delete_targets<C, F>(
    client: &C,
    targets: &[DeleteTarget],
    delete_delay: Duration,
    mut on_attempt: F,
) -> crate::Result<DeleteExecutionSummary>
where
    C: LastFmEditClient + ?Sized,
    F: FnMut(usize, &DeleteTarget, &DeleteAttemptResult),
{
    let mut successful_deletions = 0;
    let mut failed_deletions = 0;

    for (i, target) in targets.iter().enumerate() {
        let result = match client
            .delete_scrobble(&target.artist, &target.track, target.timestamp)
            .await
        {
            Ok(true) => {
                successful_deletions += 1;
                DeleteAttemptResult::Deleted
            }
            Ok(false) => {
                failed_deletions += 1;
                let message = "Deletion failed; the scrobble may already be missing".to_string();
                log::warn!(
                    "Could not delete '{}' by '{}' at timestamp {}; {}",
                    target.track,
                    target.artist,
                    target.timestamp,
                    message
                );
                DeleteAttemptResult::NotDeleted { message }
            }
            Err(e) => {
                failed_deletions += 1;
                let message = e.to_string();
                log::warn!(
                    "Error deleting '{}' by '{}' at timestamp {}: {}",
                    target.track,
                    target.artist,
                    target.timestamp,
                    message
                );
                DeleteAttemptResult::Error { message }
            }
        };

        on_attempt(i + 1, target, &result);

        if i < targets.len() - 1 {
            tokio::time::sleep(delete_delay).await;
        }
    }

    Ok(DeleteExecutionSummary {
        total_found: targets.len(),
        successful_deletions,
        failed_deletions,
    })
}