1use 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#[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(
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 let mut debouncer: Debouncer<WatcherType, _> =
118 new_debouncer(MAX_DEBOUNCE_TIME, None, move |event| {
119 let _ = tx.send(event);
120 })?;
121
122 for path in library_paths {
124 log::debug!("watching path: {}", path.display());
125 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
138impl 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
148struct 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 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 async fn handle_event(&self, event: DebouncedEvent) -> anyhow::Result<()> {
174 trace!("file event detected: {event:?}");
175
176 match event.kind {
177 EventKind::Remove(kind) => {
179 self.remove_event_handler(event, kind).await?;
180 }
181 EventKind::Create(kind) => {
183 self.create_event_handler(event, kind).await?;
184 }
185 EventKind::Modify(kind) => {
187 self.modify_event_handler(event, kind).await?;
188 }
189 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 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 => {} RemoveKind::Any | RemoveKind::Other => {
230 warn!(
231 "unhandled remove event: {:?}. rescan recommended",
232 event.paths
233 );
234 }
235 }
236
237 Ok(())
238 }
239
240 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 async fn modify_event_handler(
286 &self,
287 event: DebouncedEvent,
288 kind: ModifyKind,
289 ) -> anyhow::Result<()> {
290 match kind {
291 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 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 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 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 | RenameMode::To )) => {
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 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 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
438
439 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 assert_eq!(metadata, song.into());
449
450 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
465
466 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 assert_eq!(metadata, song.clone().into());
479
480 let new_path = music_lib.path().join("new_song.mp3");
482 std::fs::rename(&path, &new_path).unwrap();
483
484 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
525
526 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 assert_eq!(metadata, song.clone().into());
538
539 modify_song_metadata(&path, "new song name".to_string()).unwrap();
541
542 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
568
569 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 assert_eq!(metadata, song.clone().into());
581
582 std::fs::remove_file(&path).unwrap();
584
585 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 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 empty_folder = music_lib.path().join("empty_folder");
608 std::fs::create_dir(&empty_folder).unwrap();
609
610 tokio::time::sleep(Duration::from_secs(1)).await;
612
613 handler.stop();
615 music_lib.close().unwrap();
616 }
617}