1use std::{
2 collections::{HashMap, HashSet},
3 path::PathBuf,
4 time::Duration,
5};
6
7use log::{debug, error, info, warn};
8use mecomp_analysis::{
9 clustering::{ClusteringHelper, KOptimal, NotInitialized},
10 decoder::{Decoder, MecompDecoder},
11};
12use mecomp_core::{
13 config::ReclusterSettings,
14 state::library::{LibraryBrief, LibraryFull, LibraryHealth},
15};
16use one_or_many::OneOrMany;
17use surrealdb::{Connection, Surreal};
18use tap::TapFallible;
19use tracing::{Instrument, instrument};
20use walkdir::WalkDir;
21
22use mecomp_storage::{
23 db::{
24 health::{
25 count_albums, count_artists, count_collections, count_dynamic_playlists,
26 count_orphaned_albums, count_orphaned_artists, count_orphaned_collections,
27 count_orphaned_playlists, count_playlists, count_songs, count_unanalyzed_songs,
28 },
29 schemas::{
30 album::Album,
31 analysis::Analysis,
32 artist::Artist,
33 collection::Collection,
34 dynamic::DynamicPlaylist,
35 playlist::Playlist,
36 song::{Song, SongMetadata},
37 },
38 },
39 errors::Error,
40 util::MetadataConflictResolution,
41};
42
43use crate::termination::InterruptReceiver;
44
45#[instrument]
53pub async fn rescan<C: Connection>(
54 db: &Surreal<C>,
55 paths: &[PathBuf],
56 artist_name_separator: &OneOrMany<String>,
57 protected_artist_names: &OneOrMany<String>,
58 genre_separator: Option<&str>,
59 conflict_resolution_mode: MetadataConflictResolution,
60) -> Result<(), Error> {
61 let songs = Song::read_all(db).await?;
63 let mut paths_to_skip = HashSet::new(); async {
67 for song in songs {
68 let path = song.path.clone();
69 if !path.exists() {
70 warn!("Song {} no longer exists, deleting", path.to_string_lossy());
72 Song::delete(db, song.id).await?;
73 continue;
74 }
75
76 debug!("loading metadata for {}", path.to_string_lossy());
77 match SongMetadata::load_from_path(path.clone(), artist_name_separator,protected_artist_names, genre_separator) {
79 Ok(metadata) if metadata != SongMetadata::from(&song) => {
81 let log_postfix = if conflict_resolution_mode == MetadataConflictResolution::Skip {
82 "but conflict resolution mode is \"skip\", so we do nothing"
83 } else {
84 "resolving conflict"
85 };
86 info!(
87 "{} has conflicting metadata with index, {log_postfix}",
88 path.display(),
89 );
90
91 match conflict_resolution_mode {
92 MetadataConflictResolution::Overwrite => {
94 Song::update(db, song.id.clone(), metadata.merge_with_song(&song)).await?;
96 }
97 MetadataConflictResolution::Skip => {}
99 }
100 }
101 Err(e) => {
103 warn!(
104 "Error reading metadata for {}: {e}",
105 path.display()
106 );
107 info!("assuming the file isn't a song or doesn't exist anymore, removing from library");
108 Song::delete(db, song.id).await?;
109 }
110 _ => {}
112 }
113
114 paths_to_skip.insert(path);
116 }
117
118 <Result<(), Error>>::Ok(())
119 }.instrument(tracing::info_span!("Checking library for missing or outdated songs")).await?;
120
121 let mut visited_paths = paths_to_skip;
123
124 debug!("Indexing paths: {paths:?}");
125 async {
126 for path in paths
127 .iter()
128 .filter_map(|p| {
129 p.canonicalize()
130 .tap_err(|e| warn!("Error canonicalizing path: {e}"))
131 .ok()
132 })
133 .flat_map(|x| WalkDir::new(x).into_iter())
134 .filter_map(|x| x.tap_err(|e| warn!("Error reading path: {e}")).ok())
135 .filter_map(|x| x.file_type().is_file().then_some(x))
136 {
137 if visited_paths.contains(path.path()) {
138 continue;
139 }
140
141 visited_paths.insert(path.path().to_owned());
142
143 let displayable_path = path.path().display();
144
145 match SongMetadata::load_from_path(
147 path.path().to_owned(),
148 artist_name_separator,
149 protected_artist_names,
150 genre_separator,
151 ) {
152 Ok(metadata) => Song::try_load_into_db(db, metadata).await.map_or_else(
153 |e| warn!("Error indexing {displayable_path}: {e}"),
154 |_| debug!("Indexed {displayable_path}"),
155 ),
156 Err(e) => warn!("Error reading metadata for {displayable_path}: {e}"),
157 }
158 }
159
160 <Result<(), Error>>::Ok(())
161 }
162 .instrument(tracing::info_span!("Indexing new songs"))
163 .await?;
164
165 let orphans = Album::delete_orphaned(db)
168 .instrument(tracing::info_span!("Deleting orphaned albums"))
169 .await?;
170 if !orphans.is_empty() {
171 info!("Deleted orphaned albums: {orphans:?}");
172 }
173
174 let orphans = Artist::delete_orphaned(db)
175 .instrument(tracing::info_span!("Repairing artists"))
176 .await?;
177 if !orphans.is_empty() {
178 info!("Deleted orphaned artists: {orphans:?}");
179 }
180
181 let orphans = Collection::delete_orphaned(db)
182 .instrument(tracing::info_span!("Repairing collections"))
183 .await?;
184 if !orphans.is_empty() {
185 info!("Deleted orphaned collections: {orphans:?}");
186 }
187
188 let orphans = Playlist::delete_orphaned(db)
189 .instrument(tracing::info_span!("Repairing playlists"))
190 .await?;
191 if !orphans.is_empty() {
192 info!("Deleted orphaned playlists: {orphans:?}");
193 }
194
195 info!("Library rescan complete");
196 info!("Library health: {:?}", health(db).await?);
197
198 Ok(())
199}
200
201#[instrument]
217pub async fn analyze<C: Connection>(
218 db: &Surreal<C>,
219 mut interrupt: InterruptReceiver,
220 overwrite: bool,
221) -> Result<(), Error> {
222 if overwrite {
223 async {
225 for analysis in Analysis::read_all(db).await? {
226 Analysis::delete(db, analysis.id.clone()).await?;
227 }
228 <Result<(), Error>>::Ok(())
229 }
230 .instrument(tracing::info_span!("Deleting existing analyses"))
231 .await?;
232 }
233
234 let songs_to_analyze: Vec<Song> = Analysis::read_songs_without_analysis(db).await?;
236 let paths = songs_to_analyze
238 .iter()
239 .map(|song| (song.path.clone(), song.id.clone()))
240 .collect::<HashMap<_, _>>();
241
242 let keys = paths.keys().cloned().collect::<Vec<_>>();
243
244 let (tx, rx) = std::sync::mpsc::channel();
245
246 let Ok(decoder) = MecompDecoder::new() else {
247 error!("Error creating decoder");
248 return Ok(());
249 };
250
251 let handle = tokio::task::spawn_blocking(move || decoder.analyze_paths_with_callback(keys, tx));
253 let abort = handle.abort_handle();
254
255 async {
256 for (song_path, maybe_analysis) in rx {
257 if interrupt.is_stopped() {
258 info!("Analysis interrupted");
259 break;
260 }
261
262 let displayable_path = song_path.display();
263
264 let Some(song_id) = paths.get(&song_path) else {
265 error!("No song id found for path: {displayable_path}");
266 continue;
267 };
268
269 match maybe_analysis {
270 Ok(analysis) => Analysis::create(
271 db,
272 song_id.clone(),
273 Analysis {
274 id: Analysis::generate_id(),
275 features: *analysis.inner(),
276 },
277 )
278 .await?
279 .map_or_else(
280 || {
281 warn!(
282 "Error analyzing {displayable_path}: song either wasn't found or already has an analysis"
283 );
284 },
285 |_| debug!("Analyzed {displayable_path}"),
286 ),
287 Err(e) => {
288 error!("Error analyzing {displayable_path}: {e}");
289 }
290 }
291 }
292
293 <Result<(), Error>>::Ok(())
294 }
295 .instrument(tracing::info_span!("Adding analyses to database"))
296 .await?;
297
298 tokio::select! {
299 _ = interrupt.wait() => {
301 info!("Analysis interrupted");
302 abort.abort();
303 }
304 result = handle => match result {
306 Ok(Ok(())) => {
307 info!("Analysis complete");
308 info!("Library health: {:?}", health(db).await?);
309 }
310 Ok(Err(e)) => {
311 error!("Error analyzing songs: {e}");
312 }
313 Err(e) => {
314 error!("Error joining task: {e}");
315 }
316 }
317 }
318
319 Ok(())
320}
321
322#[instrument]
330pub async fn recluster<C: Connection>(
331 db: &Surreal<C>,
332 settings: ReclusterSettings,
333 mut interrupt: InterruptReceiver,
334) -> Result<(), Error> {
335 let samples = Analysis::read_all(db).await?;
337
338 if samples.is_empty() {
339 info!("No analyses found, nothing to recluster");
340 return Ok(());
341 }
342
343 let samples_ref = samples.clone();
344
345 let clustering = move || {
347 let model: ClusteringHelper<NotInitialized> = match ClusteringHelper::new(
348 samples_ref
349 .iter()
350 .map(Into::into)
351 .collect::<Vec<mecomp_analysis::Analysis>>()
352 .into(),
353 settings.max_clusters,
354 KOptimal::GapStatistic {
355 b: settings.gap_statistic_reference_datasets,
356 },
357 settings.algorithm.into(),
358 settings.projection_method.into(),
359 ) {
360 Err(e) => {
361 error!("There was an error creating the clustering helper: {e}",);
362 return None;
363 }
364 Ok(kmeans) => kmeans,
365 };
366
367 let model = match model.initialize() {
368 Err(e) => {
369 error!("There was an error initializing the clustering helper: {e}",);
370 return None;
371 }
372 Ok(kmeans) => kmeans.cluster(),
373 };
374
375 Some(model)
376 };
377
378 let handle = tokio::task::spawn_blocking(clustering)
380 .instrument(tracing::info_span!("Clustering library"));
381 let abort = handle.inner().abort_handle();
382
383 let model = tokio::select! {
385 _ = interrupt.wait() => {
386 info!("Reclustering interrupted");
387 abort.abort();
388 return Ok(());
389 }
390 result = handle => match result {
391 Ok(Some(model)) => model,
392 Ok(None) => {
393 return Ok(());
394 }
395 Err(e) => {
396 error!("Error joining task: {e}");
397 return Ok(());
398 }
399 }
400 };
401
402 async {
404 for collection in Collection::read_all(db).await? {
407 Collection::delete(db, collection.id.clone()).await?;
408 }
409
410 <Result<(), Error>>::Ok(())
411 }
412 .instrument(tracing::info_span!("Deleting old collections"))
413 .await?;
414
415 async {
417 let clusters = model.extract_analysis_clusters(samples);
418
419 for (i, cluster) in clusters.iter().filter(|c| !c.is_empty()).enumerate() {
421 let collection = Collection::create(
422 db,
423 Collection {
424 id: Collection::generate_id(),
425 name: format!("Collection {i}"),
426 runtime: Duration::default(),
427 song_count: Default::default(),
428 },
429 )
430 .await?
431 .ok_or(Error::NotCreated)?;
432
433 let mut songs = Vec::with_capacity(cluster.len());
434
435 async {
436 for analysis in cluster {
437 songs.push(Analysis::read_song(db, analysis.id.clone()).await?.id);
438 }
439
440 Collection::add_songs(db, collection.id.clone(), songs).await?;
441
442 <Result<(), Error>>::Ok(())
443 }
444 .instrument(tracing::info_span!("Adding songs to collection"))
445 .await?;
446 }
447 Ok::<(), Error>(())
448 }
449 .instrument(tracing::info_span!("Creating new collections"))
450 .await?;
451
452 info!("Library recluster complete");
453 info!("Library health: {:?}", health(db).await?);
454
455 Ok(())
456}
457
458#[instrument]
464pub async fn brief<C: Connection>(db: &Surreal<C>) -> Result<LibraryBrief, Error> {
465 Ok(LibraryBrief {
466 artists: Artist::read_all_brief(db).await?.into_boxed_slice(),
467 albums: Album::read_all_brief(db).await?.into_boxed_slice(),
468 songs: Song::read_all_brief(db).await?.into_boxed_slice(),
469 playlists: Playlist::read_all_brief(db).await?.into_boxed_slice(),
470 collections: Collection::read_all_brief(db).await?.into_boxed_slice(),
471 dynamic_playlists: DynamicPlaylist::read_all(db).await?.into_boxed_slice(),
472 })
473}
474
475#[instrument]
481pub async fn full<C: Connection>(db: &Surreal<C>) -> Result<LibraryFull, Error> {
482 Ok(LibraryFull {
483 artists: Artist::read_all(db).await?.into(),
484 albums: Album::read_all(db).await?.into(),
485 songs: Song::read_all(db).await?.into(),
486 playlists: Playlist::read_all(db).await?.into(),
487 collections: Collection::read_all(db).await?.into(),
488 dynamic_playlists: DynamicPlaylist::read_all(db).await?.into(),
489 })
490}
491
492#[instrument]
500pub async fn health<C: Connection>(db: &Surreal<C>) -> Result<LibraryHealth, Error> {
501 Ok(LibraryHealth {
502 artists: count_artists(db).await?,
503 albums: count_albums(db).await?,
504 songs: count_songs(db).await?,
505 unanalyzed_songs: Some(count_unanalyzed_songs(db).await?),
506 playlists: count_playlists(db).await?,
507 collections: count_collections(db).await?,
508 dynamic_playlists: count_dynamic_playlists(db).await?,
509 orphaned_artists: count_orphaned_artists(db).await?,
510 orphaned_albums: count_orphaned_albums(db).await?,
511 orphaned_playlists: count_orphaned_playlists(db).await?,
512 orphaned_collections: count_orphaned_collections(db).await?,
513 })
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::test_utils::init;
520
521 use mecomp_core::config::{ClusterAlgorithm, ProjectionMethod};
522 use mecomp_storage::db::schemas::song::{SongChangeSet, SongMetadata};
523 use mecomp_storage::test_utils::{
524 ARTIST_NAME_SEPARATOR, SongCase, arb_analysis_features, arb_song_case, arb_vec,
525 create_song_metadata, create_song_with_overrides, init_test_database,
526 };
527 use one_or_many::OneOrMany;
528 use pretty_assertions::assert_eq;
529 use rstest::rstest;
530
531 #[tokio::test]
532 #[allow(clippy::too_many_lines)]
533 async fn test_rescan() {
534 init();
535 let tempdir = tempfile::tempdir().unwrap();
536 let db = init_test_database().await.unwrap();
537
538 let song_cases = arb_vec(&arb_song_case(), 10..=15)();
540 let metadatas = song_cases
541 .into_iter()
542 .map(|song_case| create_song_metadata(&tempdir, song_case))
543 .collect::<Result<Vec<_>, _>>()
544 .unwrap();
545 let song_with_nonexistent_path = create_song_with_overrides(
548 &db,
549 arb_song_case()(),
550 SongChangeSet {
551 path: Some(tempdir.path().join("nonexistent.mp3")),
552 ..Default::default()
553 },
554 )
555 .await
556 .unwrap();
557 let mut metadata_of_song_with_outdated_metadata =
558 create_song_metadata(&tempdir, arb_song_case()()).unwrap();
559 metadata_of_song_with_outdated_metadata.genre = OneOrMany::None;
560 let song_with_outdated_metadata =
561 Song::try_load_into_db(&db, metadata_of_song_with_outdated_metadata)
562 .await
563 .unwrap();
564 let invalid_song_path = tempdir.path().join("invalid1.mp3");
566 std::fs::write(&invalid_song_path, "this is not a song").unwrap();
567 let invalid_song_path = tempdir.path().join("invalid2.mp3");
569 std::fs::write(&invalid_song_path, "this is not a song").unwrap();
570 let song_with_invalid_metadata = create_song_with_overrides(
571 &db,
572 arb_song_case()(),
573 SongChangeSet {
574 path: Some(tempdir.path().join("invalid2.mp3")),
575 ..Default::default()
576 },
577 )
578 .await
579 .unwrap();
580
581 rescan(
583 &db,
584 &[tempdir.path().to_owned()],
585 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
586 &OneOrMany::None,
587 Some(ARTIST_NAME_SEPARATOR),
588 MetadataConflictResolution::Overwrite,
589 )
590 .await
591 .unwrap();
592
593 assert_eq!(
596 Song::read(&db, song_with_nonexistent_path.id)
597 .await
598 .unwrap(),
599 None
600 );
601 assert_eq!(
603 Song::read(&db, song_with_invalid_metadata.id)
604 .await
605 .unwrap(),
606 None
607 );
608 assert!(
610 Song::read(&db, song_with_outdated_metadata.id)
611 .await
612 .unwrap()
613 .unwrap()
614 .genre
615 .is_some()
616 );
617 for metadata in metadatas {
620 let song = Song::read_by_path(&db, metadata.path.clone())
622 .await
623 .unwrap();
624 assert!(song.is_some());
625 let song = song.unwrap();
626
627 assert_eq!(SongMetadata::from(&song), metadata);
629
630 let artists = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
632 .await
633 .unwrap();
634 assert_eq!(artists.len(), metadata.artist.len());
635 for artist in &artists {
637 assert!(metadata.artist.contains(&artist.name));
638 assert!(
639 Artist::read_songs(&db, artist.id.clone())
640 .await
641 .unwrap()
642 .contains(&song)
643 );
644 }
645 if let Ok(song_artists) = Song::read_artist(&db, song.id.clone()).await {
647 for artist in artists {
648 assert!(song_artists.contains(&artist));
649 }
650 } else {
651 panic!("Error reading song artists");
652 }
653
654 let album = Album::read_by_name_and_album_artist(
656 &db,
657 &metadata.album,
658 metadata.album_artist.clone(),
659 )
660 .await
661 .unwrap();
662 assert!(album.is_some());
663 let album = album.unwrap();
664 assert_eq!(
666 Song::read_album(&db, song.id.clone()).await.unwrap(),
667 Some(album.clone())
668 );
669 assert!(
671 Album::read_songs(&db, album.id.clone())
672 .await
673 .unwrap()
674 .contains(&song)
675 );
676
677 let album_artists =
679 Artist::read_by_names(&db, Vec::from(metadata.album_artist.clone()))
680 .await
681 .unwrap();
682 assert_eq!(album_artists.len(), metadata.album_artist.len());
683 for album_artist in album_artists {
685 assert!(metadata.album_artist.contains(&album_artist.name));
686 assert!(
687 Artist::read_albums(&db, album_artist.id.clone())
688 .await
689 .unwrap()
690 .contains(&album)
691 );
692 }
693 }
694 }
695
696 #[tokio::test]
697 async fn rescan_deletes_preexisting_orphans() {
698 init();
699 let tempdir = tempfile::tempdir().unwrap();
700 let db = init_test_database().await.unwrap();
701
702 let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
704 let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
705
706 std::fs::remove_file(&song.path).unwrap();
708 Song::delete(&db, (song.id.clone(), false)).await.unwrap();
709
710 rescan(
712 &db,
713 &[tempdir.path().to_owned()],
714 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
715 &OneOrMany::None,
716 Some(ARTIST_NAME_SEPARATOR),
717 MetadataConflictResolution::Overwrite,
718 )
719 .await
720 .unwrap();
721
722 assert_eq!(Song::read_all(&db).await.unwrap().len(), 0);
724 assert_eq!(Album::read_all(&db).await.unwrap().len(), 0);
725 let artists = Artist::read_all(&db).await.unwrap();
726 for artist in artists {
727 assert_eq!(artist.album_count, 0);
728 assert_eq!(artist.song_count, 0);
729 }
730 assert_eq!(Artist::read_all(&db).await.unwrap().len(), 0);
731 }
732
733 #[tokio::test]
734 async fn rescan_deletes_orphaned_albums_and_artists() {
735 init();
736 let tempdir = tempfile::tempdir().unwrap();
737 let db = init_test_database().await.unwrap();
738
739 let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
741 let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
742 let artist = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
743 .await
744 .unwrap()
745 .pop()
746 .unwrap();
747 let album = Album::read_by_name_and_album_artist(
748 &db,
749 &metadata.album,
750 metadata.album_artist.clone(),
751 )
752 .await
753 .unwrap()
754 .unwrap();
755
756 std::fs::remove_file(&song.path).unwrap();
758
759 rescan(
761 &db,
762 &[tempdir.path().to_owned()],
763 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
764 &OneOrMany::None,
765 Some(ARTIST_NAME_SEPARATOR),
766 MetadataConflictResolution::Overwrite,
767 )
768 .await
769 .unwrap();
770
771 assert_eq!(Artist::read(&db, artist.id.clone()).await.unwrap(), None);
773 assert_eq!(Album::read(&db, album.id.clone()).await.unwrap(), None);
774 }
775
776 #[tokio::test]
777 async fn test_analyze() {
778 init();
779 let dir = tempfile::tempdir().unwrap();
780 let db = init_test_database().await.unwrap();
781 let interrupt = InterruptReceiver::dummy();
782
783 let song_cases = arb_vec(&arb_song_case(), 10..=15)();
785 let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
786 song: u8::try_from(i).unwrap(),
787 ..sc
788 });
789 let metadatas = song_cases
790 .into_iter()
791 .map(|song_case| create_song_metadata(&dir, song_case))
792 .collect::<Result<Vec<_>, _>>()
793 .unwrap();
794 for metadata in &metadatas {
795 Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
796 }
797
798 assert_eq!(
800 Analysis::read_songs_without_analysis(&db)
801 .await
802 .unwrap()
803 .len(),
804 metadatas.len()
805 );
806
807 analyze(&db, interrupt, true).await.unwrap();
809
810 assert_eq!(
812 Analysis::read_songs_without_analysis(&db)
813 .await
814 .unwrap()
815 .len(),
816 0
817 );
818 for metadata in &metadatas {
819 let song = Song::read_by_path(&db, metadata.path.clone())
820 .await
821 .unwrap()
822 .unwrap();
823 let analysis = Analysis::read_for_song(&db, song.id.clone()).await.unwrap();
824 assert!(analysis.is_some());
825 }
826
827 for analysis in Analysis::read_all(&db).await.unwrap() {
829 let neighbors = Analysis::nearest_neighbors(&db, analysis.id.clone(), 100)
830 .await
831 .unwrap();
832 assert!(!neighbors.contains(&analysis));
833 assert_eq!(neighbors.len(), metadatas.len() - 1);
834 assert_eq!(
835 neighbors.len(),
836 neighbors
837 .iter()
838 .map(|n| n.id.clone())
839 .collect::<HashSet<_>>()
840 .len()
841 );
842 }
843 }
844
845 #[rstest]
846 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
847 async fn test_recluster(
848 #[values(ProjectionMethod::TSne, ProjectionMethod::None, ProjectionMethod::Pca)]
849 projection_method: ProjectionMethod,
850 ) {
851 init();
852 let dir = tempfile::tempdir().unwrap();
853 let db = init_test_database().await.unwrap();
854 let settings = ReclusterSettings {
855 gap_statistic_reference_datasets: 5,
856 max_clusters: 18,
857 algorithm: ClusterAlgorithm::GMM,
858 projection_method,
859 };
860
861 let song_cases = arb_vec(&arb_song_case(), 32..=32)();
863 let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
864 song: u8::try_from(i).unwrap(),
865 ..sc
866 });
867 let metadatas = song_cases
868 .into_iter()
869 .map(|song_case| create_song_metadata(&dir, song_case))
870 .collect::<Result<Vec<_>, _>>()
871 .unwrap();
872 let mut songs = Vec::with_capacity(metadatas.len());
873 for metadata in &metadatas {
874 songs.push(Song::try_load_into_db(&db, metadata.clone()).await.unwrap());
875 }
876
877 for song in &songs {
879 Analysis::create(
880 &db,
881 song.id.clone(),
882 Analysis {
883 id: Analysis::generate_id(),
884 features: arb_analysis_features()(),
885 },
886 )
887 .await
888 .unwrap();
889 }
890
891 recluster(&db, settings, InterruptReceiver::dummy())
893 .await
894 .unwrap();
895
896 let collections = Collection::read_all(&db).await.unwrap();
898 assert!(!collections.is_empty());
899 for collection in collections {
900 let songs = Collection::read_songs(&db, collection.id.clone())
901 .await
902 .unwrap();
903 assert!(!songs.is_empty());
904 }
905 }
906
907 #[tokio::test]
908 async fn test_brief() {
909 init();
910 let db = init_test_database().await.unwrap();
911 let brief = brief(&db).await.unwrap();
912 assert_eq!(brief.artists, Box::default());
913 assert_eq!(brief.albums, Box::default());
914 assert_eq!(brief.songs, Box::default());
915 assert_eq!(brief.playlists, Box::default());
916 assert_eq!(brief.collections, Box::default());
917 }
918
919 #[tokio::test]
920 async fn test_full() {
921 init();
922 let db = init_test_database().await.unwrap();
923 let full = full(&db).await.unwrap();
924 assert_eq!(full.artists.len(), 0);
925 assert_eq!(full.albums.len(), 0);
926 assert_eq!(full.songs.len(), 0);
927 assert_eq!(full.playlists.len(), 0);
928 assert_eq!(full.collections.len(), 0);
929 }
930
931 #[tokio::test]
932 async fn test_health() {
933 init();
934 let db = init_test_database().await.unwrap();
935 let health = health(&db).await.unwrap();
936 assert_eq!(health.artists, 0);
937 assert_eq!(health.albums, 0);
938 assert_eq!(health.songs, 0);
939 assert_eq!(health.unanalyzed_songs, Some(0));
940 assert_eq!(health.playlists, 0);
941 assert_eq!(health.collections, 0);
942 assert_eq!(health.orphaned_artists, 0);
943 assert_eq!(health.orphaned_albums, 0);
944 assert_eq!(health.orphaned_playlists, 0);
945 assert_eq!(health.orphaned_collections, 0);
946 }
947}