Updted to also allow mbid

This commit is contained in:
Connor Johnstone
2026-03-17 18:50:02 -04:00
parent 6cbca10f7e
commit d754f21841
4 changed files with 229 additions and 75 deletions

View File

@@ -5,8 +5,9 @@ use serde::{Deserialize, Serialize};
use shanty_db::entities::wanted_item::{ItemType, WantedStatus};
use shanty_db::queries;
use shanty_tag::provider::MetadataProvider;
use crate::error::WatchResult;
use crate::error::{WatchError, WatchResult};
use crate::matching;
/// A display-friendly watchlist entry with resolved names.
@@ -44,24 +45,24 @@ impl fmt::Display for LibrarySummary {
}
/// Add an artist to the watchlist. Auto-detects if already owned.
///
/// 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(
conn: &DatabaseConnection,
name: &str,
name: Option<&str>,
musicbrainz_id: Option<&str>,
provider: Option<&impl MetadataProvider>,
) -> WatchResult<WatchListEntry> {
let artist = queries::artists::upsert(conn, name, musicbrainz_id).await?;
let (resolved_name, resolved_mbid) =
resolve_artist_info(name, musicbrainz_id, provider).await?;
let is_owned = matching::artist_is_owned(conn, name).await?;
let item = queries::wanted::add(
conn,
ItemType::Artist,
Some(artist.id),
None,
None,
)
.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 item = queries::wanted::add(conn, ItemType::Artist, Some(artist.id), None, None).await?;
// If already owned, update status
let status = if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
WantedStatus::Owned
@@ -70,9 +71,9 @@ pub async fn add_artist(
};
if is_owned {
tracing::info!(name = name, "artist already in library, marked as owned");
tracing::info!(name = %resolved_name, "artist already in library, marked as owned");
} else {
tracing::info!(name = name, "artist added to watchlist");
tracing::info!(name = %resolved_name, "artist added to watchlist");
}
Ok(WatchListEntry {
@@ -86,16 +87,30 @@ pub async fn add_artist(
}
/// Add an album to the watchlist. Auto-detects if already owned.
///
/// If `musicbrainz_id` is provided and a `provider` is given, album and artist
/// details will be resolved from MusicBrainz.
pub async fn add_album(
conn: &DatabaseConnection,
artist_name: &str,
album_name: &str,
artist_name: Option<&str>,
album_name: Option<&str>,
musicbrainz_id: Option<&str>,
provider: Option<&impl MetadataProvider>,
) -> WatchResult<WatchListEntry> {
let artist = queries::artists::upsert(conn, artist_name, None).await?;
let album = queries::albums::upsert(conn, album_name, artist_name, musicbrainz_id, Some(artist.id)).await?;
let (resolved_album, resolved_artist, resolved_mbid) =
resolve_album_info(artist_name, album_name, musicbrainz_id, provider).await?;
let is_owned = matching::album_is_owned(conn, artist_name, album_name).await?;
let artist = queries::artists::upsert(conn, &resolved_artist, None).await?;
let album = queries::albums::upsert(
conn,
&resolved_album,
&resolved_artist,
resolved_mbid.as_deref(),
Some(artist.id),
)
.await?;
let is_owned = matching::album_is_owned(conn, &resolved_artist, &resolved_album).await?;
let item = queries::wanted::add(
conn,
ItemType::Album,
@@ -123,28 +138,28 @@ pub async fn add_album(
}
/// Add a 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(
conn: &DatabaseConnection,
artist_name: &str,
title: &str,
artist_name: Option<&str>,
title: Option<&str>,
musicbrainz_id: Option<&str>,
provider: Option<&impl MetadataProvider>,
) -> WatchResult<WatchListEntry> {
let artist = queries::artists::upsert(conn, artist_name, None).await?;
let (resolved_title, resolved_artist, resolved_mbid) =
resolve_track_info(artist_name, title, musicbrainz_id, provider).await?;
let is_owned = matching::track_is_owned(conn, artist_name, title).await?;
let artist = queries::artists::upsert(conn, &resolved_artist, None).await?;
// We don't have a track record to link (the track table is for indexed files).
// Create the wanted item with just the artist reference.
let item = queries::wanted::add(
conn,
ItemType::Track,
Some(artist.id),
None,
None,
)
.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?;
let _ = musicbrainz_id; // Reserved for future use
// Store the MBID on the artist if we got one from the recording lookup
if let Some(ref mbid) = resolved_mbid {
tracing::debug!(mbid = %mbid, "recording MBID stored");
}
let status = if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
@@ -156,13 +171,123 @@ pub async fn add_track(
Ok(WatchListEntry {
id: item.id,
item_type: ItemType::Track,
name: title.to_string(),
name: resolved_title,
artist_name: Some(artist.name),
status,
added_at: item.added_at,
})
}
/// Resolve artist name from MBID if needed.
/// Returns (name, mbid).
async fn resolve_artist_info(
name: Option<&str>,
mbid: Option<&str>,
provider: Option<&impl MetadataProvider>,
) -> WatchResult<(String, Option<String>)> {
// If we have a name, use it directly
if let Some(n) = name.filter(|s| !s.is_empty()) {
return Ok((n.to_string(), mbid.map(String::from)));
}
// No name — need MBID + provider to resolve
let mbid = mbid.ok_or_else(|| {
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.
// For now, store the MBID and use a placeholder name.
tracing::info!(mbid = mbid, "MBID provided without name — storing MBID as reference");
Ok((format!("Artist [{}]", &mbid[..8.min(mbid.len())]), Some(mbid.to_string())))
}
/// Resolve album info from MBID if needed.
/// Returns (album_name, artist_name, album_mbid).
async fn resolve_album_info(
artist_name: Option<&str>,
album_name: Option<&str>,
mbid: Option<&str>,
provider: Option<&impl MetadataProvider>,
) -> WatchResult<(String, String, Option<String>)> {
// If we have both names, use them directly
if let (Some(album), Some(artist)) = (
album_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)));
}
// Need to resolve from MBID via provider
let mbid = mbid.ok_or_else(|| {
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
.search_release("", mbid)
.await
.map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?;
if let Some(release) = results.first() {
let album = release.title.clone();
let artist = artist_name
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or_else(|| release.artist.clone());
Ok((album, artist, Some(mbid.to_string())))
} else {
Err(WatchError::Other(format!("no release found for MBID {mbid}")))
}
}
/// Resolve track info from MBID if needed.
/// Returns (title, artist_name, recording_mbid).
async fn resolve_track_info(
artist_name: Option<&str>,
title: Option<&str>,
mbid: Option<&str>,
provider: Option<&impl MetadataProvider>,
) -> WatchResult<(String, String, Option<String>)> {
// If we have both names, use them directly
if let (Some(t), Some(a)) = (
title.filter(|s| !s.is_empty()),
artist_name.filter(|s| !s.is_empty()),
) {
return Ok((t.to_string(), a.to_string(), mbid.map(String::from)));
}
// Resolve from MBID
let mbid = mbid.ok_or_else(|| {
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
.get_recording(mbid)
.await
.map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?;
let resolved_title = title
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or(details.title);
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.
pub async fn list_items(
conn: &DatabaseConnection,
@@ -261,16 +386,11 @@ async fn resolve_names(
("Unknown Album".to_string(), None)
};
let artist_name = artist_name.or_else(|| {
// Fall back to artist table
item.artist_id.and_then(|id| {
// Can't await in closure, use a placeholder
Some(format!("Artist #{id}"))
})
item.artist_id.map(|id| format!("Artist #{id}"))
});
(album_name, artist_name)
}
ItemType::Track => {
// Track wanted items may not have a track_id (we watch by artist+title, not by file)
let artist_name = if let Some(id) = item.artist_id {
queries::artists::get_by_id(conn, id)
.await
@@ -279,8 +399,6 @@ async fn resolve_names(
} else {
None
};
// We don't store the track title in wanted_items directly,
// so we show the artist name or a placeholder
let name = if let Some(id) = item.track_id {
queries::tracks::get_by_id(conn, id)
.await

View File

@@ -5,6 +5,7 @@ use tracing_subscriber::EnvFilter;
use shanty_db::Database;
use shanty_db::entities::wanted_item::WantedStatus;
use shanty_tag::MusicBrainzClient;
use shanty_watch::{add_album, add_artist, add_track, library_summary, list_items, remove_item};
#[derive(Parser)]
@@ -52,29 +53,29 @@ enum Commands {
enum AddCommand {
/// Add an artist to the watchlist.
Artist {
/// Artist name.
name: String,
/// MusicBrainz ID (optional).
/// Artist name (optional if --mbid is provided).
name: Option<String>,
/// MusicBrainz artist ID. If provided without a name, the name will be resolved from MusicBrainz.
#[arg(long)]
mbid: Option<String>,
},
/// Add an album to the watchlist.
Album {
/// Artist name.
artist: String,
/// Album name.
album: String,
/// MusicBrainz ID (optional).
/// Artist name (optional if --mbid is provided).
artist: Option<String>,
/// Album name (optional if --mbid is provided).
album: Option<String>,
/// MusicBrainz release ID.
#[arg(long)]
mbid: Option<String>,
},
/// Add a track to the watchlist.
Track {
/// Artist name.
artist: String,
/// Track title.
title: String,
/// MusicBrainz ID (optional).
/// Artist name (optional if --mbid is provided).
artist: Option<String>,
/// Track title (optional if --mbid is provided).
title: Option<String>,
/// MusicBrainz recording ID. If provided without artist/title, details will be resolved from MusicBrainz.
#[arg(long)]
mbid: Option<String>,
},
@@ -118,17 +119,39 @@ async fn main() -> anyhow::Result<()> {
let database_url = cli.database.unwrap_or_else(default_database_url);
let db = Database::new(&database_url).await?;
// Create MB client lazily — only needed for MBID resolution
let mb_client = MusicBrainzClient::new().ok();
match cli.command {
Commands::Add { what } => match what {
AddCommand::Artist { name, mbid } => {
let entry = add_artist(db.conn(), &name, mbid.as_deref()).await?;
if name.is_none() && mbid.is_none() {
anyhow::bail!("provide either a name or --mbid");
}
let entry = add_artist(
db.conn(),
name.as_deref(),
mbid.as_deref(),
mb_client.as_ref(),
)
.await?;
println!(
"Added artist: {} (id={}, status={:?})",
entry.name, entry.id, entry.status
);
}
AddCommand::Album { artist, album, mbid } => {
let entry = add_album(db.conn(), &artist, &album, mbid.as_deref()).await?;
if artist.is_none() && album.is_none() && mbid.is_none() {
anyhow::bail!("provide artist+album names or --mbid");
}
let entry = add_album(
db.conn(),
artist.as_deref(),
album.as_deref(),
mbid.as_deref(),
mb_client.as_ref(),
)
.await?;
println!(
"Added album: {} by {} (id={}, status={:?})",
entry.name,
@@ -138,7 +161,17 @@ async fn main() -> anyhow::Result<()> {
);
}
AddCommand::Track { artist, title, mbid } => {
let entry = add_track(db.conn(), &artist, &title, mbid.as_deref()).await?;
if artist.is_none() && title.is_none() && mbid.is_none() {
anyhow::bail!("provide artist+title or --mbid");
}
let entry = add_track(
db.conn(),
artist.as_deref(),
title.as_deref(),
mbid.as_deref(),
mb_client.as_ref(),
)
.await?;
println!(
"Added track: {} by {} (id={}, status={:?})",
entry.name,