Initial commit
This commit is contained in:
+295
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user