Initial commit

This commit is contained in:
Connor Johnstone
2026-03-17 18:36:35 -04:00
commit 6cbca10f7e
9 changed files with 879 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
use std::fmt;
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use shanty_db::entities::wanted_item::{ItemType, WantedStatus};
use shanty_db::queries;
use crate::error::WatchResult;
use crate::matching;
/// A display-friendly watchlist entry with resolved names.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchListEntry {
pub id: i32,
pub item_type: ItemType,
pub name: String,
pub artist_name: Option<String>,
pub status: WantedStatus,
pub added_at: chrono::NaiveDateTime,
}
/// Summary statistics for the library.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LibrarySummary {
pub total_artists: u64,
pub total_albums: u64,
pub total_tracks: u64,
pub wanted: u64,
pub available: u64,
pub downloaded: u64,
pub owned: u64,
}
impl fmt::Display for LibrarySummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Monitored: {} artists, {} albums, {} tracks",
self.total_artists, self.total_albums, self.total_tracks)?;
writeln!(f, " Wanted: {}", self.wanted)?;
writeln!(f, " Available: {}", self.available)?;
writeln!(f, " Downloaded: {}", self.downloaded)?;
write!(f, " Owned: {}", self.owned)
}
}
/// Add an artist to the watchlist. Auto-detects if already owned.
pub async fn add_artist(
conn: &DatabaseConnection,
name: &str,
musicbrainz_id: Option<&str>,
) -> WatchResult<WatchListEntry> {
let artist = queries::artists::upsert(conn, name, musicbrainz_id).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?;
// If already owned, update status
let status = if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
WantedStatus::Owned
} else {
WantedStatus::Wanted
};
if is_owned {
tracing::info!(name = name, "artist already in library, marked as owned");
} else {
tracing::info!(name = name, "artist added to watchlist");
}
Ok(WatchListEntry {
id: item.id,
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.
pub async fn add_album(
conn: &DatabaseConnection,
artist_name: &str,
album_name: &str,
musicbrainz_id: Option<&str>,
) -> 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 is_owned = matching::album_is_owned(conn, artist_name, album_name).await?;
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 {
id: item.id,
item_type: ItemType::Album,
name: album.name,
artist_name: Some(artist.name),
status,
added_at: item.added_at,
})
}
/// Add a track to the watchlist. Auto-detects if already owned.
pub async fn add_track(
conn: &DatabaseConnection,
artist_name: &str,
title: &str,
musicbrainz_id: Option<&str>,
) -> WatchResult<WatchListEntry> {
let artist = queries::artists::upsert(conn, artist_name, None).await?;
let is_owned = matching::track_is_owned(conn, artist_name, title).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 _ = musicbrainz_id; // Reserved for future use
let status = if is_owned {
queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?;
WantedStatus::Owned
} else {
WantedStatus::Wanted
};
Ok(WatchListEntry {
id: item.id,
item_type: ItemType::Track,
name: title.to_string(),
artist_name: Some(artist.name),
status,
added_at: item.added_at,
})
}
/// List watchlist items with optional filters, resolved to display-friendly entries.
pub async fn list_items(
conn: &DatabaseConnection,
status_filter: Option<WantedStatus>,
artist_filter: Option<&str>,
) -> WatchResult<Vec<WatchListEntry>> {
let items = queries::wanted::list(conn, status_filter).await?;
let mut entries = Vec::new();
for item in items {
let (name, artist_name) = resolve_names(conn, &item).await;
// Apply artist filter if provided
if let Some(filter) = artist_filter {
let filter_norm = matching::normalize(filter);
let matches = artist_name
.as_deref()
.or(if item.item_type == ItemType::Artist { Some(name.as_str()) } else { None })
.map(|a| {
let a_norm = matching::normalize(a);
strsim::jaro_winkler(&filter_norm, &a_norm) > 0.85
})
.unwrap_or(false);
if !matches {
continue;
}
}
entries.push(WatchListEntry {
id: item.id,
item_type: item.item_type,
name,
artist_name,
status: item.status,
added_at: item.added_at,
});
}
Ok(entries)
}
/// Remove an item from the watchlist.
pub async fn remove_item(conn: &DatabaseConnection, id: i32) -> WatchResult<()> {
queries::wanted::remove(conn, id).await?;
tracing::info!(id = id, "removed from watchlist");
Ok(())
}
/// Get a summary of the library state.
pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult<LibrarySummary> {
let all = queries::wanted::list(conn, None).await?;
let mut summary = LibrarySummary::default();
for item in &all {
match item.item_type {
ItemType::Artist => summary.total_artists += 1,
ItemType::Album => summary.total_albums += 1,
ItemType::Track => summary.total_tracks += 1,
}
match item.status {
WantedStatus::Wanted => summary.wanted += 1,
WantedStatus::Available => summary.available += 1,
WantedStatus::Downloaded => summary.downloaded += 1,
WantedStatus::Owned => summary.owned += 1,
}
}
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(|| {
// Fall back to artist table
item.artist_id.and_then(|id| {
// Can't await in closure, use a placeholder
Some(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
.map(|a| a.name)
.ok()
} 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
.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)
}
}
}