use sea_orm::DatabaseConnection; use unicode_normalization::UnicodeNormalization; use shanty_db::queries; use crate::error::WatchResult; /// Normalize a string for fuzzy comparison: NFC unicode, lowercase, trim. pub fn normalize(s: &str) -> String { s.nfc() .collect::() .to_lowercase() .trim() .to_string() } /// Check if an artist is "owned" — i.e., any tracks by this artist exist in the indexed library. pub async fn artist_is_owned(conn: &DatabaseConnection, artist_name: &str) -> WatchResult { let normalized = normalize(artist_name); // Check by exact artist record first if let Some(artist) = queries::artists::find_by_name(conn, artist_name).await? { let tracks = queries::tracks::get_by_artist(conn, artist.id).await?; if !tracks.is_empty() { return Ok(true); } } // Fuzzy: search tracks where the artist field matches let tracks = queries::tracks::search(conn, artist_name).await?; for track in &tracks { if let Some(ref track_artist) = track.artist { let sim = strsim::jaro_winkler(&normalized, &normalize(track_artist)); if sim > 0.85 { return Ok(true); } } if let Some(ref album_artist) = track.album_artist { let sim = strsim::jaro_winkler(&normalized, &normalize(album_artist)); if sim > 0.85 { return Ok(true); } } } Ok(false) } /// Check if an album is "owned" — tracks from this album by this artist exist. pub async fn album_is_owned( conn: &DatabaseConnection, artist_name: &str, album_name: &str, ) -> WatchResult { let norm_artist = normalize(artist_name); let norm_album = normalize(album_name); // Try exact lookup 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?; if !tracks.is_empty() { return Ok(true); } } // Fuzzy: search tracks matching album name let tracks = queries::tracks::search(conn, album_name).await?; for track in &tracks { let album_match = track .album .as_deref() .map(|a| strsim::jaro_winkler(&norm_album, &normalize(a)) > 0.85) .unwrap_or(false); let artist_match = track .artist .as_deref() .or(track.album_artist.as_deref()) .map(|a| strsim::jaro_winkler(&norm_artist, &normalize(a)) > 0.85) .unwrap_or(false); if album_match && artist_match { return Ok(true); } } Ok(false) } /// Check if a specific track is "owned" — a track with matching artist + title exists. pub async fn track_is_owned( conn: &DatabaseConnection, artist_name: &str, title: &str, ) -> WatchResult { let norm_artist = normalize(artist_name); let norm_title = normalize(title); // Fuzzy: search tracks matching the title let tracks = queries::tracks::search(conn, title).await?; for track in &tracks { let title_match = track .title .as_deref() .map(|t| strsim::jaro_winkler(&norm_title, &normalize(t)) > 0.85) .unwrap_or(false); let artist_match = track .artist .as_deref() .or(track.album_artist.as_deref()) .map(|a| strsim::jaro_winkler(&norm_artist, &normalize(a)) > 0.85) .unwrap_or(false); if title_match && artist_match { return Ok(true); } } Ok(false) } #[cfg(test)] mod tests { use super::*; #[test] fn test_normalize() { assert_eq!(normalize(" Pink Floyd "), "pink floyd"); assert_eq!(normalize("RADIOHEAD"), "radiohead"); } #[test] fn test_normalize_unicode() { assert_eq!(normalize("café"), normalize("café")); } }