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