From 16e8962be132d508daa50935d1f9baad9f436da2 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Mon, 2 Mar 2026 21:46:39 -0500 Subject: [PATCH] Added last.fm support --- .gitignore | 1 + Cargo.lock | 342 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/lastfm.rs | 89 +++++++++++++ src/main.rs | 57 ++++++-- src/metadata.rs | 46 +++++-- 6 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 src/lastfm.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..fedaa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.env diff --git a/Cargo.lock b/Cargo.lock index 963987f..92eb93d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,34 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -35,6 +57,18 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -45,6 +79,45 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + [[package]] name = "lofty" version = "0.23.2" @@ -77,6 +150,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -96,17 +175,33 @@ dependencies = [ "byteorder", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "playlists" version = "0.1.0" dependencies = [ + "dotenvy", "lofty", + "serde", + "serde_json", + "ureq", "walkdir", ] @@ -128,6 +223,55 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "same-file" version = "1.0.6" @@ -137,12 +281,67 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -160,6 +359,47 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "walkdir" version = "2.5.0" @@ -170,13 +410,28 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -185,6 +440,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -193,3 +457,79 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index b759a6e..6122bf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +dotenvy = "0.15" lofty = "0.23" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ureq = "3" walkdir = "2.5" diff --git a/src/lastfm.rs b/src/lastfm.rs new file mode 100644 index 0000000..f61e9b9 --- /dev/null +++ b/src/lastfm.rs @@ -0,0 +1,89 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +const BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; + +pub struct LastfmClient { + api_key: String, + artist_cache: HashMap>, +} + +pub struct SimilarArtist { + pub name: String, + pub mbid: Option, + pub match_score: f64, +} + +// Last.fm returns {"error": N, "message": "..."} on failure +#[derive(Deserialize)] +struct ApiError { + error: u32, + message: String, +} + +// Deserialization structs for the Last.fm API responses + +#[derive(Deserialize)] +struct SimilarArtistsResponse { + similarartists: SimilarArtistsWrapper, +} + +#[derive(Deserialize)] +struct SimilarArtistsWrapper { + artist: Vec, +} + +#[derive(Deserialize)] +struct ArtistEntry { + name: String, + mbid: Option, + #[serde(rename = "match")] + match_score: String, +} + +impl LastfmClient { + pub fn new(api_key: String) -> Self { + Self { + api_key, + artist_cache: HashMap::new(), + } + } + + pub fn get_similar_artists( + &mut self, + artist_mbid: &str, + ) -> Result<&[SimilarArtist], Box> { + if !self.artist_cache.contains_key(artist_mbid) { + let url = format!( + "{}?method=artist.getSimilar&mbid={}&api_key={}&format=json", + BASE_URL, artist_mbid, self.api_key + ); + let body: String = ureq::get(&url).call()?.body_mut().read_to_string()?; + + let artists = if let Ok(err) = serde_json::from_str::(&body) { + eprintln!(" Last.fm: {}", err.message); + Vec::new() + } else { + let resp: SimilarArtistsResponse = serde_json::from_str(&body)?; + resp.similarartists + .artist + .into_iter() + .map(|a| { + let mbid = a.mbid.filter(|s| !s.is_empty()); + SimilarArtist { + name: a.name, + mbid, + match_score: a.match_score.parse().unwrap_or(0.0), + } + }) + .collect() + }; + + self.artist_cache.insert(artist_mbid.to_string(), artists); + } + + Ok(self.artist_cache.get(artist_mbid).unwrap()) + } + +} diff --git a/src/main.rs b/src/main.rs index bb64f70..8244e46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,30 +1,67 @@ mod filesystem; +mod lastfm; mod metadata; use std::env; use std::path::Path; fn main() { + dotenvy::dotenv().ok(); + let args: Vec = env::args().collect(); if args.len() != 2 { eprintln!("Usage: {} ", args[0]); std::process::exit(1); } + let api_key = env::var("LASTFM_API_KEY").unwrap_or_default(); + let mut lastfm = if api_key.is_empty() { + eprintln!("Warning: LASTFM_API_KEY not set, skipping similar artist lookups"); + None + } else { + Some(lastfm::LastfmClient::new(api_key)) + }; + let dir = Path::new(&args[1]); for path in filesystem::walk_music_files(dir) { - println!("{}", path.display()); - - match metadata::read_track_metadata(&path) { - Ok(Some(meta)) => { - let unknown = "(unknown)"; - println!(" Title: {}", meta.title.as_deref().unwrap_or(unknown)); - println!(" Artist: {}", meta.artist.as_deref().unwrap_or(unknown)); - println!(" Album: {}", meta.album.as_deref().unwrap_or(unknown)); + match metadata::read_all_metadata(&path) { + Ok(Some(entries)) => { + println!("{}", path.display()); + for entry in &entries { + println!(" {:30} {}", entry.key, entry.value); + } + } + Ok(None) => { + println!("{}", path.display()); + println!(" (no metadata tags found)"); + } + Err(e) => { + eprintln!("{}: could not read metadata: {e}", path.display()); + } + } + + if let Some(client) = lastfm.as_mut() { + let artist_mbid = match metadata::read_artist_mbid(&path) { + Ok(Some(mbid)) => mbid, + Ok(None) => continue, + Err(e) => { + eprintln!("{}: could not read artist MBID: {e}", path.display()); + continue; + } + }; + + match client.get_similar_artists(&artist_mbid) { + Ok(similar) => { + if !similar.is_empty() { + println!(" Similar artists:"); + for a in similar.iter().take(50) { + println!(" {:.2} {}", a.match_score, a.name); + } + } + } + Err(e) => eprintln!(" Warning: similar artists lookup failed: {e}"), } - Ok(None) => println!(" (no metadata tags found)"), - Err(e) => eprintln!(" warning: could not read metadata: {e}"), } } } diff --git a/src/metadata.rs b/src/metadata.rs index bd78f20..9a13b21 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,25 +1,47 @@ use std::path::Path; use lofty::file::TaggedFileExt; -use lofty::tag::Accessor; +use lofty::tag::{ItemKey, ItemValue}; -pub struct TrackMetadata { - pub title: Option, - pub artist: Option, - pub album: Option, +/// A single key-value metadata item from a tag. +pub struct TagEntry { + pub key: String, + pub value: String, } -/// Read metadata from a music file, returning `None` if no tags are present. -pub fn read_track_metadata(path: &Path) -> Result, lofty::error::LoftyError> { +/// Read all metadata items from a music file. +/// Returns `None` if no tags are present, otherwise a list of all tag entries. +pub fn read_all_metadata(path: &Path) -> Result>, lofty::error::LoftyError> { let tagged_file = lofty::read_from_path(path)?; let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else { return Ok(None); }; - Ok(Some(TrackMetadata { - title: tag.title().map(|s| s.into_owned()), - artist: tag.artist().map(|s| s.into_owned()), - album: tag.album().map(|s| s.into_owned()), - })) + let entries = tag + .items() + .filter_map(|item| { + let value = match item.value() { + ItemValue::Text(t) | ItemValue::Locator(t) => t.clone(), + ItemValue::Binary(b) => format!("<{} bytes>", b.len()), + }; + Some(TagEntry { + key: format!("{:?}", item.key()), + value, + }) + }) + .collect(); + + Ok(Some(entries)) +} + +/// Extract the MusicBrainz artist ID from a music file. +pub fn read_artist_mbid(path: &Path) -> Result, lofty::error::LoftyError> { + let tagged_file = lofty::read_from_path(path)?; + + let Some(tag) = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()) else { + return Ok(None); + }; + + Ok(tag.get_string(ItemKey::MusicBrainzArtistId).map(String::from)) }