Compare commits
7 Commits
d754f21841
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef4708439 | ||
|
|
85e24671a3 | ||
|
|
15a4efe1e9 | ||
|
|
0f066d5708 | ||
|
|
0b336789da | ||
|
|
3d01fa85c9 | ||
|
|
7bee8a9afa |
@@ -24,4 +24,5 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
shanty-data = { path = "../shanty-data" }
|
||||||
tokio = { version = "1", features = ["full", "test-util"] }
|
tokio = { version = "1", features = ["full", "test-util"] }
|
||||||
|
|||||||
@@ -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, library_summary,
|
||||||
remove_item,
|
list_items, remove_item,
|
||||||
};
|
};
|
||||||
|
|||||||
438
src/library.rs
438
src/library.rs
@@ -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,186 @@ 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.
|
|
||||||
/// If `name` is empty but `musicbrainz_id` is provided and a `provider` is given,
|
|
||||||
/// 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> {
|
user_id: Option<i32>,
|
||||||
|
) -> 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?;
|
let artist_mbid = resolved_mbid.or_else(|| artist.musicbrainz_id.clone());
|
||||||
let item = queries::wanted::add(conn, ItemType::Artist, Some(artist.id), None, None).await?;
|
let artist_mbid = match artist_mbid {
|
||||||
|
Some(mbid) => mbid,
|
||||||
let status = if is_owned {
|
None => {
|
||||||
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
|
let results = provider
|
||||||
WantedStatus::Owned
|
.search_artist(&resolved_name, 1)
|
||||||
} else {
|
.await
|
||||||
WantedStatus::Wanted
|
.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 (release groups)");
|
||||||
tracing::info!(name = %resolved_name, "artist already in library, marked as owned");
|
|
||||||
|
let release_groups = provider
|
||||||
|
.get_artist_release_groups(&artist_mbid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WatchError::Other(format!("failed to fetch release groups: {e}")))?;
|
||||||
|
|
||||||
|
tracing::info!(count = release_groups.len(), "found release groups");
|
||||||
|
|
||||||
|
let mut summary = AddSummary::default();
|
||||||
|
|
||||||
|
for rg in &release_groups {
|
||||||
|
// Resolve a concrete release MBID from the release group
|
||||||
|
let release_mbid = if let Some(ref rid) = rg.first_release_mbid {
|
||||||
|
rid.clone()
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(name = %resolved_name, "artist added to watchlist");
|
// Browse releases for this release group to find a concrete release
|
||||||
|
match provider.resolve_release_from_group(&rg.mbid).await {
|
||||||
|
Ok(rid) => rid,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(rg = %rg.title, rg_id = %rg.mbid, error = %e, "failed to resolve release from group");
|
||||||
|
summary.errors += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(title = %rg.title, release_mbid = %release_mbid, "fetching tracks for release group");
|
||||||
|
|
||||||
|
let tracks = match provider.get_release_tracks(&release_mbid).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(rg = %rg.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),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.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
|
|
||||||
/// 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> {
|
user_id: Option<i32>,
|
||||||
|
) -> 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?;
|
let release_mbid = match resolved_mbid {
|
||||||
let album = queries::albums::upsert(
|
Some(mbid) => mbid,
|
||||||
conn,
|
None => {
|
||||||
&resolved_album,
|
let results = provider
|
||||||
&resolved_artist,
|
.search_release(&resolved_artist, &resolved_album)
|
||||||
resolved_mbid.as_deref(),
|
.await
|
||||||
Some(artist.id),
|
.map_err(|e| WatchError::Other(format!("album search failed: {e}")))?;
|
||||||
)
|
results.into_iter().next().map(|r| r.mbid).ok_or_else(|| {
|
||||||
.await?;
|
WatchError::Other(format!(
|
||||||
|
"album '{}' not found on MusicBrainz",
|
||||||
let is_owned = matching::album_is_owned(conn, &resolved_artist, &resolved_album).await?;
|
resolved_album
|
||||||
let item = queries::wanted::add(
|
))
|
||||||
conn,
|
})?
|
||||||
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),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(%summary, "album watch complete");
|
||||||
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a track to the watchlist. Auto-detects if already owned.
|
/// Add a single track to the watchlist. Auto-detects if already owned.
|
||||||
///
|
|
||||||
/// If `musicbrainz_id` is provided and a `provider` is given, track and artist
|
|
||||||
/// details will be resolved from MusicBrainz via `get_recording`.
|
|
||||||
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,
|
||||||
|
user_id: Option<i32>,
|
||||||
) -> 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");
|
queries::wanted::AddWantedItem {
|
||||||
}
|
item_type: ItemType::Track,
|
||||||
|
name: &resolved_title,
|
||||||
|
musicbrainz_id: resolved_mbid.as_deref(),
|
||||||
|
artist_id: Some(artist.id),
|
||||||
|
album_id: None,
|
||||||
|
track_id: None,
|
||||||
|
user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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,58 +257,92 @@ 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>,
|
||||||
|
user_id: Option<i32>,
|
||||||
|
) -> 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,
|
||||||
|
queries::wanted::AddWantedItem {
|
||||||
|
item_type: ItemType::Track,
|
||||||
|
name: title,
|
||||||
|
musicbrainz_id: recording_mbid,
|
||||||
|
artist_id: Some(artist.id),
|
||||||
|
album_id: None,
|
||||||
|
track_id: None,
|
||||||
|
user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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 =
|
||||||
let mbid = mbid.ok_or_else(|| {
|
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.
|
let results = provider
|
||||||
// For now, store the MBID and use a placeholder name.
|
.search_artist(mbid, 1)
|
||||||
tracing::info!(mbid = mbid, "MBID provided without name — storing MBID as reference");
|
.await
|
||||||
Ok((format!("Artist [{}]", &mbid[..8.min(mbid.len())]), Some(mbid.to_string())))
|
.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()),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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()),
|
||||||
) {
|
) {
|
||||||
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
|
||||||
@@ -243,19 +356,19 @@ 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}"
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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,53 +376,52 @@ 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 =
|
||||||
let mbid = mbid.ok_or_else(|| {
|
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((
|
||||||
|
title
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.unwrap_or(details.title);
|
.unwrap_or(details.title),
|
||||||
let resolved_artist = artist_name
|
artist_name
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
.unwrap_or(details.artist);
|
.unwrap_or(details.artist),
|
||||||
|
Some(mbid.to_string()),
|
||||||
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>,
|
||||||
artist_filter: Option<&str>,
|
artist_filter: Option<&str>,
|
||||||
|
user_id: Option<i32>,
|
||||||
) -> WatchResult<Vec<WatchListEntry>> {
|
) -> WatchResult<Vec<WatchListEntry>> {
|
||||||
let items = queries::wanted::list(conn, status_filter).await?;
|
let items = queries::wanted::list(conn, status_filter, user_id).await?;
|
||||||
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;
|
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
|
|
||||||
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 +431,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,
|
||||||
@@ -338,15 +450,11 @@ pub async fn remove_item(conn: &DatabaseConnection, id: i32) -> WatchResult<()>
|
|||||||
|
|
||||||
/// Get a summary of the library state.
|
/// Get a summary of the library state.
|
||||||
pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult<LibrarySummary> {
|
pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult<LibrarySummary> {
|
||||||
let all = queries::wanted::list(conn, None).await?;
|
let all = queries::wanted::list(conn, None, None).await?;
|
||||||
|
|
||||||
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 +465,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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@@ -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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +121,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,39 +130,40 @@ 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,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.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,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.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() {
|
||||||
anyhow::bail!("provide artist+title or --mbid");
|
anyhow::bail!("provide artist+title or --mbid");
|
||||||
}
|
}
|
||||||
@@ -169,7 +172,8 @@ 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,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
println!(
|
println!(
|
||||||
@@ -183,14 +187,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
},
|
},
|
||||||
Commands::List { status, artist } => {
|
Commands::List { status, artist } => {
|
||||||
let status_filter = status.as_deref().map(parse_status).transpose()?;
|
let status_filter = status.as_deref().map(parse_status).transpose()?;
|
||||||
let entries = list_items(db.conn(), status_filter, artist.as_deref()).await?;
|
let entries = list_items(db.conn(), status_filter, artist.as_deref(), None).await?;
|
||||||
|
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
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!(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
|
|
||||||
|
use shanty_data::DataResult;
|
||||||
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::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,115 @@ 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(
|
||||||
let db = test_db().await;
|
&self,
|
||||||
|
_artist: &str,
|
||||||
|
_title: &str,
|
||||||
|
) -> DataResult<Vec<RecordingMatch>> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn search_release(&self, _artist: &str, _album: &str) -> DataResult<Vec<ReleaseMatch>> {
|
||||||
|
Ok(vec![ReleaseMatch {
|
||||||
|
mbid: "release-123".into(),
|
||||||
|
title: "Test Album".into(),
|
||||||
|
artist: "Test Artist".into(),
|
||||||
|
artist_mbid: Some("artist-456".into()),
|
||||||
|
date: Some("2024".into()),
|
||||||
|
track_count: Some(2),
|
||||||
|
score: 100,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
async fn get_recording(&self, _mbid: &str) -> DataResult<RecordingDetails> {
|
||||||
|
Err(shanty_data::DataError::Other("not found".into()))
|
||||||
|
}
|
||||||
|
async fn search_artist(
|
||||||
|
&self,
|
||||||
|
_query: &str,
|
||||||
|
_limit: u32,
|
||||||
|
) -> DataResult<Vec<ArtistSearchResult>> {
|
||||||
|
Ok(vec![ArtistSearchResult {
|
||||||
|
mbid: "artist-456".into(),
|
||||||
|
name: "Test Artist".into(),
|
||||||
|
disambiguation: None,
|
||||||
|
country: None,
|
||||||
|
artist_type: None,
|
||||||
|
score: 100,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
async fn get_artist_releases(
|
||||||
|
&self,
|
||||||
|
_mbid: &str,
|
||||||
|
_limit: u32,
|
||||||
|
) -> DataResult<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) -> DataResult<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),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
let entry = add_artist(db.conn(), Some("Radiohead"), None, NO_MB).await.unwrap();
|
async fn get_artist_release_groups(
|
||||||
assert_eq!(entry.item_type, ItemType::Artist);
|
&self,
|
||||||
assert_eq!(entry.name, "Radiohead");
|
_artist_mbid: &str,
|
||||||
assert_eq!(entry.status, WantedStatus::Wanted);
|
) -> DataResult<Vec<shanty_tag::provider::ReleaseGroupEntry>> {
|
||||||
}
|
Ok(vec![ReleaseGroupEntry {
|
||||||
|
mbid: "rg-123".into(),
|
||||||
|
title: "Test Album".into(),
|
||||||
|
primary_type: Some("Album".into()),
|
||||||
|
secondary_types: vec![],
|
||||||
|
first_release_date: Some("2024".into()),
|
||||||
|
first_release_mbid: Some("release-123".into()),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
async fn resolve_release_from_group(&self, release_group_mbid: &str) -> DataResult<String> {
|
||||||
async fn test_add_artist_auto_owned() {
|
if release_group_mbid == "rg-123" {
|
||||||
let db = test_db().await;
|
Ok("release-123".into())
|
||||||
|
} else {
|
||||||
insert_track(&db, "Pink Floyd", "Time", "DSOTM").await;
|
Err(shanty_data::DataError::Other(format!(
|
||||||
|
"no releases for release-group {release_group_mbid}"
|
||||||
let entry = add_artist(db.conn(), Some("Pink Floyd"), None, NO_MB).await.unwrap();
|
)))
|
||||||
assert_eq!(entry.status, WantedStatus::Owned);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_add_album_wanted() {
|
|
||||||
let db = test_db().await;
|
|
||||||
|
|
||||||
let entry = add_album(db.conn(), Some("Radiohead"), Some("OK Computer"), None, NO_MB)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(entry.item_type, ItemType::Album);
|
|
||||||
assert_eq!(entry.name, "OK Computer");
|
|
||||||
assert_eq!(entry.status, WantedStatus::Wanted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(entry.item_type, ItemType::Track);
|
assert_eq!(entry.item_type, ItemType::Track);
|
||||||
@@ -99,73 +157,151 @@ 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,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.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,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.tracks_added, 2);
|
||||||
|
|
||||||
|
let items = list_items(db.conn(), Some(WantedStatus::Wanted), None, 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, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.tracks_added, 2);
|
||||||
|
|
||||||
|
let items = list_items(db.conn(), None, 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(
|
||||||
add_artist(db.conn(), Some("Tool"), None, NO_MB).await.unwrap();
|
db.conn(),
|
||||||
|
Some("Radiohead"),
|
||||||
|
Some("Creep"),
|
||||||
|
None,
|
||||||
|
&provider,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
add_track(
|
||||||
|
db.conn(),
|
||||||
|
Some("Tool"),
|
||||||
|
Some("Lateralus"),
|
||||||
|
None,
|
||||||
|
&provider,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// List all
|
let all = list_items(db.conn(), None, 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, 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)
|
let radiohead = list_items(db.conn(), None, Some("Radiohead"), None)
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(owned.is_empty());
|
|
||||||
|
|
||||||
// List by artist
|
|
||||||
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(
|
||||||
let all = list_items(db.conn(), None, None).await.unwrap();
|
db.conn(),
|
||||||
assert_eq!(all.len(), 1);
|
Some("Radiohead"),
|
||||||
|
Some("Creep"),
|
||||||
|
None,
|
||||||
|
&provider,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.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, None).await.unwrap();
|
||||||
assert!(all.is_empty());
|
assert!(all.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
||||||
add_artist(db.conn(), Some("Pink Floyd"), None, NO_MB).await.unwrap();
|
db.conn(),
|
||||||
add_album(db.conn(), Some("Tool"), Some("Lateralus"), None, NO_MB).await.unwrap();
|
Some("Radiohead"),
|
||||||
add_track(db.conn(), Some("Pink Floyd"), Some("Time"), None, NO_MB).await.unwrap();
|
Some("Creep"),
|
||||||
|
None,
|
||||||
|
&provider,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
add_track(
|
||||||
|
db.conn(),
|
||||||
|
Some("Pink Floyd"),
|
||||||
|
Some("Time"),
|
||||||
|
None,
|
||||||
|
&provider,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user