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