diff --git a/src/db.rs b/src/db.rs index 34a1d5c..ab5a28a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -15,6 +15,11 @@ pub fn open(path: &str) -> Result { similar_name TEXT NOT NULL, match_score REAL NOT NULL, PRIMARY KEY (artist_mbid, similar_name) + ); + CREATE TABLE IF NOT EXISTS tracks ( + path TEXT PRIMARY KEY, + artist_mbid TEXT NOT NULL REFERENCES artists(mbid), + recording_mbid TEXT );", )?; Ok(conn) @@ -28,6 +33,34 @@ pub fn artist_exists(conn: &Connection, mbid: &str) -> Result 0) } +pub fn get_available_similar_artists( + conn: &Connection, + artist_mbid: &str, +) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT sa.similar_name, sa.match_score + FROM similar_artists sa + JOIN artists a ON a.mbid = sa.similar_mbid + WHERE sa.artist_mbid = ?1 + ORDER BY sa.match_score DESC", + )?; + let rows = stmt.query_map([artist_mbid], |row| Ok((row.get(0)?, row.get(1)?)))?; + rows.collect() +} + +pub fn insert_track( + conn: &Connection, + path: &str, + artist_mbid: &str, + recording_mbid: Option<&str>, +) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT OR IGNORE INTO tracks (path, artist_mbid, recording_mbid) VALUES (?1, ?2, ?3)", + rusqlite::params![path, artist_mbid, recording_mbid], + )?; + Ok(()) +} + pub fn insert_artist_with_similar( conn: &Connection, mbid: &str, diff --git a/src/lastfm.rs b/src/lastfm.rs index fbebb88..b89a194 100644 --- a/src/lastfm.rs +++ b/src/lastfm.rs @@ -50,7 +50,7 @@ impl LastfmClient { artist_mbid: &str, ) -> Result, Box> { let url = format!( - "{}?method=artist.getSimilar&mbid={}&api_key={}&format=json", + "{}?method=artist.getSimilar&mbid={}&api_key={}&limit=500&format=json", BASE_URL, artist_mbid, self.api_key ); let body: String = ureq::get(&url).call()?.body_mut().read_to_string()?; diff --git a/src/main.rs b/src/main.rs index a9bedde..fab6053 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,32 @@ mod metadata; use std::env; use std::path::Path; +fn usage(program: &str) -> ! { + eprintln!("Usage:"); + eprintln!(" {program} index [-v] "); + eprintln!(" {program} build "); + std::process::exit(1); +} + fn main() { let args: Vec = env::args().collect(); - let verbose = args.iter().any(|a| a == "-v"); - let rest: Vec<&String> = args.iter().skip(1).filter(|a| *a != "-v").collect(); - if rest.len() != 2 || rest[0] != "index" { + if args.len() < 2 { + usage(&args[0]); + } + + match args[1].as_str() { + "index" => cmd_index(&args), + "build" => cmd_build(&args), + _ => usage(&args[0]), + } +} + +fn cmd_index(args: &[String]) { + let verbose = args.iter().any(|a| a == "-v"); + let rest: Vec<&String> = args.iter().skip(2).filter(|a| *a != "-v").collect(); + + if rest.len() != 1 { eprintln!("Usage: {} index [-v] ", args[0]); std::process::exit(1); } @@ -26,7 +46,7 @@ fn main() { let conn = db::open("playlists.db").expect("failed to open database"); let lastfm = lastfm::LastfmClient::new(api_key); - let dir = Path::new(rest[1].as_str()); + let dir = Path::new(rest[0].as_str()); for path in filesystem::walk_music_files(dir) { let artist_mbid = match metadata::read_artist_mbid(&path) { @@ -38,6 +58,8 @@ fn main() { } }; + let recording_mbid = metadata::read_track_mbid(&path).ok().flatten(); + let already_indexed = match db::artist_exists(&conn, &artist_mbid) { Ok(exists) => exists, Err(e) => { @@ -49,29 +71,69 @@ fn main() { let artist_name = metadata::read_artist_name(&path).ok().flatten(); let display_name = artist_name.as_deref().unwrap_or(&artist_mbid); - if already_indexed { + if !already_indexed { if verbose { - println!("Skipping {display_name} (already indexed)"); + println!("Indexing {display_name}..."); } - continue; - } - if verbose { - println!("Indexing {display_name}..."); - } - - match lastfm.get_similar_artists(&artist_mbid) { - Ok(similar) => { - if let Err(e) = db::insert_artist_with_similar( - &conn, - &artist_mbid, - artist_name.as_deref(), - &similar, - ) { - eprintln!("DB error inserting artist {artist_mbid}: {e}"); + match lastfm.get_similar_artists(&artist_mbid) { + Ok(similar) => { + if let Err(e) = db::insert_artist_with_similar( + &conn, + &artist_mbid, + artist_name.as_deref(), + &similar, + ) { + eprintln!("DB error inserting artist {artist_mbid}: {e}"); + continue; + } + } + Err(e) => { + eprintln!("Last.fm error for {artist_mbid}: {e}"); + continue; } } - Err(e) => eprintln!("Last.fm error for {artist_mbid}: {e}"), + } else if verbose { + println!("Skipping {display_name} (already indexed)"); + } + + let path_str = path.to_string_lossy(); + if let Err(e) = db::insert_track(&conn, &path_str, &artist_mbid, recording_mbid.as_deref()) { + eprintln!("DB error inserting track {}: {e}", path.display()); + } + } +} + +fn cmd_build(args: &[String]) { + if args.len() != 3 { + eprintln!("Usage: {} build ", args[0]); + std::process::exit(1); + } + + let path = Path::new(&args[2]); + let artist_mbid = match metadata::read_artist_mbid(path) { + Ok(Some(mbid)) => mbid, + Ok(None) => { + eprintln!("{}: no artist MBID found", path.display()); + std::process::exit(1); + } + Err(e) => { + eprintln!("{}: could not read artist MBID: {e}", path.display()); + std::process::exit(1); + } + }; + + let conn = db::open("playlists.db").expect("failed to open database"); + + match db::get_available_similar_artists(&conn, &artist_mbid) { + Ok(artists) => { + for (name, score) in &artists { + println!("{name} ({score:.4})"); + } + } + Err(e) => { + eprintln!("DB error: {e}"); + std::process::exit(1); } } } diff --git a/src/metadata.rs b/src/metadata.rs index 5195bb8..2dcaa17 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -56,3 +56,14 @@ pub fn read_artist_mbid(path: &Path) -> Result, lofty::error::Lof Ok(tag.get_string(ItemKey::MusicBrainzArtistId).map(String::from)) } + +/// Extract the MusicBrainz recording ID from a music file. +pub fn read_track_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::MusicBrainzRecordingId).map(String::from)) +}