//! Minimal metadata abstraction shared between PMO crates. //! //! This crate provides a lightweight, async-friendly trait [`TrackMetadata`] for managing //! audio track metadata across different implementations. It supports both in-memory //! storage and database-backed implementations. //! //! # Features //! //! - **Async API**: All operations are asynchronous to support database backends //! - **Optional fields**: Implementations only need to override supported fields //! - **Error handling**: Distinguishes between transient errors (NotImplemented, ReadOnly) //! and backend errors that should be propagated //! - **Metadata copying**: Helper function to copy metadata between implementations //! - **Fallback cover**: Provides a default cover image when none is available //! //! # Examples //! //! ```rust //! use pmometadata::{TrackMetadata, MemoryTrackMetadata}; //! use std::time::Duration; //! //! # tokio_test::block_on(async { //! let mut metadata = MemoryTrackMetadata::new(); //! //! // Set some metadata //! metadata.set_title(Some("My Song".to_string())).await.unwrap(); //! metadata.set_artist(Some("Artist Name".to_string())).await.unwrap(); //! metadata.set_duration(Some(Duration::from_secs(180))).await.unwrap(); //! //! // Retrieve metadata //! assert_eq!(metadata.get_title().await.unwrap(), Some("My Song".to_string())); //! # }); //! ``` #![allow(async_fn_in_trait)] use async_trait::async_trait; use std::sync::Arc; use std::{ collections::HashMap, time::{Duration, SystemTime}, }; use tokio::sync::RwLock; /// Image SVG par défaut pour les covers manquantes. /// /// Cette constante contient un SVG élégant représentant une note de musique, /// utilisé comme fallback quand aucune cover n'est disponible. /// /// # Utilisation /// /// ```rust /// use pmometadata::DEFAULT_COVER_SVG; /// /// // Utiliser comme data URL /// let data_url = format!("data:image/svg+xml;utf8,{}", DEFAULT_COVER_SVG); /// ``` pub const DEFAULT_COVER_SVG: &str = r#" No Cover Available "#; /// Retourne l'URL de la cover par défaut comme data URL. /// /// Cette fonction peut être étendue à l'avenir pour accepter des paramètres /// de personnalisation (taille, couleur, etc.). /// /// # Exemples /// /// ```rust /// use pmometadata::get_default_cover_url; /// /// let url = get_default_cover_url(); /// // url commence par "data:image/svg+xml;utf8, String { format!("data:image/svg+xml;utf8,{}", DEFAULT_COVER_SVG) } /// Helper macro for copying a single metadata field. macro_rules! copy_a_metadata { ($src:ident, $dest:ident, $key:ident) => { ::paste::paste! { match $src.[]().await { Ok(Some(value)) => { match $dest.write().await.[](Some(value)).await { Ok(_) => {}, Err(e) if e.is_transient() => { // Transient error, try setting to None instead match $dest.write().await.[](None).await { Ok(_) => {}, Err(e2) if e2.is_transient() => {}, Err(e2) => return Err(e2), } } Err(e) => return Err(e), } }, Ok(None) => { match $dest.write().await.[](None).await { Ok(_) => {}, Err(e) if e.is_transient() => {}, Err(e) => return Err(e), } } Err(e) if e.is_transient() => { match $dest.write().await.[](None).await { Ok(_) => {}, Err(e2) if e2.is_transient() => {}, Err(e2) => return Err(e2), } } Err(e) => { return Err(e); } } } }; } /// Helper macro for copying multiple metadata fields. macro_rules! copy_metadata { ($src:ident, $dest:ident, $( $key:ident ),*) => { $(copy_a_metadata!($src, $dest, $key);)* }; } /// Convenience alias for metadata operations that can fail or return no value. /// /// Returns `Ok(Some(T))` when a value is present, `Ok(None)` when the field is empty, /// or `Err(MetadataError)` when an error occurs. pub type MetadataResult = Result, MetadataError>; /// Errors that can occur when manipulating metadata. #[derive(Debug, thiserror::Error)] pub enum MetadataError { /// The metadata field is not implemented by this provider. /// /// This is a transient error that indicates the implementation doesn't support /// this particular field. When copying metadata, these errors are ignored. #[error("metadata field is not implemented")] NotImplemented, /// The metadata field is read-only and cannot be modified. /// /// This is a transient error. When copying metadata, these errors are ignored. #[error("metadata field is read-only")] ReadOnly, /// An error occurred in the backend (e.g., database error). /// /// These errors are non-transient and should be propagated to the caller. #[error("backend error: {0}")] Backend(String), } impl MetadataError { /// Returns `true` if this error is transient and can be safely ignored. /// /// Transient errors (`NotImplemented`, `ReadOnly`) indicate that the operation /// is not supported but is not a critical failure. When copying metadata, /// transient errors result in setting the destination field to `None`. pub fn is_transient(&self) -> bool { matches!( self, MetadataError::NotImplemented | MetadataError::ReadOnly ) } } /// Trait implemented by metadata providers. /// /// This trait provides a uniform interface for accessing and modifying audio track metadata. /// All methods have default implementations that return `NotImplemented`, so implementations /// only need to override the fields they support. /// /// # Implementing the trait /// /// Implementations should: /// - Override `get_*` methods for readable fields /// - Override `set_*` methods for writable fields /// - Call `touch()` in setters to update the modification timestamp /// - Return `Ok(Some(value))` for successful operations /// - Return `Ok(None)` when a field is empty /// - Return `Err(NotImplemented)` for unsupported fields /// - Return `Err(ReadOnly)` for read-only fields /// - Return `Err(Backend(msg))` for backend errors /// /// # Thread safety /// /// All implementations must be `Send + Sync` to work in async contexts. #[async_trait] pub trait TrackMetadata: Send + Sync { async fn get_title(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_title(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_artist(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_artist(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_album(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_album(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_year(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_year(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_genre(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_genre(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_track_number(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_track_number(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_track_total(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_track_total(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_disc_number(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_disc_number(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_disc_total(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_disc_total(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_duration(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_duration(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_sample_rate(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_sample_rate(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_total_samples(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_total_samples(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_bits_per_sample(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_bits_per_sample(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_channels(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_channels(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_bitrate(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_bitrate(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_track_id(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_track_id(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_channel_id(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_channel_id(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_event(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_event(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_rating(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_rating(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_cover_url(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_cover_url(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_cover_pk(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn set_cover_pk(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } /// Retourne l'URL de la cover avec logique de fallback. /// /// Cette méthode implémente la logique de priorité suivante : /// 1. Si `cover_pk` est défini, retourne `Some(cover_pk)` (pour utilisation avec le cover cache) /// 2. Sinon, si `cover_url` est défini, retourne `Some(cover_url)` (URL externe) /// 3. Sinon, retourne `None` /// /// Si vous voulez toujours obtenir une URL (avec image par défaut), /// utilisez [`get_cover_url_or_default`] à la place. /// /// Les implémentations peuvent overrider cette méthode si elles veulent un comportement /// différent, mais l'implémentation par défaut devrait convenir à la plupart des cas. /// /// # Exemples /// /// ```rust /// use pmometadata::{TrackMetadata, MemoryTrackMetadata}; /// /// # tokio_test::block_on(async { /// let mut metadata = MemoryTrackMetadata::new(); /// /// // Cas 1: cover_pk défini (prioritaire) /// metadata.set_cover_pk(Some("abc123".to_string())).await.unwrap(); /// metadata.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap(); /// assert_eq!( /// metadata.get_cover_url_with_fallback().await.unwrap(), /// Some("abc123".to_string()) /// ); /// /// // Cas 2: seulement cover_url /// let mut metadata2 = MemoryTrackMetadata::new(); /// metadata2.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap(); /// assert_eq!( /// metadata2.get_cover_url_with_fallback().await.unwrap(), /// Some("https://example.com/cover.jpg".to_string()) /// ); /// /// // Cas 3: aucune cover /// let metadata3 = MemoryTrackMetadata::new(); /// assert_eq!(metadata3.get_cover_url_with_fallback().await.unwrap(), None); /// # }); /// ``` async fn get_cover_url_with_fallback(&self) -> MetadataResult { // Priorité 1: cover_pk (cache local) if let Ok(Some(pk)) = self.get_cover_pk().await { if !pk.is_empty() { return Ok(Some(pk)); } } // Priorité 2: cover_url (URL externe) if let Ok(Some(url)) = self.get_cover_url().await { if !url.is_empty() { return Ok(Some(url)); } } // Aucune cover disponible Ok(None) } /// Retourne l'URL de la cover ou l'image par défaut. /// /// Contrairement à [`get_cover_url_with_fallback`], cette méthode retourne **toujours** /// une URL utilisable, en fournissant une image SVG par défaut si aucune cover n'est disponible. /// /// La logique de priorité est : /// 1. `cover_pk` (cache local) /// 2. `cover_url` (URL externe) /// 3. Image SVG par défaut (data URL) /// /// # Exemples /// /// ```rust /// use pmometadata::{TrackMetadata, MemoryTrackMetadata}; /// /// # tokio_test::block_on(async { /// // Sans cover : retourne l'image par défaut /// let metadata = MemoryTrackMetadata::new(); /// let url = metadata.get_cover_url_or_default().await.unwrap(); /// assert!(url.starts_with("data:image/svg+xml")); /// /// // Avec cover : retourne la cover /// let mut metadata2 = MemoryTrackMetadata::new(); /// metadata2.set_cover_pk(Some("abc123".to_string())).await.unwrap(); /// assert_eq!( /// metadata2.get_cover_url_or_default().await.unwrap(), /// "abc123" /// ); /// # }); /// ``` async fn get_cover_url_or_default(&self) -> Result { match self.get_cover_url_with_fallback().await { Ok(Some(url)) => Ok(url), Ok(None) => Ok(get_default_cover_url()), Err(e) => Err(e), } } async fn get_extra(&self) -> MetadataResult> { Err(MetadataError::NotImplemented) } async fn set_extra(&mut self, _value: Option>) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } async fn get_updated_at(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } async fn touch(&mut self) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } } /// Copies all available metadata from one implementation to another. /// /// This function reads all metadata fields from the source and attempts to write them /// to the destination. It handles errors intelligently: /// /// - **Transient errors** (`NotImplemented`, `ReadOnly`): The field is set to `None` in the destination /// - **Backend errors**: The error is propagated to the caller /// - **Success**: The value is copied from source to destination /// /// After all fields are copied, the destination's `touch()` method is called to update /// its modification timestamp. /// /// # Arguments /// /// * `src` - Source metadata wrapped in `Arc>` /// * `dest` - Destination metadata wrapped in `Arc>` /// /// # Errors /// /// Returns an error if: /// - The destination returns a backend error when setting a field /// - The destination's `touch()` method returns an error /// /// # Examples /// /// ```rust /// use pmometadata::{TrackMetadata, MemoryTrackMetadata, copy_metadata_into}; /// use std::sync::Arc; /// use tokio::sync::RwLock; /// /// # tokio_test::block_on(async { /// let mut src = MemoryTrackMetadata::new(); /// src.set_title(Some("My Song".to_string())).await.unwrap(); /// src.set_artist(Some("Artist".to_string())).await.unwrap(); /// /// let dest = MemoryTrackMetadata::new(); /// /// let src_lock = Arc::new(RwLock::new(src)); /// let dest_lock = Arc::new(RwLock::new(dest)); /// /// copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); /// /// assert_eq!( /// dest_lock.read().await.get_title().await.unwrap(), /// Some("My Song".to_string()) /// ); /// # }); /// ``` pub async fn copy_metadata_into( src: &Arc>, dest: &Arc>, ) -> Result<(), MetadataError> where S: TrackMetadata + ?Sized, D: TrackMetadata + ?Sized, { let src_guard = src.read().await; copy_metadata!( src_guard, dest, title, artist, album, year, duration, sample_rate, total_samples, bits_per_sample, track_id, channel_id, event, rating, cover_url, cover_pk, extra ); // Try to update the timestamp, but ignore transient errors match dest.write().await.touch().await { Ok(_) => Ok(()), Err(e) if e.is_transient() => Ok(()), Err(e) => Err(e), } } /// In-memory metadata implementation with full read/write support. #[derive(Clone, Debug, Default, PartialEq)] pub struct MemoryTrackMetadata { title: Option, artist: Option, album: Option, year: Option, genre: Option, track_number: Option, track_total: Option, disc_number: Option, disc_total: Option, duration: Option, sample_rate: Option, total_samples: Option, bits_per_sample: Option, channels: Option, bitrate: Option, track_id: Option, channel_id: Option, event: Option, rating: Option, cover_url: Option, cover_pk: Option, updated_at: Option, extra: Option>, } impl MemoryTrackMetadata { pub fn new() -> Self { Self::default() } } #[async_trait] impl TrackMetadata for MemoryTrackMetadata { async fn get_title(&self) -> MetadataResult { Ok(self.title.clone()) } async fn set_title(&mut self, value: Option) -> MetadataResult<()> { self.title = value; self.touch().await?; Ok(Some(())) } async fn get_artist(&self) -> MetadataResult { Ok(self.artist.clone()) } async fn set_artist(&mut self, value: Option) -> MetadataResult<()> { self.artist = value; self.touch().await?; Ok(Some(())) } async fn get_album(&self) -> MetadataResult { Ok(self.album.clone()) } async fn set_album(&mut self, value: Option) -> MetadataResult<()> { self.album = value; self.touch().await?; Ok(Some(())) } async fn get_year(&self) -> MetadataResult { Ok(self.year) } async fn set_year(&mut self, value: Option) -> MetadataResult<()> { self.year = value; self.touch().await?; Ok(Some(())) } async fn get_genre(&self) -> MetadataResult { Ok(self.genre.clone()) } async fn set_genre(&mut self, value: Option) -> MetadataResult<()> { self.genre = value; self.touch().await?; Ok(Some(())) } async fn get_track_number(&self) -> MetadataResult { Ok(self.track_number) } async fn set_track_number(&mut self, value: Option) -> MetadataResult<()> { self.track_number = value; self.touch().await?; Ok(Some(())) } async fn get_track_total(&self) -> MetadataResult { Ok(self.track_total) } async fn set_track_total(&mut self, value: Option) -> MetadataResult<()> { self.track_total = value; self.touch().await?; Ok(Some(())) } async fn get_disc_number(&self) -> MetadataResult { Ok(self.disc_number) } async fn set_disc_number(&mut self, value: Option) -> MetadataResult<()> { self.disc_number = value; self.touch().await?; Ok(Some(())) } async fn get_disc_total(&self) -> MetadataResult { Ok(self.disc_total) } async fn set_disc_total(&mut self, value: Option) -> MetadataResult<()> { self.disc_total = value; self.touch().await?; Ok(Some(())) } async fn get_duration(&self) -> MetadataResult { Ok(self.duration) } async fn set_duration(&mut self, value: Option) -> MetadataResult<()> { self.duration = value; self.touch().await?; Ok(Some(())) } async fn get_sample_rate(&self) -> MetadataResult { Ok(self.sample_rate) } async fn set_sample_rate(&mut self, value: Option) -> MetadataResult<()> { self.sample_rate = value; self.touch().await?; Ok(Some(())) } async fn get_total_samples(&self) -> MetadataResult { Ok(self.total_samples) } async fn set_total_samples(&mut self, value: Option) -> MetadataResult<()> { self.total_samples = value; self.touch().await?; Ok(Some(())) } async fn get_bits_per_sample(&self) -> MetadataResult { Ok(self.bits_per_sample) } async fn set_bits_per_sample(&mut self, value: Option) -> MetadataResult<()> { self.bits_per_sample = value; self.touch().await?; Ok(Some(())) } async fn get_channels(&self) -> MetadataResult { Ok(self.channels) } async fn set_channels(&mut self, value: Option) -> MetadataResult<()> { self.channels = value; self.touch().await?; Ok(Some(())) } async fn get_bitrate(&self) -> MetadataResult { Ok(self.bitrate) } async fn set_bitrate(&mut self, value: Option) -> MetadataResult<()> { self.bitrate = value; self.touch().await?; Ok(Some(())) } async fn get_track_id(&self) -> MetadataResult { Ok(self.track_id.clone()) } async fn set_track_id(&mut self, value: Option) -> MetadataResult<()> { self.track_id = value; self.touch().await?; Ok(Some(())) } async fn get_channel_id(&self) -> MetadataResult { Ok(self.channel_id.clone()) } async fn set_channel_id(&mut self, value: Option) -> MetadataResult<()> { self.channel_id = value; self.touch().await?; Ok(Some(())) } async fn get_event(&self) -> MetadataResult { Ok(self.event.clone()) } async fn set_event(&mut self, value: Option) -> MetadataResult<()> { self.event = value; self.touch().await?; Ok(Some(())) } async fn get_rating(&self) -> MetadataResult { Ok(self.rating) } async fn set_rating(&mut self, value: Option) -> MetadataResult<()> { self.rating = value; self.touch().await?; Ok(Some(())) } async fn get_cover_url(&self) -> MetadataResult { Ok(self.cover_url.clone()) } async fn set_cover_url(&mut self, value: Option) -> MetadataResult<()> { self.cover_url = value; self.touch().await?; Ok(Some(())) } async fn get_cover_pk(&self) -> MetadataResult { Ok(self.cover_pk.clone()) } async fn set_cover_pk(&mut self, value: Option) -> MetadataResult<()> { self.cover_pk = value; self.touch().await?; Ok(Some(())) } async fn get_extra(&self) -> MetadataResult> { Ok(self.extra.clone()) } async fn set_extra(&mut self, value: Option>) -> MetadataResult<()> { self.extra = value; self.touch().await?; Ok(Some(())) } async fn get_updated_at(&self) -> MetadataResult { Ok(self.updated_at) } async fn touch(&mut self) -> MetadataResult<()> { self.updated_at = Some(SystemTime::now()); Ok(Some(())) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_memory_metadata_new() { let metadata = MemoryTrackMetadata::new(); assert_eq!(metadata.get_title().await.unwrap(), None); assert_eq!(metadata.get_artist().await.unwrap(), None); assert_eq!(metadata.get_album().await.unwrap(), None); assert_eq!(metadata.get_year().await.unwrap(), None); assert_eq!(metadata.get_duration().await.unwrap(), None); assert_eq!(metadata.get_sample_rate().await.unwrap(), None); assert_eq!(metadata.get_total_samples().await.unwrap(), None); assert_eq!(metadata.get_bits_per_sample().await.unwrap(), None); assert_eq!(metadata.get_updated_at().await.unwrap(), None); } #[tokio::test] async fn test_memory_metadata_set_get_title() { let mut metadata = MemoryTrackMetadata::new(); let result = metadata.set_title(Some("Test Title".to_string())).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), Some(())); let title = metadata.get_title().await.unwrap(); assert_eq!(title, Some("Test Title".to_string())); } #[tokio::test] async fn test_memory_metadata_set_get_artist() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_artist(Some("Artist Name".to_string())) .await .unwrap(); assert_eq!( metadata.get_artist().await.unwrap(), Some("Artist Name".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_album() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_album(Some("Album Name".to_string())) .await .unwrap(); assert_eq!( metadata.get_album().await.unwrap(), Some("Album Name".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_year() { let mut metadata = MemoryTrackMetadata::new(); metadata.set_year(Some(2024)).await.unwrap(); assert_eq!(metadata.get_year().await.unwrap(), Some(2024)); } #[tokio::test] async fn test_memory_metadata_set_get_duration() { let mut metadata = MemoryTrackMetadata::new(); let duration = Duration::from_secs(180); metadata.set_duration(Some(duration)).await.unwrap(); assert_eq!(metadata.get_duration().await.unwrap(), Some(duration)); } #[tokio::test] async fn test_memory_metadata_set_get_rating() { let mut metadata = MemoryTrackMetadata::new(); metadata.set_rating(Some(4.5)).await.unwrap(); assert_eq!(metadata.get_rating().await.unwrap(), Some(4.5)); } #[tokio::test] async fn test_memory_metadata_set_get_track_id() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_track_id(Some("track123".to_string())) .await .unwrap(); assert_eq!( metadata.get_track_id().await.unwrap(), Some("track123".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_channel_id() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_channel_id(Some("channel456".to_string())) .await .unwrap(); assert_eq!( metadata.get_channel_id().await.unwrap(), Some("channel456".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_event() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_event(Some("event789".to_string())) .await .unwrap(); assert_eq!( metadata.get_event().await.unwrap(), Some("event789".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_cover_url() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_cover_url(Some("https://example.com/cover.jpg".to_string())) .await .unwrap(); assert_eq!( metadata.get_cover_url().await.unwrap(), Some("https://example.com/cover.jpg".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_cover_pk() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_cover_pk(Some("pk123".to_string())) .await .unwrap(); assert_eq!( metadata.get_cover_pk().await.unwrap(), Some("pk123".to_string()) ); } #[tokio::test] async fn test_memory_metadata_set_get_extra() { let mut metadata = MemoryTrackMetadata::new(); let mut extra = HashMap::new(); extra.insert("key1".to_string(), "value1".to_string()); extra.insert("key2".to_string(), "value2".to_string()); metadata.set_extra(Some(extra.clone())).await.unwrap(); assert_eq!(metadata.get_extra().await.unwrap(), Some(extra)); } #[tokio::test] async fn test_memory_metadata_set_none() { let mut metadata = MemoryTrackMetadata::new(); metadata.set_title(Some("Title".to_string())).await.unwrap(); assert_eq!( metadata.get_title().await.unwrap(), Some("Title".to_string()) ); metadata.set_title(None).await.unwrap(); assert_eq!(metadata.get_title().await.unwrap(), None); } #[tokio::test] async fn test_memory_metadata_touch() { let mut metadata = MemoryTrackMetadata::new(); assert_eq!(metadata.get_updated_at().await.unwrap(), None); metadata.touch().await.unwrap(); let updated_at = metadata.get_updated_at().await.unwrap(); assert!(updated_at.is_some()); } #[tokio::test] async fn test_memory_metadata_touch_on_set() { let mut metadata = MemoryTrackMetadata::new(); assert_eq!(metadata.get_updated_at().await.unwrap(), None); metadata.set_title(Some("Title".to_string())).await.unwrap(); let updated_at = metadata.get_updated_at().await.unwrap(); assert!(updated_at.is_some()); } #[tokio::test] async fn test_metadata_error_is_transient() { assert!(MetadataError::NotImplemented.is_transient()); assert!(MetadataError::ReadOnly.is_transient()); assert!(!MetadataError::Backend("error".to_string()).is_transient()); } #[tokio::test] async fn test_copy_metadata_into_basic() { let mut src = MemoryTrackMetadata::new(); src.set_title(Some("Source Title".to_string())) .await .unwrap(); src.set_artist(Some("Source Artist".to_string())) .await .unwrap(); src.set_year(Some(2024)).await.unwrap(); let dest = MemoryTrackMetadata::new(); let src_lock = Arc::new(RwLock::new(src)); let dest_lock = Arc::new(RwLock::new(dest)); copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let dest_guard = dest_lock.read().await; assert_eq!( dest_guard.get_title().await.unwrap(), Some("Source Title".to_string()) ); assert_eq!( dest_guard.get_artist().await.unwrap(), Some("Source Artist".to_string()) ); assert_eq!(dest_guard.get_year().await.unwrap(), Some(2024)); } #[tokio::test] async fn test_copy_metadata_into_partial() { let mut src = MemoryTrackMetadata::new(); src.set_title(Some("Title".to_string())).await.unwrap(); // artist is None let dest = MemoryTrackMetadata::new(); let src_lock = Arc::new(RwLock::new(src)); let dest_lock = Arc::new(RwLock::new(dest)); copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let dest_guard = dest_lock.read().await; assert_eq!( dest_guard.get_title().await.unwrap(), Some("Title".to_string()) ); assert_eq!(dest_guard.get_artist().await.unwrap(), None); } #[tokio::test] async fn test_copy_metadata_into_updates_timestamp() { let src = MemoryTrackMetadata::new(); let dest = MemoryTrackMetadata::new(); let src_lock = Arc::new(RwLock::new(src)); let dest_lock = Arc::new(RwLock::new(dest)); let before = dest_lock.read().await.get_updated_at().await.unwrap(); assert_eq!(before, None); copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let after = dest_lock.read().await.get_updated_at().await.unwrap(); assert!(after.is_some()); } #[tokio::test] async fn test_copy_metadata_into_all_fields() { let mut src = MemoryTrackMetadata::new(); src.set_title(Some("Title".to_string())).await.unwrap(); src.set_artist(Some("Artist".to_string())).await.unwrap(); src.set_album(Some("Album".to_string())).await.unwrap(); src.set_year(Some(2024)).await.unwrap(); src.set_duration(Some(Duration::from_secs(180))) .await .unwrap(); src.set_track_id(Some("track123".to_string())) .await .unwrap(); src.set_channel_id(Some("channel456".to_string())) .await .unwrap(); src.set_event(Some("event789".to_string())).await.unwrap(); src.set_rating(Some(4.5)).await.unwrap(); src.set_cover_url(Some("https://example.com/cover.jpg".to_string())) .await .unwrap(); src.set_cover_pk(Some("pk123".to_string())).await.unwrap(); let mut extra = HashMap::new(); extra.insert("key".to_string(), "value".to_string()); src.set_extra(Some(extra.clone())).await.unwrap(); let dest = MemoryTrackMetadata::new(); let src_lock = Arc::new(RwLock::new(src)); let dest_lock = Arc::new(RwLock::new(dest)); copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let dest_guard = dest_lock.read().await; assert_eq!( dest_guard.get_title().await.unwrap(), Some("Title".to_string()) ); assert_eq!( dest_guard.get_artist().await.unwrap(), Some("Artist".to_string()) ); assert_eq!( dest_guard.get_album().await.unwrap(), Some("Album".to_string()) ); assert_eq!(dest_guard.get_year().await.unwrap(), Some(2024)); assert_eq!( dest_guard.get_duration().await.unwrap(), Some(Duration::from_secs(180)) ); assert_eq!( dest_guard.get_track_id().await.unwrap(), Some("track123".to_string()) ); assert_eq!( dest_guard.get_channel_id().await.unwrap(), Some("channel456".to_string()) ); assert_eq!( dest_guard.get_event().await.unwrap(), Some("event789".to_string()) ); assert_eq!(dest_guard.get_rating().await.unwrap(), Some(4.5)); assert_eq!( dest_guard.get_cover_url().await.unwrap(), Some("https://example.com/cover.jpg".to_string()) ); assert_eq!( dest_guard.get_cover_pk().await.unwrap(), Some("pk123".to_string()) ); assert_eq!(dest_guard.get_extra().await.unwrap(), Some(extra)); } #[tokio::test] async fn test_copy_metadata_handles_errors() { // Test that copy works even when some fields can't be set let mut src = MemoryTrackMetadata::new(); src.set_title(Some("Title".to_string())).await.unwrap(); src.set_artist(Some("Artist".to_string())).await.unwrap(); let dest = MemoryTrackMetadata::new(); let src_lock = Arc::new(RwLock::new(src)); let dest_lock = Arc::new(RwLock::new(dest)); // Should succeed let result = copy_metadata_into(&src_lock, &dest_lock).await; assert!(result.is_ok()); // Verify the data was copied let dest_guard = dest_lock.read().await; assert_eq!( dest_guard.get_title().await.unwrap(), Some("Title".to_string()) ); assert_eq!( dest_guard.get_artist().await.unwrap(), Some("Artist".to_string()) ); } #[tokio::test] async fn test_get_cover_url_with_fallback_cover_pk_priority() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_cover_pk(Some("abc123".to_string())) .await .unwrap(); metadata .set_cover_url(Some("https://example.com/cover.jpg".to_string())) .await .unwrap(); // cover_pk should have priority assert_eq!( metadata.get_cover_url_with_fallback().await.unwrap(), Some("abc123".to_string()) ); } #[tokio::test] async fn test_get_cover_url_with_fallback_cover_url_only() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_cover_url(Some("https://example.com/cover.jpg".to_string())) .await .unwrap(); assert_eq!( metadata.get_cover_url_with_fallback().await.unwrap(), Some("https://example.com/cover.jpg".to_string()) ); } #[tokio::test] async fn test_get_cover_url_with_fallback_none() { let metadata = MemoryTrackMetadata::new(); assert_eq!( metadata.get_cover_url_with_fallback().await.unwrap(), None ); } #[tokio::test] async fn test_get_cover_url_with_fallback_empty_strings() { let mut metadata = MemoryTrackMetadata::new(); metadata.set_cover_pk(Some("".to_string())).await.unwrap(); metadata .set_cover_url(Some("https://example.com/cover.jpg".to_string())) .await .unwrap(); // Empty cover_pk should fallback to cover_url assert_eq!( metadata.get_cover_url_with_fallback().await.unwrap(), Some("https://example.com/cover.jpg".to_string()) ); } #[tokio::test] async fn test_get_cover_url_or_default_with_cover_pk() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_cover_pk(Some("abc123".to_string())) .await .unwrap(); assert_eq!( metadata.get_cover_url_or_default().await.unwrap(), "abc123" ); } #[tokio::test] async fn test_get_cover_url_or_default_with_cover_url() { let mut metadata = MemoryTrackMetadata::new(); metadata .set_cover_url(Some("https://example.com/cover.jpg".to_string())) .await .unwrap(); assert_eq!( metadata.get_cover_url_or_default().await.unwrap(), "https://example.com/cover.jpg" ); } #[tokio::test] async fn test_get_cover_url_or_default_no_cover() { let metadata = MemoryTrackMetadata::new(); let url = metadata.get_cover_url_or_default().await.unwrap(); // Should return the default SVG data URL assert!(url.starts_with("data:image/svg+xml")); assert!(url.contains("