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
43#[instrument]
51pub async fn rescan<C: Connection>(
52 db: &Surreal<C>,
53 paths: &[PathBuf],
54 artist_name_separator: &OneOrMany<String>,
55 protected_artist_names: &OneOrMany<String>,
56 genre_separator: Option<&str>,
57 conflict_resolution_mode: MetadataConflictResolution,
58) -> Result<(), Error> {
59 let songs = Song::read_all(db).await?;
61 let mut paths_to_skip = HashSet::new(); async {
65 for song in songs {
66 let path = song.path.clone();
67 if !path.exists() {
68 warn!("Song {} no longer exists, deleting", path.to_string_lossy());
70 Song::delete(db, song.id).await?;
71 continue;
72 }
73
74 debug!("loading metadata for {}", path.to_string_lossy());
75 match SongMetadata::load_from_path(path.clone(), artist_name_separator,protected_artist_names, genre_separator) {
77 Ok(metadata) if metadata != SongMetadata::from(&song) => {
79 let log_postfix = if conflict_resolution_mode == MetadataConflictResolution::Skip {
80 "but conflict resolution mode is \"skip\", so we do nothing"
81 } else {
82 "resolving conflict"
83 };
84 info!(
85 "{} has conflicting metadata with index, {log_postfix}",
86 path.to_string_lossy(),
87 );
88
89 match conflict_resolution_mode {
90 MetadataConflictResolution::Overwrite => {
92 Song::update(db, song.id.clone(), metadata.merge_with_song(&song)).await?;
94 }
95 MetadataConflictResolution::Skip => {
97 continue;
98 }
99 }
100 }
101 Err(e) => {
103 warn!(
104 "Error reading metadata for {}: {}",
105 path.to_string_lossy(),
106 e
107 );
108 info!("assuming the file isn't a song or doesn't exist anymore, removing from library");
109 Song::delete(db, song.id).await?;
110 }
111 _ => {}
113 }
114
115 paths_to_skip.insert(path);
117 }
118
119 <Result<(), Error>>::Ok(())
120 }.instrument(tracing::info_span!("Checking library for missing or outdated songs")).await?;
121
122 let mut visited_paths = paths_to_skip;
124
125 debug!("Indexing paths: {paths:?}");
126 async {
127 for path in paths
128 .iter()
129 .filter_map(|p| {
130 p.canonicalize()
131 .tap_err(|e| warn!("Error canonicalizing path: {e}"))
132 .ok()
133 })
134 .flat_map(|x| WalkDir::new(x).into_iter())
135 .filter_map(|x| x.tap_err(|e| warn!("Error reading path: {e}")).ok())
136 .filter_map(|x| x.file_type().is_file().then_some(x))
137 {
138 if visited_paths.contains(path.path()) {
139 continue;
140 }
141
142 visited_paths.insert(path.path().to_owned());
143
144 match SongMetadata::load_from_path(
146 path.path().to_owned(),
147 artist_name_separator,
148 protected_artist_names,
149 genre_separator,
150 ) {
151 Ok(metadata) => Song::try_load_into_db(db, metadata).await.map_or_else(
152 |e| warn!("Error indexing {}: {}", path.path().to_string_lossy(), e),
153 |_| debug!("Indexed {}", path.path().to_string_lossy()),
154 ),
155 Err(e) => warn!(
156 "Error reading metadata for {}: {}",
157 path.path().to_string_lossy(),
158 e
159 ),
160 }
161 }
162
163 <Result<(), Error>>::Ok(())
164 }
165 .instrument(tracing::info_span!("Indexing new songs"))
166 .await?;
167
168 async {
172 for album in Album::read_all(db).await? {
173 if Album::repair(db, album.id.clone()).await? {
174 info!("Deleted orphaned album {}", album.id.clone());
175 Album::delete(db, album.id.clone()).await?;
176 }
177 }
178 <Result<(), Error>>::Ok(())
179 }
180 .instrument(tracing::info_span!("Repairing albums"))
181 .await?;
182 async {
183 for artist in Artist::read_all(db).await? {
184 if Artist::repair(db, artist.id.clone()).await? {
185 info!("Deleted orphaned artist {}", artist.id.clone());
186 Artist::delete(db, artist.id.clone()).await?;
187 }
188 }
189 <Result<(), Error>>::Ok(())
190 }
191 .instrument(tracing::info_span!("Repairing artists"))
192 .await?;
193 async {
194 for collection in Collection::read_all(db).await? {
195 if Collection::repair(db, collection.id.clone()).await? {
196 info!("Deleted orphaned collection {}", collection.id.clone());
197 Collection::delete(db, collection.id.clone()).await?;
198 }
199 }
200 <Result<(), Error>>::Ok(())
201 }
202 .instrument(tracing::info_span!("Repairing collections"))
203 .await?;
204 async {
205 for playlist in Playlist::read_all(db).await? {
206 if Playlist::repair(db, playlist.id.clone()).await? {
207 info!("Deleted orphaned playlist {}", playlist.id.clone());
208 Playlist::delete(db, playlist.id.clone()).await?;
209 }
210 }
211 <Result<(), Error>>::Ok(())
212 }
213 .instrument(tracing::info_span!("Repairing playlists"))
214 .await?;
215
216 info!("Library rescan complete");
217 info!("Library brief: {:?}", brief(db).await?);
218
219 Ok(())
220}
221
222#[instrument]
238pub async fn analyze<C: Connection>(db: &Surreal<C>, overwrite: bool) -> Result<(), Error> {
239 if overwrite {
240 async {
242 for analysis in Analysis::read_all(db).await? {
243 Analysis::delete(db, analysis.id.clone()).await?;
244 }
245 <Result<(), Error>>::Ok(())
246 }
247 .instrument(tracing::info_span!("Deleting existing analyses"))
248 .await?;
249 }
250
251 let songs_to_analyze: Vec<Song> = Analysis::read_songs_without_analysis(db).await?;
253 let paths = songs_to_analyze
255 .iter()
256 .map(|song| (song.path.clone(), song.id.clone()))
257 .collect::<HashMap<_, _>>();
258
259 let keys = paths.keys().cloned().collect::<Vec<_>>();
260
261 let (tx, rx) = std::sync::mpsc::channel();
262
263 let Ok(decoder) = MecompDecoder::new() else {
264 error!("Error creating decoder");
265 return Ok(());
266 };
267
268 let handle = std::thread::spawn(move || {
270 decoder.analyze_paths_with_callback(keys, tx);
271 });
272
273 async {
274 for (song_path, maybe_analysis) in rx {
275 let Some(song_id) = paths.get(&song_path) else {
276 error!("No song id found for path: {}", song_path.to_string_lossy());
277 continue;
278 };
279
280 match maybe_analysis {
281 Ok(analysis) => Analysis::create(
282 db,
283 song_id.clone(),
284 Analysis {
285 id: Analysis::generate_id(),
286 features: *analysis.inner(),
287 },
288 )
289 .await?
290 .map_or_else(
291 || {
292 warn!(
293 "Error analyzing {}: song either wasn't found or already has an analysis",
294 song_path.to_string_lossy()
295 );
296 },
297 |_| debug!("Analyzed {}", song_path.to_string_lossy()),
298 ),
299 Err(e) => {
300 error!("Error analyzing {}: {}", song_path.to_string_lossy(), e);
301 }
302 }
303 }
304
305 <Result<(), Error>>::Ok(())
306 }
307 .instrument(tracing::info_span!("Adding analyses to database"))
308 .await?;
309
310 handle.join().expect("Couldn't join thread");
311
312 info!("Library analysis complete");
313 info!("Library brief: {:?}", brief(db).await?);
314
315 Ok(())
316}
317
318#[instrument]
326pub async fn recluster<C: Connection>(
327 db: &Surreal<C>,
328 settings: &ReclusterSettings,
329) -> Result<(), Error> {
330 let samples = Analysis::read_all(db).await?;
332
333 let entered = tracing::info_span!("Clustering library").entered();
334 let model: ClusteringHelper<NotInitialized> = match ClusteringHelper::new(
336 samples
337 .iter()
338 .map(Into::into)
339 .collect::<Vec<mecomp_analysis::Analysis>>()
340 .into(),
341 settings.max_clusters,
342 KOptimal::GapStatistic {
343 b: settings.gap_statistic_reference_datasets,
344 },
345 settings.algorithm.into(),
346 ) {
347 Err(e) => {
348 error!("There was an error creating the clustering helper: {e}",);
349 return Ok(());
350 }
351 Ok(kmeans) => kmeans,
352 };
353
354 let model = match model.initialize() {
355 Err(e) => {
356 error!("There was an error initializing the clustering helper: {e}",);
357 return Ok(());
358 }
359 Ok(kmeans) => kmeans.cluster(),
360 };
361 drop(entered);
362
363 async {
365 for collection in Collection::read_all(db).await? {
368 Collection::delete(db, collection.id.clone()).await?;
369 }
370
371 <Result<(), Error>>::Ok(())
372 }
373 .instrument(tracing::info_span!("Deleting old collections"))
374 .await?;
375
376 async {
378 let clusters = model.extract_analysis_clusters(samples);
379
380 for (i, cluster) in clusters.iter().filter(|c| !c.is_empty()).enumerate() {
382 let collection = Collection::create(
383 db,
384 Collection {
385 id: Collection::generate_id(),
386 name: format!("Collection {i}"),
387 runtime: Duration::default(),
388 song_count: Default::default(),
389 },
390 )
391 .await?
392 .ok_or(Error::NotCreated)?;
393
394 let mut songs = Vec::with_capacity(cluster.len());
395
396 async {
397 for analysis in cluster {
398 songs.push(Analysis::read_song(db, analysis.id.clone()).await?.id);
399 }
400
401 Collection::add_songs(db, collection.id.clone(), songs).await?;
402
403 <Result<(), Error>>::Ok(())
404 }
405 .instrument(tracing::info_span!("Adding songs to collection"))
406 .await?;
407 }
408 Ok::<(), Error>(())
409 }
410 .instrument(tracing::info_span!("Creating new collections"))
411 .await?;
412
413 info!("Library recluster complete");
414 info!("Library brief: {:?}", brief(db).await?);
415
416 Ok(())
417}
418
419#[instrument]
425pub async fn brief<C: Connection>(db: &Surreal<C>) -> Result<LibraryBrief, Error> {
426 Ok(LibraryBrief {
427 artists: count_artists(db).await?,
428 albums: count_albums(db).await?,
429 songs: count_songs(db).await?,
430 playlists: count_playlists(db).await?,
431 collections: count_collections(db).await?,
432 dynamic_playlists: count_dynamic_playlists(db).await?,
433 })
434}
435
436#[instrument]
442pub async fn full<C: Connection>(db: &Surreal<C>) -> Result<LibraryFull, Error> {
443 Ok(LibraryFull {
444 artists: Artist::read_all(db).await?.into(),
445 albums: Album::read_all(db).await?.into(),
446 songs: Song::read_all(db).await?.into(),
447 playlists: Playlist::read_all(db).await?.into(),
448 collections: Collection::read_all(db).await?.into(),
449 dynamic_playlists: DynamicPlaylist::read_all(db).await?.into(),
450 })
451}
452
453#[instrument]
461pub async fn health<C: Connection>(db: &Surreal<C>) -> Result<LibraryHealth, Error> {
462 Ok(LibraryHealth {
463 artists: count_artists(db).await?,
464 albums: count_albums(db).await?,
465 songs: count_songs(db).await?,
466 #[cfg(feature = "analysis")]
467 unanalyzed_songs: Some(count_unanalyzed_songs(db).await?),
468 #[cfg(not(feature = "analysis"))]
469 unanalyzed_songs: None,
470 playlists: count_playlists(db).await?,
471 collections: count_collections(db).await?,
472 dynamic_playlists: count_dynamic_playlists(db).await?,
473 orphaned_artists: count_orphaned_artists(db).await?,
474 orphaned_albums: count_orphaned_albums(db).await?,
475 orphaned_playlists: count_orphaned_playlists(db).await?,
476 orphaned_collections: count_orphaned_collections(db).await?,
477 })
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::test_utils::init;
484
485 use mecomp_core::config::ClusterAlgorithm;
486 use mecomp_storage::db::schemas::song::{SongChangeSet, SongMetadata};
487 use mecomp_storage::test_utils::{
488 ARTIST_NAME_SEPARATOR, SongCase, arb_analysis_features, arb_song_case, arb_vec,
489 create_song_metadata, create_song_with_overrides, init_test_database,
490 };
491 use one_or_many::OneOrMany;
492 use pretty_assertions::assert_eq;
493
494 #[tokio::test]
495 #[allow(clippy::too_many_lines)]
496 async fn test_rescan() {
497 init();
498 let tempdir = tempfile::tempdir().unwrap();
499 let db = init_test_database().await.unwrap();
500
501 let song_cases = arb_vec(&arb_song_case(), 10..=15)();
503 let metadatas = song_cases
504 .into_iter()
505 .map(|song_case| create_song_metadata(&tempdir, song_case))
506 .collect::<Result<Vec<_>, _>>()
507 .unwrap();
508 let song_with_nonexistent_path = create_song_with_overrides(
511 &db,
512 arb_song_case()(),
513 SongChangeSet {
514 path: Some(tempdir.path().join("nonexistent.mp3")),
515 ..Default::default()
516 },
517 )
518 .await
519 .unwrap();
520 let mut metadata_of_song_with_outdated_metadata =
521 create_song_metadata(&tempdir, arb_song_case()()).unwrap();
522 metadata_of_song_with_outdated_metadata.genre = OneOrMany::None;
523 let song_with_outdated_metadata =
524 Song::try_load_into_db(&db, metadata_of_song_with_outdated_metadata)
525 .await
526 .unwrap();
527 let invalid_song_path = tempdir.path().join("invalid1.mp3");
529 std::fs::write(&invalid_song_path, "this is not a song").unwrap();
530 let invalid_song_path = tempdir.path().join("invalid2.mp3");
532 std::fs::write(&invalid_song_path, "this is not a song").unwrap();
533 let song_with_invalid_metadata = create_song_with_overrides(
534 &db,
535 arb_song_case()(),
536 SongChangeSet {
537 path: Some(tempdir.path().join("invalid2.mp3")),
538 ..Default::default()
539 },
540 )
541 .await
542 .unwrap();
543
544 rescan(
546 &db,
547 &[tempdir.path().to_owned()],
548 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
549 &OneOrMany::None,
550 Some(ARTIST_NAME_SEPARATOR),
551 MetadataConflictResolution::Overwrite,
552 )
553 .await
554 .unwrap();
555
556 assert_eq!(
559 Song::read(&db, song_with_nonexistent_path.id)
560 .await
561 .unwrap(),
562 None
563 );
564 assert_eq!(
566 Song::read(&db, song_with_invalid_metadata.id)
567 .await
568 .unwrap(),
569 None
570 );
571 assert!(
573 Song::read(&db, song_with_outdated_metadata.id)
574 .await
575 .unwrap()
576 .unwrap()
577 .genre
578 .is_some()
579 );
580 for metadata in metadatas {
583 let song = Song::read_by_path(&db, metadata.path.clone())
585 .await
586 .unwrap();
587 assert!(song.is_some());
588 let song = song.unwrap();
589
590 assert_eq!(SongMetadata::from(&song), metadata);
592
593 let artists = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
595 .await
596 .unwrap();
597 assert_eq!(artists.len(), metadata.artist.len());
598 for artist in &artists {
600 assert!(metadata.artist.contains(&artist.name));
601 assert!(
602 Artist::read_songs(&db, artist.id.clone())
603 .await
604 .unwrap()
605 .contains(&song)
606 );
607 }
608 if let Ok(song_artists) = Song::read_artist(&db, song.id.clone()).await {
610 for artist in &artists {
611 assert!(song_artists.contains(artist));
612 }
613 } else {
614 panic!("Error reading song artists");
615 }
616
617 let album = Album::read_by_name_and_album_artist(
619 &db,
620 &metadata.album,
621 metadata.album_artist.clone(),
622 )
623 .await
624 .unwrap();
625 assert!(album.is_some());
626 let album = album.unwrap();
627 assert_eq!(
629 Song::read_album(&db, song.id.clone()).await.unwrap(),
630 Some(album.clone())
631 );
632 assert!(
634 Album::read_songs(&db, album.id.clone())
635 .await
636 .unwrap()
637 .contains(&song)
638 );
639
640 let album_artists =
642 Artist::read_by_names(&db, Vec::from(metadata.album_artist.clone()))
643 .await
644 .unwrap();
645 assert_eq!(album_artists.len(), metadata.album_artist.len());
646 for album_artist in album_artists {
648 assert!(metadata.album_artist.contains(&album_artist.name));
649 assert!(
650 Artist::read_albums(&db, album_artist.id.clone())
651 .await
652 .unwrap()
653 .contains(&album)
654 );
655 }
656 }
657 }
658
659 #[tokio::test]
660 async fn rescan_deletes_preexisting_orphans() {
661 init();
662 let tempdir = tempfile::tempdir().unwrap();
663 let db = init_test_database().await.unwrap();
664
665 let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
667 let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
668
669 std::fs::remove_file(&song.path).unwrap();
671 Song::delete(&db, (song.id.clone(), false)).await.unwrap();
672
673 rescan(
675 &db,
676 &[tempdir.path().to_owned()],
677 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
678 &OneOrMany::None,
679 Some(ARTIST_NAME_SEPARATOR),
680 MetadataConflictResolution::Overwrite,
681 )
682 .await
683 .unwrap();
684
685 assert_eq!(Song::read_all(&db).await.unwrap().len(), 0);
687 assert_eq!(Album::read_all(&db).await.unwrap().len(), 0);
688 assert_eq!(Artist::read_all(&db).await.unwrap().len(), 0);
689 }
690
691 #[tokio::test]
692 async fn rescan_deletes_orphaned_albums_and_artists() {
693 init();
694 let tempdir = tempfile::tempdir().unwrap();
695 let db = init_test_database().await.unwrap();
696
697 let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
699 let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
700 let artist = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
701 .await
702 .unwrap()
703 .pop()
704 .unwrap();
705 let album = Album::read_by_name_and_album_artist(
706 &db,
707 &metadata.album,
708 metadata.album_artist.clone(),
709 )
710 .await
711 .unwrap()
712 .unwrap();
713
714 std::fs::remove_file(&song.path).unwrap();
716
717 rescan(
719 &db,
720 &[tempdir.path().to_owned()],
721 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
722 &OneOrMany::None,
723 Some(ARTIST_NAME_SEPARATOR),
724 MetadataConflictResolution::Overwrite,
725 )
726 .await
727 .unwrap();
728
729 assert_eq!(Artist::read(&db, artist.id.clone()).await.unwrap(), None);
731 assert_eq!(Album::read(&db, album.id.clone()).await.unwrap(), None);
732 }
733
734 #[tokio::test]
735 async fn test_analyze() {
736 init();
737 let dir = tempfile::tempdir().unwrap();
738 let db = init_test_database().await.unwrap();
739
740 let song_cases = arb_vec(&arb_song_case(), 10..=15)();
742 let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
743 song: u8::try_from(i).unwrap(),
744 ..sc
745 });
746 let metadatas = song_cases
747 .into_iter()
748 .map(|song_case| create_song_metadata(&dir, song_case))
749 .collect::<Result<Vec<_>, _>>()
750 .unwrap();
751 for metadata in &metadatas {
752 Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
753 }
754
755 assert_eq!(
757 Analysis::read_songs_without_analysis(&db)
758 .await
759 .unwrap()
760 .len(),
761 metadatas.len()
762 );
763
764 analyze(&db, true).await.unwrap();
766
767 assert_eq!(
769 Analysis::read_songs_without_analysis(&db)
770 .await
771 .unwrap()
772 .len(),
773 0
774 );
775 for metadata in &metadatas {
776 let song = Song::read_by_path(&db, metadata.path.clone())
777 .await
778 .unwrap()
779 .unwrap();
780 let analysis = Analysis::read_for_song(&db, song.id.clone()).await.unwrap();
781 assert!(analysis.is_some());
782 }
783
784 for analysis in Analysis::read_all(&db).await.unwrap() {
786 let neighbors = Analysis::nearest_neighbors(&db, analysis.id.clone(), 100)
787 .await
788 .unwrap();
789 assert!(!neighbors.contains(&analysis));
790 assert_eq!(neighbors.len(), metadatas.len() - 1);
791 assert_eq!(
792 neighbors.len(),
793 neighbors
794 .iter()
795 .map(|n| n.id.clone())
796 .collect::<HashSet<_>>()
797 .len()
798 );
799 }
800 }
801
802 #[tokio::test]
803 async fn test_recluster() {
804 init();
805 let dir = tempfile::tempdir().unwrap();
806 let db = init_test_database().await.unwrap();
807 let settings = ReclusterSettings {
808 gap_statistic_reference_datasets: 5,
809 max_clusters: 18,
810 algorithm: ClusterAlgorithm::GMM,
811 };
812
813 let song_cases = arb_vec(&arb_song_case(), 32..=32)();
815 let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
816 song: u8::try_from(i).unwrap(),
817 ..sc
818 });
819 let metadatas = song_cases
820 .into_iter()
821 .map(|song_case| create_song_metadata(&dir, song_case))
822 .collect::<Result<Vec<_>, _>>()
823 .unwrap();
824 let mut songs = Vec::with_capacity(metadatas.len());
825 for metadata in &metadatas {
826 songs.push(Song::try_load_into_db(&db, metadata.clone()).await.unwrap());
827 }
828
829 for song in &songs {
831 Analysis::create(
832 &db,
833 song.id.clone(),
834 Analysis {
835 id: Analysis::generate_id(),
836 features: arb_analysis_features()(),
837 },
838 )
839 .await
840 .unwrap();
841 }
842
843 recluster(&db, &settings).await.unwrap();
845
846 let collections = Collection::read_all(&db).await.unwrap();
848 assert!(!collections.is_empty());
849 for collection in collections {
850 let songs = Collection::read_songs(&db, collection.id.clone())
851 .await
852 .unwrap();
853 assert!(!songs.is_empty());
854 }
855 }
856
857 #[tokio::test]
858 async fn test_brief() {
859 init();
860 let db = init_test_database().await.unwrap();
861 let brief = brief(&db).await.unwrap();
862 assert_eq!(brief.artists, 0);
863 assert_eq!(brief.albums, 0);
864 assert_eq!(brief.songs, 0);
865 assert_eq!(brief.playlists, 0);
866 assert_eq!(brief.collections, 0);
867 }
868
869 #[tokio::test]
870 async fn test_full() {
871 init();
872 let db = init_test_database().await.unwrap();
873 let full = full(&db).await.unwrap();
874 assert_eq!(full.artists.len(), 0);
875 assert_eq!(full.albums.len(), 0);
876 assert_eq!(full.songs.len(), 0);
877 assert_eq!(full.playlists.len(), 0);
878 assert_eq!(full.collections.len(), 0);
879 }
880
881 #[tokio::test]
882 async fn test_health() {
883 init();
884 let db = init_test_database().await.unwrap();
885 let health = health(&db).await.unwrap();
886 assert_eq!(health.artists, 0);
887 assert_eq!(health.albums, 0);
888 assert_eq!(health.songs, 0);
889 #[cfg(feature = "analysis")]
890 assert_eq!(health.unanalyzed_songs, Some(0));
891 #[cfg(not(feature = "analysis"))]
892 assert_eq!(health.unanalyzed_songs, None);
893 assert_eq!(health.playlists, 0);
894 assert_eq!(health.collections, 0);
895 assert_eq!(health.orphaned_artists, 0);
896 assert_eq!(health.orphaned_albums, 0);
897 assert_eq!(health.orphaned_playlists, 0);
898 assert_eq!(health.orphaned_collections, 0);
899 }
900}