tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

lib.rs (4780B)


      1 use clap::Parser;
      2 use csv::Writer;
      3 use reqwest::blocking::Client;
      4 use serde::{Deserialize, Serialize};
      5 use std::error::Error;
      6 use std::fs::File;
      7 
      8 const TRACKS_PER_PAGE: u32 = 200;
      9 const API_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/";
     10 
     11 #[derive(Parser, Debug)]
     12 #[command(name = "lastfm-csv-export")]
     13 #[command(about = "Export Last.fm scrobbles to a .csv file", long_about = None)]
     14 pub struct Args {
     15     /// Last.fm username
     16     #[arg(short, long)]
     17     username: String,
     18 
     19     /// Last.fm API key
     20     #[arg(short, long)]
     21     api_key: String,
     22 
     23     /// Output CSV file path
     24     #[arg(short, long, default_value = "scrobbles.csv")]
     25     output: String,
     26 
     27     /// Start timestamp (Unix timestamp)
     28     #[arg(long)]
     29     from: Option<u64>,
     30 
     31     /// End timestamp (Unix timestamp)
     32     #[arg(long)]
     33     to: Option<u64>,
     34 
     35     /// Maximum number of pages to fetch (200 tracks per page)
     36     #[arg(short, long)]
     37     limit: Option<u32>,
     38 }
     39 
     40 #[derive(Debug, Deserialize)]
     41 struct LastFmResponse {
     42     recenttracks: RecentTracks,
     43 }
     44 
     45 #[derive(Debug, Deserialize)]
     46 struct RecentTracks {
     47     track: Vec<Track>,
     48     #[serde(rename = "@attr")]
     49     attr: TrackAttributes,
     50 }
     51 
     52 #[derive(Debug, Deserialize)]
     53 struct TrackAttributes {
     54     total: String,
     55     #[serde(rename = "totalPages")]
     56     total_pages: String,
     57 }
     58 
     59 #[derive(Debug, Deserialize)]
     60 struct Track {
     61     artist: ArtistInfo,
     62     album: AlbumInfo,
     63     name: String,
     64     date: Option<DateInfo>,
     65 }
     66 
     67 #[derive(Debug, Deserialize)]
     68 struct ArtistInfo {
     69     #[serde(rename = "#text")]
     70     text: String,
     71 }
     72 
     73 #[derive(Debug, Deserialize)]
     74 struct AlbumInfo {
     75     #[serde(rename = "#text")]
     76     text: String,
     77 }
     78 
     79 #[derive(Debug, Deserialize)]
     80 struct DateInfo {
     81     #[serde(rename = "#text")]
     82     text: String,
     83 }
     84 
     85 #[derive(Debug, Serialize)]
     86 struct CsvRecord {
     87     artist: String,
     88     album: String,
     89     track: String,
     90     date: String,
     91 }
     92 
     93 /// Parse the command line, fetch the scrobbles, and write the CSV.
     94 pub fn run() -> Result<(), Box<dyn Error>> {
     95     let args = Args::parse();
     96 
     97     println!("Fetching scrobbles for user: {}", args.username);
     98 
     99     let client = Client::new();
    100     let tracks = fetch_all_tracks(&client, &args)?;
    101 
    102     println!("Writing {} tracks to {}", tracks.len(), args.output);
    103     write_csv(&args.output, &tracks)?;
    104 
    105     println!("Done!");
    106     Ok(())
    107 }
    108 
    109 fn fetch_all_tracks(client: &Client, args: &Args) -> Result<Vec<Track>, Box<dyn Error>> {
    110     let mut all_tracks = Vec::new();
    111 
    112     // First request to get total pages
    113     let first_response = fetch_page(client, args, 1)?;
    114     let total_pages: u32 = first_response.recenttracks.attr.total_pages.parse()?;
    115     let total_tracks: u32 = first_response.recenttracks.attr.total.parse()?;
    116 
    117     println!("Total tracks: {}", total_tracks);
    118     println!("Total pages: {}", total_pages);
    119 
    120     // Add tracks from first page
    121     all_tracks.extend(first_response.recenttracks.track);
    122 
    123     // Determine how many pages to fetch
    124     let max_page = args
    125         .limit
    126         .map_or(total_pages, |limit| limit.min(total_pages));
    127 
    128     // Fetch remaining pages
    129     for page in 2..=max_page {
    130         println!("Fetching page {}/{}", page, max_page);
    131         let response = fetch_page(client, args, page)?;
    132         all_tracks.extend(response.recenttracks.track);
    133     }
    134 
    135     Ok(all_tracks)
    136 }
    137 
    138 fn fetch_page(client: &Client, args: &Args, page: u32) -> Result<LastFmResponse, Box<dyn Error>> {
    139     let mut url = format!(
    140         "{}?method=user.getrecenttracks&user={}&api_key={}&format=json&limit={}&page={}",
    141         API_BASE_URL, args.username, args.api_key, TRACKS_PER_PAGE, page
    142     );
    143 
    144     if let Some(from) = args.from {
    145         url.push_str(&format!("&from={}", from));
    146     }
    147 
    148     if let Some(to) = args.to {
    149         url.push_str(&format!("&to={}", to));
    150     }
    151 
    152     let response = client.get(&url).send()?;
    153 
    154     if !response.status().is_success() {
    155         return Err(format!("API request failed with status: {}", response.status()).into());
    156     }
    157 
    158     Ok(response.json()?)
    159 }
    160 
    161 fn write_csv(path: &str, tracks: &[Track]) -> Result<(), Box<dyn Error>> {
    162     let file = File::create(path)?;
    163     let mut writer = Writer::from_writer(file);
    164 
    165     // Write empty header row as Maloja doesn't expect it
    166     writer.write_record(["", "", "", ""])?;
    167 
    168     for track in tracks {
    169         let date_str = track
    170             .date
    171             .as_ref()
    172             .map(|date| {
    173                 // Input format: "29 Sep 2025, 15:32"
    174                 date.text.replace(", ", " ")
    175             })
    176             .unwrap_or_default();
    177 
    178         let record = CsvRecord {
    179             artist: track.artist.text.clone(),
    180             album: track.album.text.clone(),
    181             track: track.name.clone(),
    182             date: date_str,
    183         };
    184 
    185         writer.serialize(&record)?;
    186     }
    187 
    188     writer.flush()?;
    189     Ok(())
    190 }
    191