tools

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

commit 553814591bc7bcc6f23377f4d6c612bb07b69cf6
parent ace4beff2303cc52988c0902bea63aef874adfcf
Author: mtmn <miro@haravara.org>
Date:   Sun, 14 Jun 2026 18:55:42 +0200

lastfm_rs: generate man page from clap

Diffstat:
Mdam/Cargo.toml | 4+---
Mdam/examples/mangen.rs | 6------
Alastfm_rs/examples/mangen.rs | 23+++++++++++++++++++++++
Alastfm_rs/lastfm_rs.1 | 31+++++++++++++++++++++++++++++++
Alastfm_rs/src/lib.rs | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 246 insertions(+), 9 deletions(-)

diff --git a/dam/Cargo.toml b/dam/Cargo.toml @@ -11,7 +11,5 @@ natord = "1.0" similar = "3.0.0" console = "0.16" -# Only used by the `mangen` example (man-page generation), so it never reaches -# the shipped binary. [dev-dependencies] -clap_mangen = "0.2" +clap_mangen = "0.3" diff --git a/dam/examples/mangen.rs b/dam/examples/mangen.rs @@ -1,9 +1,3 @@ -//! Render dam's man page straight from its clap `Command`, so the page can -//! never drift from `dam --help`. -//! -//! Run `cargo run --example mangen` to refresh the in-tree dam.1, or pass an -//! output path (as the Bazel genrule does) to write elsewhere. - use std::io::Write; use clap::CommandFactory; diff --git a/lastfm_rs/examples/mangen.rs b/lastfm_rs/examples/mangen.rs @@ -0,0 +1,23 @@ +use std::io::Write; + +use clap::CommandFactory; + +fn main() -> std::io::Result<()> { + let mut buffer = Vec::new(); + clap_mangen::Man::new(lastfm_rs::Args::command()).render(&mut buffer)?; + + match std::env::args().nth(1).or_else(default_out) { + Some(path) => { + std::fs::write(&path, buffer)?; + eprintln!("generated {path}"); + } + None => std::io::stdout().write_all(&buffer)?, + } + Ok(()) +} + +/// Default to lastfm_rs.1 beside this crate's manifest when run through cargo; +/// `option_env!` keeps it compiling under Bazel, where the path is passed in. +fn default_out() -> Option<String> { + option_env!("CARGO_MANIFEST_DIR").map(|dir| format!("{dir}/lastfm_rs.1")) +} diff --git a/lastfm_rs/lastfm_rs.1 b/lastfm_rs/lastfm_rs.1 @@ -0,0 +1,31 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH lastfm-csv-export 1 "lastfm-csv-export " +.SH NAME +lastfm\-csv\-export \- Export Last.fm scrobbles to a .csv file +.SH SYNOPSIS +\fBlastfm\-csv\-export\fR <\fB\-u\fR|\fB\-\-username\fR> <\fB\-a\fR|\fB\-\-api\-key\fR> [\fB\-o\fR|\fB\-\-output\fR] [\fB\-\-from\fR] [\fB\-\-to\fR] [\fB\-l\fR|\fB\-\-limit\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Export Last.fm scrobbles to a .csv file +.SH OPTIONS +.TP +\fB\-u\fR, \fB\-\-username\fR \fI<USERNAME>\fR +Last.fm username +.TP +\fB\-a\fR, \fB\-\-api\-key\fR \fI<API_KEY>\fR +Last.fm API key +.TP +\fB\-o\fR, \fB\-\-output\fR \fI<OUTPUT>\fR [default: scrobbles.csv] +Output CSV file path +.TP +\fB\-\-from\fR \fI<FROM>\fR +Start timestamp (Unix timestamp) +.TP +\fB\-\-to\fR \fI<TO>\fR +End timestamp (Unix timestamp) +.TP +\fB\-l\fR, \fB\-\-limit\fR \fI<LIMIT>\fR +Maximum number of pages to fetch (200 tracks per page) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/lastfm_rs/src/lib.rs b/lastfm_rs/src/lib.rs @@ -0,0 +1,191 @@ +use clap::Parser; +use csv::Writer; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fs::File; + +const TRACKS_PER_PAGE: u32 = 200; +const API_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; + +#[derive(Parser, Debug)] +#[command(name = "lastfm-csv-export")] +#[command(about = "Export Last.fm scrobbles to a .csv file", long_about = None)] +pub struct Args { + /// Last.fm username + #[arg(short, long)] + username: String, + + /// Last.fm API key + #[arg(short, long)] + api_key: String, + + /// Output CSV file path + #[arg(short, long, default_value = "scrobbles.csv")] + output: String, + + /// Start timestamp (Unix timestamp) + #[arg(long)] + from: Option<u64>, + + /// End timestamp (Unix timestamp) + #[arg(long)] + to: Option<u64>, + + /// Maximum number of pages to fetch (200 tracks per page) + #[arg(short, long)] + limit: Option<u32>, +} + +#[derive(Debug, Deserialize)] +struct LastFmResponse { + recenttracks: RecentTracks, +} + +#[derive(Debug, Deserialize)] +struct RecentTracks { + track: Vec<Track>, + #[serde(rename = "@attr")] + attr: TrackAttributes, +} + +#[derive(Debug, Deserialize)] +struct TrackAttributes { + total: String, + #[serde(rename = "totalPages")] + total_pages: String, +} + +#[derive(Debug, Deserialize)] +struct Track { + artist: ArtistInfo, + album: AlbumInfo, + name: String, + date: Option<DateInfo>, +} + +#[derive(Debug, Deserialize)] +struct ArtistInfo { + #[serde(rename = "#text")] + text: String, +} + +#[derive(Debug, Deserialize)] +struct AlbumInfo { + #[serde(rename = "#text")] + text: String, +} + +#[derive(Debug, Deserialize)] +struct DateInfo { + #[serde(rename = "#text")] + text: String, +} + +#[derive(Debug, Serialize)] +struct CsvRecord { + artist: String, + album: String, + track: String, + date: String, +} + +/// Parse the command line, fetch the scrobbles, and write the CSV. +pub fn run() -> Result<(), Box<dyn Error>> { + let args = Args::parse(); + + println!("Fetching scrobbles for user: {}", args.username); + + let client = Client::new(); + let tracks = fetch_all_tracks(&client, &args)?; + + println!("Writing {} tracks to {}", tracks.len(), args.output); + write_csv(&args.output, &tracks)?; + + println!("Done!"); + Ok(()) +} + +fn fetch_all_tracks(client: &Client, args: &Args) -> Result<Vec<Track>, Box<dyn Error>> { + let mut all_tracks = Vec::new(); + + // First request to get total pages + let first_response = fetch_page(client, args, 1)?; + let total_pages: u32 = first_response.recenttracks.attr.total_pages.parse()?; + let total_tracks: u32 = first_response.recenttracks.attr.total.parse()?; + + println!("Total tracks: {}", total_tracks); + println!("Total pages: {}", total_pages); + + // Add tracks from first page + all_tracks.extend(first_response.recenttracks.track); + + // Determine how many pages to fetch + let max_page = args + .limit + .map_or(total_pages, |limit| limit.min(total_pages)); + + // Fetch remaining pages + for page in 2..=max_page { + println!("Fetching page {}/{}", page, max_page); + let response = fetch_page(client, args, page)?; + all_tracks.extend(response.recenttracks.track); + } + + Ok(all_tracks) +} + +fn fetch_page(client: &Client, args: &Args, page: u32) -> Result<LastFmResponse, Box<dyn Error>> { + let mut url = format!( + "{}?method=user.getrecenttracks&user={}&api_key={}&format=json&limit={}&page={}", + API_BASE_URL, args.username, args.api_key, TRACKS_PER_PAGE, page + ); + + if let Some(from) = args.from { + url.push_str(&format!("&from={}", from)); + } + + if let Some(to) = args.to { + url.push_str(&format!("&to={}", to)); + } + + let response = client.get(&url).send()?; + + if !response.status().is_success() { + return Err(format!("API request failed with status: {}", response.status()).into()); + } + + Ok(response.json()?) +} + +fn write_csv(path: &str, tracks: &[Track]) -> Result<(), Box<dyn Error>> { + let file = File::create(path)?; + let mut writer = Writer::from_writer(file); + + // Write empty header row as Maloja doesn't expect it + writer.write_record(["", "", "", ""])?; + + for track in tracks { + let date_str = track + .date + .as_ref() + .map(|date| { + // Input format: "29 Sep 2025, 15:32" + date.text.replace(", ", " ") + }) + .unwrap_or_default(); + + let record = CsvRecord { + artist: track.artist.text.clone(), + album: track.album.text.clone(), + track: track.name.clone(), + date: date_str, + }; + + writer.serialize(&record)?; + } + + writer.flush()?; + Ok(()) +} +