tools

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

main.rs (4705B)


      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 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 fn main() -> Result<(), Box<dyn Error>> {
     94     let args = Args::parse();
     95 
     96     println!("Fetching scrobbles for user: {}", args.username);
     97 
     98     let client = Client::new();
     99     let tracks = fetch_all_tracks(&client, &args)?;
    100 
    101     println!("Writing {} tracks to {}", tracks.len(), args.output);
    102     write_csv(&args.output, &tracks)?;
    103 
    104     println!("Done!");
    105     Ok(())
    106 }
    107 
    108 fn fetch_all_tracks(client: &Client, args: &Args) -> Result<Vec<Track>, Box<dyn Error>> {
    109     let mut all_tracks = Vec::new();
    110 
    111     // First request to get total pages
    112     let first_response = fetch_page(client, args, 1)?;
    113     let total_pages: u32 = first_response.recenttracks.attr.total_pages.parse()?;
    114     let total_tracks: u32 = first_response.recenttracks.attr.total.parse()?;
    115 
    116     println!("Total tracks: {}", total_tracks);
    117     println!("Total pages: {}", total_pages);
    118 
    119     // Add tracks from first page
    120     all_tracks.extend(first_response.recenttracks.track);
    121 
    122     // Determine how many pages to fetch
    123     let max_page = args
    124         .limit
    125         .map_or(total_pages, |limit| limit.min(total_pages));
    126 
    127     // Fetch remaining pages
    128     for page in 2..=max_page {
    129         println!("Fetching page {}/{}", page, max_page);
    130         let response = fetch_page(client, args, page)?;
    131         all_tracks.extend(response.recenttracks.track);
    132     }
    133 
    134     Ok(all_tracks)
    135 }
    136 
    137 fn fetch_page(client: &Client, args: &Args, page: u32) -> Result<LastFmResponse, Box<dyn Error>> {
    138     let mut url = format!(
    139         "{}?method=user.getrecenttracks&user={}&api_key={}&format=json&limit={}&page={}",
    140         API_BASE_URL, args.username, args.api_key, TRACKS_PER_PAGE, page
    141     );
    142 
    143     if let Some(from) = args.from {
    144         url.push_str(&format!("&from={}", from));
    145     }
    146 
    147     if let Some(to) = args.to {
    148         url.push_str(&format!("&to={}", to));
    149     }
    150 
    151     let response = client.get(&url).send()?;
    152 
    153     if !response.status().is_success() {
    154         return Err(format!("API request failed with status: {}", response.status()).into());
    155     }
    156 
    157     Ok(response.json()?)
    158 }
    159 
    160 fn write_csv(path: &str, tracks: &[Track]) -> Result<(), Box<dyn Error>> {
    161     let file = File::create(path)?;
    162     let mut writer = Writer::from_writer(file);
    163 
    164     // Write empty header row as Maloja doesn't expect it
    165     writer.write_record(["", "", "", ""])?;
    166 
    167     for track in tracks {
    168         let date_str = track
    169             .date
    170             .as_ref()
    171             .map(|date| {
    172                 // Input format: "29 Sep 2025, 15:32"
    173                 date.text.replace(", ", " ")
    174             })
    175             .unwrap_or_default();
    176 
    177         let record = CsvRecord {
    178             artist: track.artist.text.clone(),
    179             album: track.album.text.clone(),
    180             track: track.name.clone(),
    181             date: date_str,
    182         };
    183 
    184         writer.serialize(&record)?;
    185     }
    186 
    187     writer.flush()?;
    188     Ok(())
    189 }
    190