1use 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#[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 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
70
71 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 let mut debouncer: Debouncer<WatcherType, _> =
117 new_debouncer(MAX_DEBOUNCE_TIME, None, move |event| {
118 let _ = tx.send(event);
119 })?;
120
121 for path in library_paths {
123 log::debug!("watching path: {}", path.display());
124 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
137impl 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
147struct 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 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 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 EventKind::Remove(kind) => {
182 self.remove_event_handler(event, kind, lock).await?;
183 }
184 EventKind::Create(kind) => {
186 self.create_event_handler(event, kind, lock).await?;
187 }
188 EventKind::Modify(kind) => {
190 self.modify_event_handler(event, kind, lock).await?;
191 }
192 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 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 => {} RemoveKind::Any | RemoveKind::Other => {
236 warn!(
237 "unhandled remove event: {:?}. rescan recommended",
238 event.paths
239 );
240 }
241 }
242
243 Ok(())
244 }
245
246 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 async fn modify_event_handler(
295 &self,
296 event: DebouncedEvent,
297 kind: ModifyKind,
298 lock: Arc<Mutex<()>>,
299 ) -> anyhow::Result<()> {
300 match kind {
301 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 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 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 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 | RenameMode::To )) => {
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 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 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
451
452 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 assert_eq!(metadata, song.into());
462
463 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
478
479 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 assert_eq!(metadata, song.clone().into());
492
493 let new_path = music_lib.path().join("new_song.mp3");
495 std::fs::rename(&path, &new_path).unwrap();
496
497 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
538
539 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 assert_eq!(metadata, song.clone().into());
551
552 modify_song_metadata(&path, "new song name".to_string()).unwrap();
554
555 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
581
582 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 assert_eq!(metadata, song.clone().into());
594
595 std::fs::remove_file(&path).unwrap();
597
598 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 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 empty_folder = music_lib.path().join("empty_folder");
621 std::fs::create_dir(&empty_folder).unwrap();
622
623 tokio::time::sleep(Duration::from_secs(1)).await;
625
626 handler.stop();
628 music_lib.close().unwrap();
629 }
630}