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