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