Compare commits

..

1 Commits

Author SHA1 Message Date
Connor Johnstone
0b336789da Formatting 2026-03-18 15:37:02 -04:00
5 changed files with 103 additions and 46 deletions

View File

@@ -9,6 +9,6 @@ pub mod matching;
pub use error::{WatchError, WatchResult}; pub use error::{WatchError, WatchResult};
pub use library::{ pub use library::{
AddSummary, LibrarySummary, WatchListEntry, add_album, add_artist, add_track, AddSummary, LibrarySummary, WatchListEntry, add_album, add_artist, add_track, library_summary,
library_summary, list_items, remove_item, list_items, remove_item,
}; };

View File

@@ -84,11 +84,12 @@ pub async fn add_artist(
.search_artist(&resolved_name, 1) .search_artist(&resolved_name, 1)
.await .await
.map_err(|e| WatchError::Other(format!("artist search failed: {e}")))?; .map_err(|e| WatchError::Other(format!("artist search failed: {e}")))?;
results results.into_iter().next().map(|a| a.mbid).ok_or_else(|| {
.into_iter() WatchError::Other(format!(
.next() "artist '{}' not found on MusicBrainz",
.map(|a| a.mbid) resolved_name
.ok_or_else(|| WatchError::Other(format!("artist '{}' not found on MusicBrainz", resolved_name)))? ))
})?
} }
}; };
@@ -117,7 +118,14 @@ pub async fn add_artist(
}; };
for track in &tracks { for track in &tracks {
match add_track_inner(conn, &resolved_name, &track.title, Some(&track.recording_mbid)).await { match add_track_inner(
conn,
&resolved_name,
&track.title,
Some(&track.recording_mbid),
)
.await
{
Ok(true) => summary.tracks_added += 1, Ok(true) => summary.tracks_added += 1,
Ok(false) => summary.tracks_already_owned += 1, Ok(false) => summary.tracks_already_owned += 1,
Err(e) => { Err(e) => {
@@ -153,11 +161,12 @@ pub async fn add_album(
.search_release(&resolved_artist, &resolved_album) .search_release(&resolved_artist, &resolved_album)
.await .await
.map_err(|e| WatchError::Other(format!("album search failed: {e}")))?; .map_err(|e| WatchError::Other(format!("album search failed: {e}")))?;
results results.into_iter().next().map(|r| r.mbid).ok_or_else(|| {
.into_iter() WatchError::Other(format!(
.next() "album '{}' not found on MusicBrainz",
.map(|r| r.mbid) resolved_album
.ok_or_else(|| WatchError::Other(format!("album '{}' not found on MusicBrainz", resolved_album)))? ))
})?
} }
}; };
@@ -171,7 +180,14 @@ pub async fn add_album(
let mut summary = AddSummary::default(); let mut summary = AddSummary::default();
for track in &tracks { for track in &tracks {
match add_track_inner(conn, &resolved_artist, &track.title, Some(&track.recording_mbid)).await { match add_track_inner(
conn,
&resolved_artist,
&track.title,
Some(&track.recording_mbid),
)
.await
{
Ok(true) => summary.tracks_added += 1, Ok(true) => summary.tracks_added += 1,
Ok(false) => summary.tracks_already_owned += 1, Ok(false) => summary.tracks_already_owned += 1,
Err(e) => { Err(e) => {
@@ -269,9 +285,8 @@ async fn resolve_artist_info(
return Ok((n.to_string(), mbid.map(String::from))); return Ok((n.to_string(), mbid.map(String::from)));
} }
let mbid = mbid.ok_or_else(|| { let mbid =
WatchError::Other("either a name or --mbid is required".into()) mbid.ok_or_else(|| WatchError::Other("either a name or --mbid is required".into()))?;
})?;
// Search for artist by MBID to get the name // Search for artist by MBID to get the name
let results = provider let results = provider
@@ -282,7 +297,10 @@ async fn resolve_artist_info(
if let Some(artist) = results.into_iter().next() { if let Some(artist) = results.into_iter().next() {
Ok((artist.name, Some(mbid.to_string()))) Ok((artist.name, Some(mbid.to_string())))
} else { } else {
Ok((format!("Artist [{}]", &mbid[..8.min(mbid.len())]), Some(mbid.to_string()))) Ok((
format!("Artist [{}]", &mbid[..8.min(mbid.len())]),
Some(mbid.to_string()),
))
} }
} }
@@ -297,7 +315,11 @@ async fn resolve_album_info(
album_name.filter(|s| !s.is_empty()), album_name.filter(|s| !s.is_empty()),
artist_name.filter(|s| !s.is_empty()), artist_name.filter(|s| !s.is_empty()),
) { ) {
return Ok((album.to_string(), artist.to_string(), mbid.map(String::from))); return Ok((
album.to_string(),
artist.to_string(),
mbid.map(String::from),
));
} }
let mbid = mbid.ok_or_else(|| { let mbid = mbid.ok_or_else(|| {
@@ -317,7 +339,9 @@ async fn resolve_album_info(
.unwrap_or_else(|| release.artist.clone()); .unwrap_or_else(|| release.artist.clone());
Ok((album, artist, Some(mbid.to_string()))) Ok((album, artist, Some(mbid.to_string())))
} else { } else {
Err(WatchError::Other(format!("no release found for MBID {mbid}"))) Err(WatchError::Other(format!(
"no release found for MBID {mbid}"
)))
} }
} }
@@ -335,9 +359,8 @@ async fn resolve_track_info(
return Ok((t.to_string(), a.to_string(), mbid.map(String::from))); return Ok((t.to_string(), a.to_string(), mbid.map(String::from)));
} }
let mbid = mbid.ok_or_else(|| { let mbid =
WatchError::Other("either artist+title or --mbid is required".into()) mbid.ok_or_else(|| WatchError::Other("either artist+title or --mbid is required".into()))?;
})?;
let details = provider let details = provider
.get_recording(mbid) .get_recording(mbid)
@@ -345,8 +368,14 @@ async fn resolve_track_info(
.map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?; .map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?;
Ok(( Ok((
title.filter(|s| !s.is_empty()).map(String::from).unwrap_or(details.title), title
artist_name.filter(|s| !s.is_empty()).map(String::from).unwrap_or(details.artist), .filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or(details.title),
artist_name
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or(details.artist),
Some(mbid.to_string()), Some(mbid.to_string()),
)) ))
} }

View File

@@ -96,7 +96,9 @@ fn parse_status(s: &str) -> anyhow::Result<WantedStatus> {
"available" => Ok(WantedStatus::Available), "available" => Ok(WantedStatus::Available),
"downloaded" => Ok(WantedStatus::Downloaded), "downloaded" => Ok(WantedStatus::Downloaded),
"owned" => Ok(WantedStatus::Owned), "owned" => Ok(WantedStatus::Owned),
_ => anyhow::bail!("unknown status: {s} (expected wanted, available, downloaded, or owned)"), _ => {
anyhow::bail!("unknown status: {s} (expected wanted, available, downloaded, or owned)")
}
} }
} }
@@ -128,16 +130,15 @@ async fn main() -> anyhow::Result<()> {
if name.is_none() && mbid.is_none() { if name.is_none() && mbid.is_none() {
anyhow::bail!("provide either a name or --mbid"); anyhow::bail!("provide either a name or --mbid");
} }
let summary = add_artist( let summary =
db.conn(), add_artist(db.conn(), name.as_deref(), mbid.as_deref(), &mb_client).await?;
name.as_deref(),
mbid.as_deref(),
&mb_client,
)
.await?;
println!("Artist watch: {summary}"); println!("Artist watch: {summary}");
} }
AddCommand::Album { artist, album, mbid } => { AddCommand::Album {
artist,
album,
mbid,
} => {
if artist.is_none() && album.is_none() && mbid.is_none() { if artist.is_none() && album.is_none() && mbid.is_none() {
anyhow::bail!("provide artist+album names or --mbid"); anyhow::bail!("provide artist+album names or --mbid");
} }
@@ -151,7 +152,11 @@ async fn main() -> anyhow::Result<()> {
.await?; .await?;
println!("Album watch: {summary}"); println!("Album watch: {summary}");
} }
AddCommand::Track { artist, title, mbid } => { AddCommand::Track {
artist,
title,
mbid,
} => {
if artist.is_none() && title.is_none() && mbid.is_none() { if artist.is_none() && title.is_none() && mbid.is_none() {
anyhow::bail!("provide artist+title or --mbid"); anyhow::bail!("provide artist+title or --mbid");
} }
@@ -180,8 +185,8 @@ async fn main() -> anyhow::Result<()> {
println!("Watchlist is empty."); println!("Watchlist is empty.");
} else { } else {
println!( println!(
"{:<5} {:<8} {:<12} {:<30} {}", "{:<5} {:<8} {:<12} {:<30} ARTIST",
"ID", "TYPE", "STATUS", "NAME", "ARTIST" "ID", "TYPE", "STATUS", "NAME"
); );
for e in &entries { for e in &entries {
println!( println!(

View File

@@ -7,7 +7,11 @@ use crate::error::WatchResult;
/// Normalize a string for fuzzy comparison: NFC unicode, lowercase, trim. /// Normalize a string for fuzzy comparison: NFC unicode, lowercase, trim.
pub fn normalize(s: &str) -> String { pub fn normalize(s: &str) -> String {
s.nfc().collect::<String>().to_lowercase().trim().to_string() s.nfc()
.collect::<String>()
.to_lowercase()
.trim()
.to_string()
} }
/// Check if an artist is "owned" — i.e., any tracks by this artist exist in the indexed library. /// Check if an artist is "owned" — i.e., any tracks by this artist exist in the indexed library.
@@ -52,7 +56,9 @@ pub async fn album_is_owned(
let norm_album = normalize(album_name); let norm_album = normalize(album_name);
// Try exact lookup // Try exact lookup
if let Some(album) = queries::albums::find_by_name_and_artist(conn, album_name, artist_name).await? { if let Some(album) =
queries::albums::find_by_name_and_artist(conn, album_name, artist_name).await?
{
let tracks = queries::tracks::get_by_album(conn, album.id).await?; let tracks = queries::tracks::get_by_album(conn, album.id).await?;
if !tracks.is_empty() { if !tracks.is_empty() {
return Ok(true); return Ok(true);

View File

@@ -42,7 +42,11 @@ async fn insert_track(db: &Database, artist: &str, title: &str, album: &str) {
struct MockProvider; struct MockProvider;
impl MetadataProvider for MockProvider { impl MetadataProvider for MockProvider {
async fn search_recording(&self, _artist: &str, _title: &str) -> TagResult<Vec<RecordingMatch>> { async fn search_recording(
&self,
_artist: &str,
_title: &str,
) -> TagResult<Vec<RecordingMatch>> {
Ok(vec![]) Ok(vec![])
} }
async fn search_release(&self, _artist: &str, _album: &str) -> TagResult<Vec<ReleaseMatch>> { async fn search_release(&self, _artist: &str, _album: &str) -> TagResult<Vec<ReleaseMatch>> {
@@ -69,7 +73,11 @@ impl MetadataProvider for MockProvider {
score: 100, score: 100,
}]) }])
} }
async fn get_artist_releases(&self, _mbid: &str, _limit: u32) -> TagResult<Vec<DiscographyEntry>> { async fn get_artist_releases(
&self,
_mbid: &str,
_limit: u32,
) -> TagResult<Vec<DiscographyEntry>> {
Ok(vec![DiscographyEntry { Ok(vec![DiscographyEntry {
mbid: "release-123".into(), mbid: "release-123".into(),
title: "Test Album".into(), title: "Test Album".into(),
@@ -97,7 +105,10 @@ impl MetadataProvider for MockProvider {
]) ])
} }
async fn get_artist_release_groups(&self, _artist_mbid: &str) -> TagResult<Vec<shanty_tag::provider::ReleaseGroupEntry>> { async fn get_artist_release_groups(
&self,
_artist_mbid: &str,
) -> TagResult<Vec<shanty_tag::provider::ReleaseGroupEntry>> {
Ok(vec![]) Ok(vec![])
} }
} }
@@ -133,7 +144,13 @@ async fn test_add_album_expands_to_tracks() {
let db = test_db().await; let db = test_db().await;
let provider = MockProvider; let provider = MockProvider;
let summary = add_album(db.conn(), Some("Test Artist"), Some("Test Album"), None, &provider) let summary = add_album(
db.conn(),
Some("Test Artist"),
Some("Test Album"),
None,
&provider,
)
.await .await
.unwrap(); .unwrap();
assert_eq!(summary.tracks_added, 2); assert_eq!(summary.tracks_added, 2);