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