mecomp_daemon/
dynamic_updates.rs

1//! Module for handling dynamic updates to the music library.
2//!
3//! This module is only available when the `dynamic_updates` feature is enabled.
4//!
5//! The `init_music_library_watcher`
6use std::{path::PathBuf, sync::Arc, time::Duration};
7
8use futures::FutureExt;
9use futures::StreamExt;
10use log::{debug, error, info, trace, warn};
11use mecomp_storage::db::schemas::song::{Song, SongChangeSet, SongMetadata};
12#[cfg(target_os = "macos")]
13use notify::FsEventWatcher;
14#[cfg(target_os = "linux")]
15use notify::INotifyWatcher;
16#[cfg(target_os = "windows")]
17use notify::ReadDirectoryChangesWatcher;
18use notify::{
19    EventKind, RecursiveMode,
20    event::{CreateKind, MetadataKind, ModifyKind, RemoveKind, RenameMode},
21};
22use notify_debouncer_full::RecommendedCache;
23use notify_debouncer_full::{DebouncedEvent, Debouncer, new_debouncer};
24use one_or_many::OneOrMany;
25use surrealdb::{Surreal, engine::local::Db};
26
27#[cfg(target_os = "linux")]
28type WatcherType = INotifyWatcher;
29#[cfg(target_os = "macos")]
30type WatcherType = FsEventWatcher;
31#[cfg(target_os = "windows")]
32type WatcherType = ReadDirectoryChangesWatcher;
33
34const VALID_AUDIO_EXTENSIONS: [&str; 4] = ["mp3", "wav", "ogg", "flac"];
35
36pub const MAX_DEBOUNCE_TIME: Duration = Duration::from_millis(500);
37
38/// uses the notify crate to update
39/// the internal music library (database) when changes to configured
40/// music library directories are detected.
41///
42/// this watcher is terminated when the returned value is dropped.
43///
44/// # Arguments
45///
46/// * `library_paths` - The root paths of the music library.
47/// * `db` - The database connection used to update the library.
48///
49/// # Returns
50///
51/// If the watchers were successfully started, it is returned.
52/// it will stop when it is dropped.
53///
54/// # Errors
55///
56/// If the watcher could not be started, an error is returned.
57#[allow(clippy::missing_inline_in_public_items)]
58pub fn init_music_library_watcher(
59    db: Arc<Surreal<Db>>,
60    library_paths: &[PathBuf],
61    artist_name_separator: OneOrMany<String>,
62    protected_artist_names: OneOrMany<String>,
63    genre_separator: Option<String>,
64) -> anyhow::Result<MusicLibEventHandlerGuard> {
65    let (tx, rx) = futures::channel::mpsc::unbounded();
66    // create a oneshot that can be used to stop the watcher
67    let (stop_tx, stop_rx) = futures::channel::oneshot::channel();
68
69    // spawn the event handler in a new thread
70    std::thread::Builder::new()
71        .name(String::from("Music Library Watcher"))
72        .spawn(move || {
73            let handler = MusicLibEventHandler::new(
74                db,
75                artist_name_separator,
76                protected_artist_names,
77                genre_separator,
78            );
79            futures::executor::block_on(async {
80                let mut stop_rx = stop_rx.fuse();
81                let mut rx = rx.fuse();
82
83                loop {
84                    futures::select! {
85                        _ = stop_rx => {
86                            debug!("stopping watcher");
87                            break;
88                        }
89                        result = rx.select_next_some() => {
90                            match result {
91                                Ok(events) => {
92                                    for event in events {
93                                        if let Err(e) = handler.handle_event(event).await {
94                                            error!("failed to handle event: {e:?}");
95                                        }
96                                    }
97                                }
98                                Err(errors) => {
99                                    for error in errors {
100                                        error!("watch error: {error:?}");
101                                    }
102                                }
103                            }
104                        }
105                    }
106                }
107            });
108        })?;
109
110    // Select recommended watcher for debouncer.
111    // Using a callback here, could also be a channel.
112    let mut debouncer: Debouncer<WatcherType, _> =
113        new_debouncer(MAX_DEBOUNCE_TIME, None, move |event| {
114            let _ = tx.unbounded_send(event);
115        })?;
116
117    // Add all library paths to the debouncer.
118    for path in library_paths {
119        log::debug!("watching path: {path:?}");
120        // Add a path to be watched. All files and directories at that path and
121        // below will be monitored for changes.
122        debouncer.watch(path, RecursiveMode::Recursive)?;
123    }
124
125    Ok(MusicLibEventHandlerGuard { debouncer, stop_tx })
126}
127
128pub struct MusicLibEventHandlerGuard {
129    debouncer: Debouncer<WatcherType, RecommendedCache>,
130    stop_tx: futures::channel::oneshot::Sender<()>,
131}
132
133impl MusicLibEventHandlerGuard {
134    #[inline]
135    pub fn stop(self) {
136        let Self { debouncer, stop_tx } = self;
137        stop_tx.send(()).ok();
138        debouncer.stop();
139    }
140}
141
142/// Handles incoming file Events.
143struct MusicLibEventHandler {
144    db: Arc<Surreal<Db>>,
145    artist_name_separator: OneOrMany<String>,
146    protected_artist_names: OneOrMany<String>,
147    genre_separator: Option<String>,
148}
149
150impl MusicLibEventHandler {
151    /// Creates a new `MusicLibEventHandler`.
152    pub const fn new(
153        db: Arc<Surreal<Db>>,
154        artist_name_separator: OneOrMany<String>,
155        protected_artist_names: OneOrMany<String>,
156        genre_separator: Option<String>,
157    ) -> Self {
158        Self {
159            db,
160            artist_name_separator,
161            protected_artist_names,
162            genre_separator,
163        }
164    }
165
166    /// Handles incoming file Events.
167    async fn handle_event(&self, event: DebouncedEvent) -> anyhow::Result<()> {
168        trace!("file event detected: {event:?}");
169
170        match event.kind {
171            // remove events
172            EventKind::Remove(kind) => {
173                self.remove_event_handler(event, kind).await?;
174            }
175            // create events
176            EventKind::Create(kind) => {
177                self.create_event_handler(event, kind).await?;
178            }
179            // modify events
180            EventKind::Modify(kind) => {
181                self.modify_event_handler(event, kind).await?;
182            }
183            // other events
184            EventKind::Any => {
185                warn!("unhandled event (Any): {:?}", event.paths);
186            }
187            EventKind::Other => {
188                warn!("unhandled event (Other): {:?}", event.paths);
189            }
190            EventKind::Access(_) => {}
191        }
192
193        Ok(())
194    }
195
196    // handler for remove events
197    async fn remove_event_handler(
198        &self,
199        event: DebouncedEvent,
200        kind: RemoveKind,
201    ) -> anyhow::Result<()> {
202        match kind {
203            RemoveKind::File => {
204                if let Some(path) = event.paths.first() {
205                    match path.extension().map(|ext| ext.to_str()) {
206                        Some(Some(ext)) if VALID_AUDIO_EXTENSIONS.contains(&ext) => {
207                            info!("file removed: {:?}. removing from db", event.paths);
208                            let song = Song::read_by_path(&self.db, path.clone()).await?;
209                            if let Some(song) = song {
210                                Song::delete(&self.db, song.id).await?;
211                            }
212                        }
213                        _ => {
214                            debug!(
215                                "file removed: {:?}. not a song, no action needed",
216                                event.paths
217                            );
218                        }
219                    }
220                }
221            }
222            RemoveKind::Folder => {} // if an empty folder is removed, no action needed
223            RemoveKind::Any | RemoveKind::Other => {
224                warn!(
225                    "unhandled remove event: {:?}. rescan recommended",
226                    event.paths
227                );
228            }
229        }
230
231        Ok(())
232    }
233
234    // handler for create events
235    async fn create_event_handler(
236        &self,
237        event: DebouncedEvent,
238        kind: CreateKind,
239    ) -> anyhow::Result<()> {
240        match kind {
241            CreateKind::File => {
242                if let Some(path) = event.paths.first() {
243                    match path.extension().map(|ext| ext.to_str()) {
244                        Some(Some(ext)) if VALID_AUDIO_EXTENSIONS.contains(&ext) => {
245                            info!("file created: {:?}. adding to db", event.paths);
246
247                            let metadata = SongMetadata::load_from_path(
248                                path.to_owned(),
249                                &self.artist_name_separator,
250                                &self.protected_artist_names,
251                                self.genre_separator.as_deref(),
252                            )?;
253
254                            Song::try_load_into_db(&self.db, metadata).await?;
255                        }
256                        _ => {
257                            debug!(
258                                "file created: {:?}. not a song, no action needed",
259                                event.paths
260                            );
261                        }
262                    }
263                }
264            }
265            CreateKind::Folder => {
266                debug!("folder created: {:?}. no action needed", event.paths);
267            }
268            CreateKind::Any | CreateKind::Other => {
269                warn!(
270                    "unhandled create event: {:?}. rescan recommended",
271                    event.paths
272                );
273            }
274        }
275        Ok(())
276    }
277
278    // handler for modify events
279    async fn modify_event_handler(
280        &self,
281        event: DebouncedEvent,
282        kind: ModifyKind,
283    ) -> anyhow::Result<()> {
284        match kind {
285            // file data modified
286            ModifyKind::Data(kind) => if let Some(path) = event.paths.first() {
287                match path.extension().map(|ext| ext.to_str()) {
288                    Some(Some(ext)) if VALID_AUDIO_EXTENSIONS.contains(&ext) => {
289                        info!("file data modified ({kind:?}): {:?}. updating in db", event.paths);
290
291                        // NOTE: if this fails, the song may just not've been added previously, may want to handle that in the future
292                        let song = Song::read_by_path(&self.db, path.clone()).await?.ok_or(mecomp_storage::errors::Error::NotFound)?;
293
294                        let new_metadata: SongMetadata = SongMetadata::load_from_path(
295                            path.to_owned(),
296                            &self.artist_name_separator,
297                            &self.protected_artist_names,
298                            self.genre_separator.as_deref(),
299                        )?;
300
301                        let changeset = new_metadata.merge_with_song(&song);
302
303                        Song::update(&self.db, song.id, changeset).await?;
304                    }
305                    _ => {
306                        debug!("file data modified ({kind:?}): {:?}.  not a song, no action needed", event.paths);
307                    }
308                }
309            },
310            // file name (path) modified
311            ModifyKind::Name(RenameMode::Both) => {
312                if let (Some(from_path),Some(to_path)) = (event.paths.first(), event.paths.get(1)) {
313                     match (from_path.extension().map(|ext| ext.to_string_lossy()),to_path.extension().map(|ext| ext.to_string_lossy())) {
314                        (Some(from_ext), Some(to_ext)) if VALID_AUDIO_EXTENSIONS.iter().any(|ext| *ext == from_ext) && VALID_AUDIO_EXTENSIONS.iter().any(|ext| *ext == to_ext) => {
315                            info!("file name modified: {:?}. updating in db",
316                            event.paths);
317
318                            // NOTE: if this fails, the song may just not've been added previously, may want to handle that in the future
319                            let song = Song::read_by_path(&self.db, from_path.clone()).await?.ok_or(mecomp_storage::errors::Error::NotFound)?;
320
321                            Song::update(&self.db, song.id, SongChangeSet{
322                                path: Some(to_path.clone()),
323                                ..Default::default()
324                            }).await?;
325
326                        }
327                        _ => {
328                            debug!(
329                                "file name modified: {:?}. not a song, no action needed",
330                                event.paths
331                            );
332                        }
333                    }
334                }
335
336            }
337            ModifyKind::Name(
338                kind @ (
339                    RenameMode::From // likely a Remove event
340                |  RenameMode::To // likely a Create event
341            )) => {
342                warn!(
343                    "file name modified ({kind:?}): {:?}. not enough info to handle properly, rescan recommended",
344                    event.paths
345                );
346            }
347            ModifyKind::Name(RenameMode::Other | RenameMode::Any) => {
348                warn!(
349                    "unhandled file name modification: {:?}. rescan recommended",
350                    event.paths
351                );
352            }
353            // file attributes modified
354            ModifyKind::Metadata(
355                MetadataKind::AccessTime
356                | MetadataKind::WriteTime
357                | MetadataKind::Ownership
358                | MetadataKind::Permissions,
359            ) => {}
360            ModifyKind::Metadata(kind@(MetadataKind::Any | MetadataKind::Other | MetadataKind::Extended)) => {
361                warn!(
362                    "unhandled metadata modification ({kind:?}): {:?}. rescan recommended",
363                    event.paths
364                );
365            }
366            // other modification event
367            ModifyKind::Any | ModifyKind::Other => {
368                warn!(
369                    "unhandled modify event: {:?}. rescan recommended",
370                    event.paths
371                );
372            }
373        }
374        Ok(())
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    //! Tests for the `dynamic_updates` module.
381    //!
382    //! These tests initialize the database, and create a temporary music library directory
383    //!
384    //! The tests then create a `MusicLibEventHandler` and test the event handlers
385    //! by adding, modifying, and removing files in the temporary music library directory
386    //!
387    //! The way many of these tests work is by exploiting the timeout attribute.
388    //! Rather than asserting that certain state changes have occurred, we wait for them to occur within a certain time frame.
389
390    use crate::test_utils::init;
391
392    use super::*;
393
394    use lofty::file::AudioFile;
395    use pretty_assertions::assert_eq;
396    use rstest::{fixture, rstest};
397    use tempfile::{TempDir, tempdir};
398
399    use mecomp_storage::test_utils::{
400        ARTIST_NAME_SEPARATOR, arb_song_case, create_song_metadata, init_test_database,
401    };
402
403    #[fixture]
404    async fn setup() -> (TempDir, Arc<Surreal<Db>>, MusicLibEventHandlerGuard) {
405        init();
406        let music_lib = tempdir().expect("Failed to create temporary directory");
407        let db = Arc::new(init_test_database().await.unwrap());
408        let handler = init_music_library_watcher(
409            db.clone(),
410            &[music_lib.path().to_owned()],
411            OneOrMany::One(ARTIST_NAME_SEPARATOR.into()),
412            OneOrMany::None,
413            Some(ARTIST_NAME_SEPARATOR.into()),
414        )
415        .expect("Failed to create music library watcher");
416
417        (music_lib, db, handler)
418    }
419
420    #[rstest]
421    #[timeout(Duration::from_secs(5))]
422    #[tokio::test]
423    async fn test_create_song(
424        #[future] setup: (TempDir, Arc<Surreal<Db>>, MusicLibEventHandlerGuard),
425    ) {
426        let (music_lib, db, handler) = setup.await;
427
428        // let's call create_song_metadata to create a new song in our temporary music library, and get the metadata of that song
429        let metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
430
431        // this should trigger the create event handler to add the song to the database, so let's see if it's there
432        while Song::read_all(&db).await.unwrap().is_empty() {
433            tokio::time::sleep(Duration::from_millis(100)).await;
434        }
435
436        let path = metadata.path.clone();
437        let song = Song::read_by_path(&db, path).await.unwrap().unwrap();
438
439        // let's assert that the song in the database is the same as the song we created
440        assert_eq!(metadata, song.into());
441
442        // let's stop the watcher
443        handler.stop();
444        music_lib.close().unwrap();
445    }
446
447    #[rstest]
448    #[timeout(Duration::from_secs(5))]
449    #[tokio::test]
450    async fn test_rename_song(
451        #[future] setup: (TempDir, Arc<Surreal<Db>>, MusicLibEventHandlerGuard),
452    ) {
453        let (music_lib, db, handler) = setup.await;
454
455        // let's call create_song_metadata to create a new song in our temporary music library, and get the metadata of that song
456        let metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
457
458        // this should trigger the create event handler to add the song to the database, so let's see if it's there
459        while Song::read_all(&db).await.unwrap().is_empty() {
460            tokio::time::sleep(Duration::from_millis(100)).await;
461        }
462
463        let path = metadata.path.clone();
464        let song = Song::read_by_path(&db, path.clone())
465            .await
466            .unwrap()
467            .unwrap();
468
469        // let's assert that the song in the database is the same as the song we created
470        assert_eq!(metadata, song.clone().into());
471
472        // let's rename the song
473        let new_path = music_lib.path().join("new_song.mp3");
474        std::fs::rename(&path, &new_path).unwrap();
475
476        // this should trigger the modify event handler to update the song in the database, so let's see if it's there
477        while Song::read_by_path(&db, new_path.clone())
478            .await
479            .unwrap()
480            .is_none()
481        {
482            tokio::time::sleep(Duration::from_millis(100)).await;
483        }
484
485        let new_song = Song::read_by_path(&db, new_path.clone())
486            .await
487            .unwrap()
488            .unwrap();
489        assert_eq!(song.id, new_song.id);
490
491        // let's stop the watcher
492        handler.stop();
493        music_lib.close().unwrap();
494    }
495
496    fn modify_song_metadata(path: &PathBuf, new_name: String) -> anyhow::Result<()> {
497        use lofty::{file::TaggedFileExt, tag::Accessor};
498        let mut tagged_file = lofty::probe::Probe::open(path)?.read()?;
499        let tag = tagged_file
500            .primary_tag_mut()
501            .ok_or_else(|| anyhow::anyhow!("ERROR: No tags found"))?;
502        tag.set_title(new_name);
503        tagged_file.save_to_path(path, lofty::config::WriteOptions::default())?;
504        Ok(())
505    }
506
507    #[rstest]
508    #[timeout(Duration::from_secs(5))]
509    #[tokio::test]
510    async fn test_modify_song(
511        #[future] setup: (TempDir, Arc<Surreal<Db>>, MusicLibEventHandlerGuard),
512    ) {
513        let (music_lib, db, handler) = setup.await;
514
515        // let's call create_song_metadata to create a new song in our temporary music library, and get the metadata of that song
516        let metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
517
518        // this should trigger the create event handler to add the song to the database, so let's see if it's there
519        while Song::read_all(&db).await.unwrap().is_empty() {
520            tokio::time::sleep(Duration::from_millis(100)).await;
521        }
522        let path = metadata.path.clone();
523        let song = Song::read_by_path(&db, path.clone())
524            .await
525            .unwrap()
526            .unwrap();
527
528        // let's assert that the song in the database is the same as the song we created
529        assert_eq!(metadata, song.clone().into());
530
531        // let's modify the song metadata in the file
532        modify_song_metadata(&path, "new song name".to_string()).unwrap();
533
534        // this should trigger the modify event handler to update the song in the database, so let's see if it's there
535        while Song::read_by_path(&db, path.clone())
536            .await
537            .unwrap()
538            .unwrap()
539            .title
540            != "new song name"
541        {
542            tokio::time::sleep(Duration::from_millis(100)).await;
543        }
544
545        // let's stop the watcher
546        handler.stop();
547        music_lib.close().unwrap();
548    }
549
550    #[rstest]
551    #[timeout(Duration::from_secs(5))]
552    #[tokio::test]
553    async fn test_remove_song(
554        #[future] setup: (TempDir, Arc<Surreal<Db>>, MusicLibEventHandlerGuard),
555    ) {
556        let (music_lib, db, handler) = setup.await;
557
558        // let's call create_song_metadata to create a new song in our temporary music library, and get the metadata of that song
559        let metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
560
561        // this should trigger the create event handler to add the song to the database, so let's see if it's there
562        while Song::read_all(&db).await.unwrap().is_empty() {
563            tokio::time::sleep(Duration::from_millis(100)).await;
564        }
565        let path = metadata.path.clone();
566        let song = Song::read_by_path(&db, path.clone())
567            .await
568            .unwrap()
569            .unwrap();
570
571        // let's assert that the song in the database is the same as the song we created
572        assert_eq!(metadata, song.clone().into());
573
574        // let's remove the song
575        std::fs::remove_file(&path).unwrap();
576
577        // this should trigger the remove event handler to remove the song from the database, so let's see if it's there
578        while Song::read_by_path(&db, path.clone())
579            .await
580            .unwrap()
581            .is_some()
582        {
583            tokio::time::sleep(Duration::from_millis(100)).await;
584        }
585
586        // let's stop the watcher
587        handler.stop();
588        music_lib.close().unwrap();
589    }
590
591    #[rstest]
592    #[tokio::test]
593    async fn test_remove_empty_folder(
594        #[future] setup: (TempDir, Arc<Surreal<Db>>, MusicLibEventHandlerGuard),
595    ) {
596        let (music_lib, _, handler) = setup.await;
597
598        // let's create an empty folder in our temporary music library
599        let empty_folder = music_lib.path().join("empty_folder");
600        std::fs::create_dir(&empty_folder).unwrap();
601
602        // this should trigger the remove event handler, but no action is needed
603        tokio::time::sleep(Duration::from_secs(1)).await;
604
605        // let's stop the watcher
606        handler.stop();
607        music_lib.close().unwrap();
608    }
609}