Compare commits

..

1 Commits

Author SHA1 Message Date
Connor Johnstone
7bee8a9afa Updates for the "full flow" 2026-03-17 21:39:24 -04:00
4 changed files with 319 additions and 275 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::{
LibrarySummary, WatchListEntry, add_album, add_artist, add_track, library_summary, list_items, AddSummary, LibrarySummary, WatchListEntry, add_album, add_artist, add_track,
remove_item, library_summary, list_items, remove_item,
}; };

View File

@@ -21,12 +21,28 @@ pub struct WatchListEntry {
pub added_at: chrono::NaiveDateTime, pub added_at: chrono::NaiveDateTime,
} }
/// Summary of how many tracks were added when watching an artist or album.
#[derive(Debug, Default)]
pub struct AddSummary {
pub tracks_added: u64,
pub tracks_already_owned: u64,
pub errors: u64,
}
impl fmt::Display for AddSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"added: {}, already owned: {}, errors: {}",
self.tracks_added, self.tracks_already_owned, self.errors,
)
}
}
/// Summary statistics for the library. /// Summary statistics for the library.
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Serialize, Deserialize)]
pub struct LibrarySummary { pub struct LibrarySummary {
pub total_artists: u64, pub total_items: u64,
pub total_albums: u64,
pub total_tracks: u64,
pub wanted: u64, pub wanted: u64,
pub available: u64, pub available: u64,
pub downloaded: u64, pub downloaded: u64,
@@ -35,8 +51,7 @@ pub struct LibrarySummary {
impl fmt::Display for LibrarySummary { impl fmt::Display for LibrarySummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Monitored: {} artists, {} albums, {} tracks", writeln!(f, "Total tracked: {} items", self.total_items)?;
self.total_artists, self.total_albums, self.total_tracks)?;
writeln!(f, " Wanted: {}", self.wanted)?; writeln!(f, " Wanted: {}", self.wanted)?;
writeln!(f, " Available: {}", self.available)?; writeln!(f, " Available: {}", self.available)?;
writeln!(f, " Downloaded: {}", self.downloaded)?; writeln!(f, " Downloaded: {}", self.downloaded)?;
@@ -44,122 +59,156 @@ impl fmt::Display for LibrarySummary {
} }
} }
/// Add an artist to the watchlist. Auto-detects if already owned. /// Add an artist to the watchlist by expanding into individual track wanted items.
/// ///
/// If `musicbrainz_id` is provided, it will be stored with the artist record. /// Fetches the artist's discography from the provider, then for each release
/// If `name` is empty but `musicbrainz_id` is provided and a `provider` is given, /// fetches the tracklist and adds each track as a separate Wanted item.
/// the name will be looked up from MusicBrainz.
pub async fn add_artist( pub async fn add_artist(
conn: &DatabaseConnection, conn: &DatabaseConnection,
name: Option<&str>, name: Option<&str>,
musicbrainz_id: Option<&str>, musicbrainz_id: Option<&str>,
provider: Option<&impl MetadataProvider>, provider: &impl MetadataProvider,
) -> WatchResult<WatchListEntry> { ) -> WatchResult<AddSummary> {
let (resolved_name, resolved_mbid) = let (resolved_name, resolved_mbid) =
resolve_artist_info(name, musicbrainz_id, provider).await?; resolve_artist_info(name, musicbrainz_id, provider).await?;
let artist = queries::artists::upsert(conn, &resolved_name, resolved_mbid.as_deref()).await?; let artist = queries::artists::upsert(conn, &resolved_name, resolved_mbid.as_deref()).await?;
let is_owned = matching::artist_is_owned(conn, &resolved_name).await?; // Get artist MBID — either provided or from the search
let item = queries::wanted::add(conn, ItemType::Artist, Some(artist.id), None, None).await?; let artist_mbid = resolved_mbid.or_else(|| artist.musicbrainz_id.clone());
let artist_mbid = match artist_mbid {
let status = if is_owned { Some(mbid) => mbid,
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; None => {
WantedStatus::Owned // Search for the artist to get MBID
} else { let results = provider
WantedStatus::Wanted .search_artist(&resolved_name, 1)
.await
.map_err(|e| WatchError::Other(format!("artist search failed: {e}")))?;
results
.into_iter()
.next()
.map(|a| a.mbid)
.ok_or_else(|| WatchError::Other(format!("artist '{}' not found on MusicBrainz", resolved_name)))?
}
}; };
if is_owned { tracing::info!(name = %resolved_name, mbid = %artist_mbid, "fetching discography");
tracing::info!(name = %resolved_name, "artist already in library, marked as owned");
} else { // Fetch all releases
tracing::info!(name = %resolved_name, "artist added to watchlist"); let releases = provider
.get_artist_releases(&artist_mbid, 100)
.await
.map_err(|e| WatchError::Other(format!("failed to fetch discography: {e}")))?;
tracing::info!(count = releases.len(), "found releases");
let mut summary = AddSummary::default();
for release in &releases {
tracing::info!(title = %release.title, mbid = %release.mbid, "fetching tracks");
let tracks = match provider.get_release_tracks(&release.mbid).await {
Ok(t) => t,
Err(e) => {
tracing::warn!(release = %release.title, error = %e, "failed to fetch tracks");
summary.errors += 1;
continue;
}
};
for track in &tracks {
match add_track_inner(conn, &resolved_name, &track.title, Some(&track.recording_mbid)).await {
Ok(true) => summary.tracks_added += 1,
Ok(false) => summary.tracks_already_owned += 1,
Err(e) => {
tracing::warn!(track = %track.title, error = %e, "failed to add track");
summary.errors += 1;
}
}
}
} }
Ok(WatchListEntry { tracing::info!(%summary, "artist watch complete");
id: item.id, Ok(summary)
item_type: ItemType::Artist,
name: artist.name,
artist_name: None,
status,
added_at: item.added_at,
})
} }
/// Add an album to the watchlist. Auto-detects if already owned. /// Add an album to the watchlist by expanding into individual track wanted items.
/// ///
/// If `musicbrainz_id` is provided and a `provider` is given, album and artist /// Fetches the album's tracklist and adds each track as a separate Wanted item.
/// details will be resolved from MusicBrainz.
pub async fn add_album( pub async fn add_album(
conn: &DatabaseConnection, conn: &DatabaseConnection,
artist_name: Option<&str>, artist_name: Option<&str>,
album_name: Option<&str>, album_name: Option<&str>,
musicbrainz_id: Option<&str>, musicbrainz_id: Option<&str>,
provider: Option<&impl MetadataProvider>, provider: &impl MetadataProvider,
) -> WatchResult<WatchListEntry> { ) -> WatchResult<AddSummary> {
let (resolved_album, resolved_artist, resolved_mbid) = let (resolved_album, resolved_artist, resolved_mbid) =
resolve_album_info(artist_name, album_name, musicbrainz_id, provider).await?; resolve_album_info(artist_name, album_name, musicbrainz_id, provider).await?;
let artist = queries::artists::upsert(conn, &resolved_artist, None).await?; // Get release MBID
let album = queries::albums::upsert( let release_mbid = match resolved_mbid {
conn, Some(mbid) => mbid,
&resolved_album, None => {
&resolved_artist, let results = provider
resolved_mbid.as_deref(), .search_release(&resolved_artist, &resolved_album)
Some(artist.id), .await
) .map_err(|e| WatchError::Other(format!("album search failed: {e}")))?;
.await?; results
.into_iter()
let is_owned = matching::album_is_owned(conn, &resolved_artist, &resolved_album).await?; .next()
let item = queries::wanted::add( .map(|r| r.mbid)
conn, .ok_or_else(|| WatchError::Other(format!("album '{}' not found on MusicBrainz", resolved_album)))?
ItemType::Album, }
Some(artist.id),
Some(album.id),
None,
)
.await?;
let status = if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
WantedStatus::Owned
} else {
WantedStatus::Wanted
}; };
Ok(WatchListEntry { tracing::info!(album = %resolved_album, artist = %resolved_artist, mbid = %release_mbid, "fetching tracks");
id: item.id,
item_type: ItemType::Album, let tracks = provider
name: album.name, .get_release_tracks(&release_mbid)
artist_name: Some(artist.name), .await
status, .map_err(|e| WatchError::Other(format!("failed to fetch tracks: {e}")))?;
added_at: item.added_at,
}) let mut summary = AddSummary::default();
for track in &tracks {
match add_track_inner(conn, &resolved_artist, &track.title, Some(&track.recording_mbid)).await {
Ok(true) => summary.tracks_added += 1,
Ok(false) => summary.tracks_already_owned += 1,
Err(e) => {
tracing::warn!(track = %track.title, error = %e, "failed to add track");
summary.errors += 1;
}
}
} }
/// Add a track to the watchlist. Auto-detects if already owned. tracing::info!(%summary, "album watch complete");
/// Ok(summary)
/// If `musicbrainz_id` is provided and a `provider` is given, track and artist }
/// details will be resolved from MusicBrainz via `get_recording`.
/// Add a single track to the watchlist. Auto-detects if already owned.
pub async fn add_track( pub async fn add_track(
conn: &DatabaseConnection, conn: &DatabaseConnection,
artist_name: Option<&str>, artist_name: Option<&str>,
title: Option<&str>, title: Option<&str>,
musicbrainz_id: Option<&str>, musicbrainz_id: Option<&str>,
provider: Option<&impl MetadataProvider>, provider: &impl MetadataProvider,
) -> WatchResult<WatchListEntry> { ) -> WatchResult<WatchListEntry> {
let (resolved_title, resolved_artist, resolved_mbid) = let (resolved_title, resolved_artist, resolved_mbid) =
resolve_track_info(artist_name, title, musicbrainz_id, provider).await?; resolve_track_info(artist_name, title, musicbrainz_id, provider).await?;
let artist = queries::artists::upsert(conn, &resolved_artist, None).await?; let artist = queries::artists::upsert(conn, &resolved_artist, None).await?;
let is_owned = matching::track_is_owned(conn, &resolved_artist, &resolved_title).await?; let is_owned = matching::track_is_owned(conn, &resolved_artist, &resolved_title).await?;
let item = queries::wanted::add(conn, ItemType::Track, Some(artist.id), None, None).await?;
// Store the MBID on the artist if we got one from the recording lookup let item = queries::wanted::add(
if let Some(ref mbid) = resolved_mbid { conn,
tracing::debug!(mbid = %mbid, "recording MBID stored"); ItemType::Track,
} &resolved_title,
resolved_mbid.as_deref(),
Some(artist.id),
None,
None,
)
.await?;
let status = if is_owned { let status = if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
@@ -178,41 +227,72 @@ pub async fn add_track(
}) })
} }
/// Internal: add a single track wanted item. Returns Ok(true) if added as Wanted,
/// Ok(false) if already owned.
async fn add_track_inner(
conn: &DatabaseConnection,
artist_name: &str,
title: &str,
recording_mbid: Option<&str>,
) -> WatchResult<bool> {
let artist = queries::artists::upsert(conn, artist_name, None).await?;
let is_owned = matching::track_is_owned(conn, artist_name, title).await?;
let item = queries::wanted::add(
conn,
ItemType::Track,
title,
recording_mbid,
Some(artist.id),
None,
None,
)
.await?;
if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
tracing::debug!(title = title, artist = artist_name, "already owned");
Ok(false)
} else {
tracing::debug!(title = title, artist = artist_name, "added to watchlist");
Ok(true)
}
}
/// Resolve artist name from MBID if needed. /// Resolve artist name from MBID if needed.
/// Returns (name, mbid).
async fn resolve_artist_info( async fn resolve_artist_info(
name: Option<&str>, name: Option<&str>,
mbid: Option<&str>, mbid: Option<&str>,
provider: Option<&impl MetadataProvider>, provider: &impl MetadataProvider,
) -> WatchResult<(String, Option<String>)> { ) -> WatchResult<(String, Option<String>)> {
// If we have a name, use it directly
if let Some(n) = name.filter(|s| !s.is_empty()) { if let Some(n) = name.filter(|s| !s.is_empty()) {
return Ok((n.to_string(), mbid.map(String::from))); return Ok((n.to_string(), mbid.map(String::from)));
} }
// No name — need MBID + provider to resolve
let mbid = mbid.ok_or_else(|| { let mbid = mbid.ok_or_else(|| {
WatchError::Other("either a name or --mbid is required".into()) WatchError::Other("either a name or --mbid is required".into())
})?; })?;
let _provider = provider.ok_or_else(|| {
WatchError::Other("MusicBrainz provider needed to resolve MBID".into())
})?;
// TODO: Add get_artist(mbid) to MetadataProvider trait for proper resolution. // Search for artist by MBID to get the name
// For now, store the MBID and use a placeholder name. let results = provider
tracing::info!(mbid = mbid, "MBID provided without name — storing MBID as reference"); .search_artist(mbid, 1)
.await
.map_err(|e| WatchError::Other(format!("artist lookup failed: {e}")))?;
if let Some(artist) = results.into_iter().next() {
Ok((artist.name, Some(mbid.to_string())))
} 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())))
} }
}
/// Resolve album info from MBID if needed. /// Resolve album info from MBID if needed.
/// Returns (album_name, artist_name, album_mbid).
async fn resolve_album_info( async fn resolve_album_info(
artist_name: Option<&str>, artist_name: Option<&str>,
album_name: Option<&str>, album_name: Option<&str>,
mbid: Option<&str>, mbid: Option<&str>,
provider: Option<&impl MetadataProvider>, provider: &impl MetadataProvider,
) -> WatchResult<(String, String, Option<String>)> { ) -> WatchResult<(String, String, Option<String>)> {
// If we have both names, use them directly
if let (Some(album), Some(artist)) = ( if let (Some(album), Some(artist)) = (
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()),
@@ -220,16 +300,10 @@ async fn resolve_album_info(
return Ok((album.to_string(), artist.to_string(), mbid.map(String::from))); return Ok((album.to_string(), artist.to_string(), mbid.map(String::from)));
} }
// Need to resolve from MBID via provider
let mbid = mbid.ok_or_else(|| { let mbid = mbid.ok_or_else(|| {
WatchError::Other("either artist+album names or --mbid is required".into()) WatchError::Other("either artist+album names or --mbid is required".into())
})?; })?;
let provider = provider.ok_or_else(|| {
WatchError::Other("MusicBrainz provider needed to resolve MBID".into())
})?;
// Use search_release with the MBID — our trait doesn't have get_release yet,
// but we can search for it
let results = provider let results = provider
.search_release("", mbid) .search_release("", mbid)
.await .await
@@ -248,14 +322,12 @@ async fn resolve_album_info(
} }
/// Resolve track info from MBID if needed. /// Resolve track info from MBID if needed.
/// Returns (title, artist_name, recording_mbid).
async fn resolve_track_info( async fn resolve_track_info(
artist_name: Option<&str>, artist_name: Option<&str>,
title: Option<&str>, title: Option<&str>,
mbid: Option<&str>, mbid: Option<&str>,
provider: Option<&impl MetadataProvider>, provider: &impl MetadataProvider,
) -> WatchResult<(String, String, Option<String>)> { ) -> WatchResult<(String, String, Option<String>)> {
// If we have both names, use them directly
if let (Some(t), Some(a)) = ( if let (Some(t), Some(a)) = (
title.filter(|s| !s.is_empty()), title.filter(|s| !s.is_empty()),
artist_name.filter(|s| !s.is_empty()), artist_name.filter(|s| !s.is_empty()),
@@ -263,32 +335,23 @@ 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)));
} }
// Resolve from MBID
let mbid = mbid.ok_or_else(|| { let mbid = mbid.ok_or_else(|| {
WatchError::Other("either artist+title or --mbid is required".into()) WatchError::Other("either artist+title or --mbid is required".into())
})?; })?;
let provider = provider.ok_or_else(|| {
WatchError::Other("MusicBrainz provider needed to resolve MBID".into())
})?;
let details = provider let details = provider
.get_recording(mbid) .get_recording(mbid)
.await .await
.map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?; .map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?;
let resolved_title = title Ok((
.filter(|s| !s.is_empty()) title.filter(|s| !s.is_empty()).map(String::from).unwrap_or(details.title),
.map(String::from) artist_name.filter(|s| !s.is_empty()).map(String::from).unwrap_or(details.artist),
.unwrap_or(details.title); Some(mbid.to_string()),
let resolved_artist = artist_name ))
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or(details.artist);
Ok((resolved_title, resolved_artist, Some(mbid.to_string())))
} }
/// List watchlist items with optional filters, resolved to display-friendly entries. /// List watchlist items with optional filters.
pub async fn list_items( pub async fn list_items(
conn: &DatabaseConnection, conn: &DatabaseConnection,
status_filter: Option<WantedStatus>, status_filter: Option<WantedStatus>,
@@ -298,18 +361,22 @@ pub async fn list_items(
let mut entries = Vec::new(); let mut entries = Vec::new();
for item in items { for item in items {
let (name, artist_name) = resolve_names(conn, &item).await; // Use the name field directly now
let artist_name = if let Some(id) = item.artist_id {
queries::artists::get_by_id(conn, id)
.await
.map(|a| a.name)
.ok()
} else {
None
};
// Apply artist filter if provided // Apply artist filter if provided
if let Some(filter) = artist_filter { if let Some(filter) = artist_filter {
let filter_norm = matching::normalize(filter); let filter_norm = matching::normalize(filter);
let matches = artist_name let matches = artist_name
.as_deref() .as_deref()
.or(if item.item_type == ItemType::Artist { Some(name.as_str()) } else { None }) .map(|a| strsim::jaro_winkler(&filter_norm, &matching::normalize(a)) > 0.85)
.map(|a| {
let a_norm = matching::normalize(a);
strsim::jaro_winkler(&filter_norm, &a_norm) > 0.85
})
.unwrap_or(false); .unwrap_or(false);
if !matches { if !matches {
continue; continue;
@@ -319,7 +386,7 @@ pub async fn list_items(
entries.push(WatchListEntry { entries.push(WatchListEntry {
id: item.id, id: item.id,
item_type: item.item_type, item_type: item.item_type,
name, name: item.name.clone(),
artist_name, artist_name,
status: item.status, status: item.status,
added_at: item.added_at, added_at: item.added_at,
@@ -342,11 +409,7 @@ pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult<LibrarySu
let mut summary = LibrarySummary::default(); let mut summary = LibrarySummary::default();
for item in &all { for item in &all {
match item.item_type { summary.total_items += 1;
ItemType::Artist => summary.total_artists += 1,
ItemType::Album => summary.total_albums += 1,
ItemType::Track => summary.total_tracks += 1,
}
match item.status { match item.status {
WantedStatus::Wanted => summary.wanted += 1, WantedStatus::Wanted => summary.wanted += 1,
WantedStatus::Available => summary.available += 1, WantedStatus::Available => summary.available += 1,
@@ -357,57 +420,3 @@ pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult<LibrarySu
Ok(summary) Ok(summary)
} }
/// Resolve the display name and artist name for a wanted item.
async fn resolve_names(
conn: &DatabaseConnection,
item: &shanty_db::entities::wanted_item::Model,
) -> (String, Option<String>) {
match item.item_type {
ItemType::Artist => {
let name = if let Some(id) = item.artist_id {
queries::artists::get_by_id(conn, id)
.await
.map(|a| a.name)
.unwrap_or_else(|_| format!("Artist #{id}"))
} else {
"Unknown Artist".to_string()
};
(name, None)
}
ItemType::Album => {
let (album_name, artist_name) = if let Some(id) = item.album_id {
let album = queries::albums::get_by_id(conn, id).await;
match album {
Ok(a) => (a.name.clone(), Some(a.album_artist.clone())),
Err(_) => (format!("Album #{id}"), None),
}
} else {
("Unknown Album".to_string(), None)
};
let artist_name = artist_name.or_else(|| {
item.artist_id.map(|id| format!("Artist #{id}"))
});
(album_name, artist_name)
}
ItemType::Track => {
let artist_name = if let Some(id) = item.artist_id {
queries::artists::get_by_id(conn, id)
.await
.map(|a| a.name)
.ok()
} else {
None
};
let name = if let Some(id) = item.track_id {
queries::tracks::get_by_id(conn, id)
.await
.map(|t| t.title.unwrap_or_else(|| format!("Track #{id}")))
.unwrap_or_else(|_| format!("Track #{id}"))
} else {
"Track (title not stored)".to_string()
};
(name, artist_name)
}
}
}

View File

@@ -119,8 +119,8 @@ async fn main() -> anyhow::Result<()> {
let database_url = cli.database.unwrap_or_else(default_database_url); let database_url = cli.database.unwrap_or_else(default_database_url);
let db = Database::new(&database_url).await?; let db = Database::new(&database_url).await?;
// Create MB client lazily — only needed for MBID resolution // MB client needed for artist/album expansion into tracks
let mb_client = MusicBrainzClient::new().ok(); let mb_client = MusicBrainzClient::new()?;
match cli.command { match cli.command {
Commands::Add { what } => match what { Commands::Add { what } => match what {
@@ -128,37 +128,28 @@ 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 entry = add_artist( let summary = add_artist(
db.conn(), db.conn(),
name.as_deref(), name.as_deref(),
mbid.as_deref(), mbid.as_deref(),
mb_client.as_ref(), &mb_client,
) )
.await?; .await?;
println!( println!("Artist watch: {summary}");
"Added artist: {} (id={}, status={:?})",
entry.name, entry.id, entry.status
);
} }
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");
} }
let entry = add_album( let summary = add_album(
db.conn(), db.conn(),
artist.as_deref(), artist.as_deref(),
album.as_deref(), album.as_deref(),
mbid.as_deref(), mbid.as_deref(),
mb_client.as_ref(), &mb_client,
) )
.await?; .await?;
println!( println!("Album watch: {summary}");
"Added album: {} by {} (id={}, status={:?})",
entry.name,
entry.artist_name.as_deref().unwrap_or("?"),
entry.id,
entry.status
);
} }
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() {
@@ -169,7 +160,7 @@ async fn main() -> anyhow::Result<()> {
artist.as_deref(), artist.as_deref(),
title.as_deref(), title.as_deref(),
mbid.as_deref(), mbid.as_deref(),
mb_client.as_ref(), &mb_client,
) )
.await?; .await?;
println!( println!(

View File

@@ -3,7 +3,8 @@ use sea_orm::ActiveValue::Set;
use shanty_db::entities::wanted_item::{ItemType, WantedStatus}; use shanty_db::entities::wanted_item::{ItemType, WantedStatus};
use shanty_db::{Database, queries}; use shanty_db::{Database, queries};
use shanty_tag::MusicBrainzClient; use shanty_tag::error::TagResult;
use shanty_tag::provider::*;
use shanty_watch::{add_album, add_artist, add_track, library_summary, list_items, remove_item}; use shanty_watch::{add_album, add_artist, add_track, library_summary, list_items, remove_item};
async fn test_db() -> Database { async fn test_db() -> Database {
@@ -37,58 +38,72 @@ async fn insert_track(db: &Database, artist: &str, title: &str, album: &str) {
queries::tracks::upsert(db.conn(), active).await.unwrap(); queries::tracks::upsert(db.conn(), active).await.unwrap();
} }
// No MB client needed for tests — pass None /// Mock provider that returns a tracklist for known releases.
const NO_MB: Option<&MusicBrainzClient> = None; struct MockProvider;
#[tokio::test] impl MetadataProvider for MockProvider {
async fn test_add_artist_wanted() { async fn search_recording(&self, _artist: &str, _title: &str) -> TagResult<Vec<RecordingMatch>> {
let db = test_db().await; Ok(vec![])
let entry = add_artist(db.conn(), Some("Radiohead"), None, NO_MB).await.unwrap();
assert_eq!(entry.item_type, ItemType::Artist);
assert_eq!(entry.name, "Radiohead");
assert_eq!(entry.status, WantedStatus::Wanted);
} }
async fn search_release(&self, _artist: &str, _album: &str) -> TagResult<Vec<ReleaseMatch>> {
#[tokio::test] Ok(vec![ReleaseMatch {
async fn test_add_artist_auto_owned() { mbid: "release-123".into(),
let db = test_db().await; title: "Test Album".into(),
artist: "Test Artist".into(),
insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; artist_mbid: Some("artist-456".into()),
date: Some("2024".into()),
let entry = add_artist(db.conn(), Some("Pink Floyd"), None, NO_MB).await.unwrap(); track_count: Some(2),
assert_eq!(entry.status, WantedStatus::Owned); score: 100,
}])
} }
async fn get_recording(&self, _mbid: &str) -> TagResult<RecordingDetails> {
#[tokio::test] Err(shanty_tag::TagError::Other("not found".into()))
async fn test_add_album_wanted() { }
let db = test_db().await; async fn search_artist(&self, _query: &str, _limit: u32) -> TagResult<Vec<ArtistSearchResult>> {
Ok(vec![ArtistSearchResult {
let entry = add_album(db.conn(), Some("Radiohead"), Some("OK Computer"), None, NO_MB) mbid: "artist-456".into(),
.await name: "Test Artist".into(),
.unwrap(); disambiguation: None,
assert_eq!(entry.item_type, ItemType::Album); country: None,
assert_eq!(entry.name, "OK Computer"); artist_type: None,
assert_eq!(entry.status, WantedStatus::Wanted); score: 100,
}])
}
async fn get_artist_releases(&self, _mbid: &str, _limit: u32) -> TagResult<Vec<DiscographyEntry>> {
Ok(vec![DiscographyEntry {
mbid: "release-123".into(),
title: "Test Album".into(),
date: Some("2024".into()),
release_type: Some("Album".into()),
track_count: Some(2),
}])
}
async fn get_release_tracks(&self, _release_mbid: &str) -> TagResult<Vec<ReleaseTrack>> {
Ok(vec![
ReleaseTrack {
recording_mbid: "rec-1".into(),
title: "Track One".into(),
track_number: Some(1),
disc_number: Some(1),
duration_ms: Some(180_000),
},
ReleaseTrack {
recording_mbid: "rec-2".into(),
title: "Track Two".into(),
track_number: Some(2),
disc_number: Some(1),
duration_ms: Some(200_000),
},
])
} }
#[tokio::test]
async fn test_add_album_auto_owned() {
let db = test_db().await;
insert_track(&db, "Pink Floyd", "Time", "DSOTM").await;
let entry = add_album(db.conn(), Some("Pink Floyd"), Some("DSOTM"), None, NO_MB)
.await
.unwrap();
assert_eq!(entry.status, WantedStatus::Owned);
} }
#[tokio::test] #[tokio::test]
async fn test_add_track_wanted() { async fn test_add_track_wanted() {
let db = test_db().await; let db = test_db().await;
let provider = MockProvider;
let entry = add_track(db.conn(), Some("Radiohead"), Some("Creep"), None, NO_MB) let entry = add_track(db.conn(), Some("Radiohead"), Some("Creep"), None, &provider)
.await .await
.unwrap(); .unwrap();
assert_eq!(entry.item_type, ItemType::Track); assert_eq!(entry.item_type, ItemType::Track);
@@ -99,53 +114,81 @@ async fn test_add_track_wanted() {
#[tokio::test] #[tokio::test]
async fn test_add_track_auto_owned() { async fn test_add_track_auto_owned() {
let db = test_db().await; let db = test_db().await;
let provider = MockProvider;
insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; insert_track(&db, "Pink Floyd", "Time", "DSOTM").await;
let entry = add_track(db.conn(), Some("Pink Floyd"), Some("Time"), None, NO_MB) let entry = add_track(db.conn(), Some("Pink Floyd"), Some("Time"), None, &provider)
.await .await
.unwrap(); .unwrap();
assert_eq!(entry.status, WantedStatus::Owned); assert_eq!(entry.status, WantedStatus::Owned);
} }
#[tokio::test]
async fn test_add_album_expands_to_tracks() {
let db = test_db().await;
let provider = MockProvider;
let summary = add_album(db.conn(), Some("Test Artist"), Some("Test Album"), None, &provider)
.await
.unwrap();
assert_eq!(summary.tracks_added, 2);
let items = list_items(db.conn(), Some(WantedStatus::Wanted), None)
.await
.unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].item_type, ItemType::Track);
}
#[tokio::test]
async fn test_add_artist_expands_to_tracks() {
let db = test_db().await;
let provider = MockProvider;
let summary = add_artist(db.conn(), Some("Test Artist"), None, &provider)
.await
.unwrap();
assert_eq!(summary.tracks_added, 2);
let items = list_items(db.conn(), None, None).await.unwrap();
assert_eq!(items.len(), 2);
}
#[tokio::test] #[tokio::test]
async fn test_list_items_with_filters() { async fn test_list_items_with_filters() {
let db = test_db().await; let db = test_db().await;
let provider = MockProvider;
add_artist(db.conn(), Some("Radiohead"), None, NO_MB).await.unwrap(); add_track(db.conn(), Some("Radiohead"), Some("Creep"), None, &provider)
add_artist(db.conn(), Some("Tool"), None, NO_MB).await.unwrap(); .await
.unwrap();
add_track(db.conn(), Some("Tool"), Some("Lateralus"), None, &provider)
.await
.unwrap();
// List all
let all = list_items(db.conn(), None, None).await.unwrap(); let all = list_items(db.conn(), None, None).await.unwrap();
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
// List by status
let wanted = list_items(db.conn(), Some(WantedStatus::Wanted), None) let wanted = list_items(db.conn(), Some(WantedStatus::Wanted), None)
.await .await
.unwrap(); .unwrap();
assert_eq!(wanted.len(), 2); assert_eq!(wanted.len(), 2);
let owned = list_items(db.conn(), Some(WantedStatus::Owned), None)
.await
.unwrap();
assert!(owned.is_empty());
// List by artist
let radiohead = list_items(db.conn(), None, Some("Radiohead")) let radiohead = list_items(db.conn(), None, Some("Radiohead"))
.await .await
.unwrap(); .unwrap();
assert_eq!(radiohead.len(), 1); assert_eq!(radiohead.len(), 1);
assert_eq!(radiohead[0].name, "Radiohead");
} }
#[tokio::test] #[tokio::test]
async fn test_remove_item() { async fn test_remove_item() {
let db = test_db().await; let db = test_db().await;
let provider = MockProvider;
let entry = add_artist(db.conn(), Some("Radiohead"), None, NO_MB).await.unwrap(); let entry = add_track(db.conn(), Some("Radiohead"), Some("Creep"), None, &provider)
let all = list_items(db.conn(), None, None).await.unwrap(); .await
assert_eq!(all.len(), 1); .unwrap();
remove_item(db.conn(), entry.id).await.unwrap(); remove_item(db.conn(), entry.id).await.unwrap();
let all = list_items(db.conn(), None, None).await.unwrap(); let all = list_items(db.conn(), None, None).await.unwrap();
assert!(all.is_empty()); assert!(all.is_empty());
@@ -154,18 +197,19 @@ async fn test_remove_item() {
#[tokio::test] #[tokio::test]
async fn test_library_summary() { async fn test_library_summary() {
let db = test_db().await; let db = test_db().await;
let provider = MockProvider;
insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; insert_track(&db, "Pink Floyd", "Time", "DSOTM").await;
add_artist(db.conn(), Some("Radiohead"), None, NO_MB).await.unwrap(); add_track(db.conn(), Some("Radiohead"), Some("Creep"), None, &provider)
add_artist(db.conn(), Some("Pink Floyd"), None, NO_MB).await.unwrap(); .await
add_album(db.conn(), Some("Tool"), Some("Lateralus"), None, NO_MB).await.unwrap(); .unwrap();
add_track(db.conn(), Some("Pink Floyd"), Some("Time"), None, NO_MB).await.unwrap(); add_track(db.conn(), Some("Pink Floyd"), Some("Time"), None, &provider)
.await
.unwrap();
let summary = library_summary(db.conn()).await.unwrap(); let summary = library_summary(db.conn()).await.unwrap();
assert_eq!(summary.total_artists, 2); assert_eq!(summary.total_items, 2);
assert_eq!(summary.total_albums, 1); assert_eq!(summary.wanted, 1);
assert_eq!(summary.total_tracks, 1); assert_eq!(summary.owned, 1);
assert_eq!(summary.wanted, 2);
assert_eq!(summary.owned, 2);
} }