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:
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(())
+}
+