1use 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#[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 let (stop_tx, stop_rx) = futures::channel::oneshot::channel();
68
69 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 let mut debouncer: Debouncer<WatcherType, _> =
113 new_debouncer(MAX_DEBOUNCE_TIME, None, move |event| {
114 let _ = tx.unbounded_send(event);
115 })?;
116
117 for path in library_paths {
119 log::debug!("watching path: {path:?}");
120 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
142struct 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 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 async fn handle_event(&self, event: DebouncedEvent) -> anyhow::Result<()> {
168 trace!("file event detected: {event:?}");
169
170 match event.kind {
171 EventKind::Remove(kind) => {
173 self.remove_event_handler(event, kind).await?;
174 }
175 EventKind::Create(kind) => {
177 self.create_event_handler(event, kind).await?;
178 }
179 EventKind::Modify(kind) => {
181 self.modify_event_handler(event, kind).await?;
182 }
183 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 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 => {} RemoveKind::Any | RemoveKind::Other => {
224 warn!(
225 "unhandled remove event: {:?}. rescan recommended",
226 event.paths
227 );
228 }
229 }
230
231 Ok(())
232 }
233
234 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 async fn modify_event_handler(
280 &self,
281 event: DebouncedEvent,
282 kind: ModifyKind,
283 ) -> anyhow::Result<()> {
284 match kind {
285 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 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 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 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 | RenameMode::To )) => {
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 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 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
430
431 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 assert_eq!(metadata, song.into());
441
442 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
457
458 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 assert_eq!(metadata, song.clone().into());
471
472 let new_path = music_lib.path().join("new_song.mp3");
474 std::fs::rename(&path, &new_path).unwrap();
475
476 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
517
518 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 assert_eq!(metadata, song.clone().into());
530
531 modify_song_metadata(&path, "new song name".to_string()).unwrap();
533
534 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 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 metadata = create_song_metadata(&music_lib, arb_song_case()()).unwrap();
560
561 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 assert_eq!(metadata, song.clone().into());
573
574 std::fs::remove_file(&path).unwrap();
576
577 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 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 empty_folder = music_lib.path().join("empty_folder");
600 std::fs::create_dir(&empty_folder).unwrap();
601
602 tokio::time::sleep(Duration::from_secs(1)).await;
604
605 handler.stop();
607 music_lib.close().unwrap();
608 }
609}