Added last.fm support
This commit is contained in:
89
src/lastfm.rs
Normal file
89
src/lastfm.rs
Normal file
@@ -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<String, Vec<SimilarArtist>>,
|
||||
}
|
||||
|
||||
pub struct SimilarArtist {
|
||||
pub name: String,
|
||||
pub mbid: Option<String>,
|
||||
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<ArtistEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ArtistEntry {
|
||||
name: String,
|
||||
mbid: Option<String>,
|
||||
#[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<dyn std::error::Error>> {
|
||||
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::<ApiError>(&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())
|
||||
}
|
||||
|
||||
}
|
||||
57
src/main.rs
57
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<String> = env::args().collect();
|
||||
if args.len() != 2 {
|
||||
eprintln!("Usage: {} <directory>", 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
/// 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<Option<TrackMetadata>, 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<Option<Vec<TagEntry>>, 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<Option<String>, 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user