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::{DecoderWithCallback, 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]
235pub async fn analyze<C: Connection>(db: &Surreal<C>) -> Result<(), Error> {
236 let songs_to_analyze: Vec<Song> = Analysis::read_songs_without_analysis(db).await?;
238 let paths = songs_to_analyze
240 .iter()
241 .map(|song| (song.path.clone(), song.id.clone()))
242 .collect::<HashMap<_, _>>();
243
244 let keys = paths.keys().cloned().collect::<Vec<_>>();
245
246 let (tx, rx) = std::sync::mpsc::channel();
247
248 let handle = std::thread::spawn(move || {
250 MecompDecoder::analyze_paths_with_callback(keys, tx);
251 });
252
253 async {
254 for (song_path, maybe_analysis) in rx {
255 let Some(song_id) = paths.get(&song_path) else {
256 error!("No song id found for path: {}", song_path.to_string_lossy());
257 continue;
258 };
259
260 match maybe_analysis {
261 Ok(analysis) => Analysis::create(
262 db,
263 song_id.clone(),
264 Analysis {
265 id: Analysis::generate_id(),
266 features: *analysis.inner(),
267 },
268 )
269 .await?
270 .map_or_else(
271 || {
272 warn!(
273 "Error analyzing {}: song either wasn't found or already has an analysis",
274 song_path.to_string_lossy()
275 );
276 },
277 |_| debug!("Analyzed {}", song_path.to_string_lossy()),
278 ),
279 Err(e) => {
280 error!("Error analyzing {}: {}", song_path.to_string_lossy(), e);
281 }
282 }
283 }
284
285 <Result<(), Error>>::Ok(())
286 }
287 .instrument(tracing::info_span!("Adding analyses to database"))
288 .await?;
289
290 handle.join().expect("Couldn't join thread");
291
292 info!("Library analysis complete");
293 info!("Library brief: {:?}", brief(db).await?);
294
295 Ok(())
296}
297
298#[instrument]
306pub async fn recluster<C: Connection>(
307 db: &Surreal<C>,
308 settings: &ReclusterSettings,
309) -> Result<(), Error> {
310 let samples = Analysis::read_all(db).await?;
312
313 let entered = tracing::info_span!("Clustering library").entered();
314 let model: ClusteringHelper<NotInitialized> = match ClusteringHelper::new(
316 samples
317 .iter()
318 .map(Into::into)
319 .collect::<Vec<mecomp_analysis::Analysis>>()
320 .into(),
321 settings.max_clusters,
322 KOptimal::GapStatistic {
323 b: settings.gap_statistic_reference_datasets,
324 },
325 settings.algorithm.into(),
326 ) {
327 Err(e) => {
328 error!("There was an error creating the clustering helper: {e}",);
329 return Ok(());
330 }
331 Ok(kmeans) => kmeans,
332 };
333
334 let model = match model.initialize() {
335 Err(e) => {
336 error!("There was an error initializing the clustering helper: {e}",);
337 return Ok(());
338 }
339 Ok(kmeans) => kmeans.cluster(),
340 };
341 drop(entered);
342
343 async {
345 for collection in Collection::read_all(db).await? {
348 Collection::delete(db, collection.id.clone()).await?;
349 }
350
351 <Result<(), Error>>::Ok(())
352 }
353 .instrument(tracing::info_span!("Deleting old collections"))
354 .await?;
355
356 async {
358 let clusters = model.extract_analysis_clusters(samples);
359
360 for (i, cluster) in clusters.iter().filter(|c| !c.is_empty()).enumerate() {
362 let collection = Collection::create(
363 db,
364 Collection {
365 id: Collection::generate_id(),
366 name: format!("Collection {i}"),
367 runtime: Duration::default(),
368 song_count: Default::default(),
369 },
370 )
371 .await?
372 .ok_or(Error::NotCreated)?;
373
374 let mut songs = Vec::with_capacity(cluster.len());
375
376 async {
377 for analysis in cluster {
378 songs.push(Analysis::read_song(db, analysis.id.clone()).await?.id);
379 }
380
381 Collection::add_songs(db, collection.id.clone(), songs).await?;
382
383 <Result<(), Error>>::Ok(())
384 }
385 .instrument(tracing::info_span!("Adding songs to collection"))
386 .await?;
387 }
388 Ok::<(), Error>(())
389 }
390 .instrument(tracing::info_span!("Creating new collections"))
391 .await?;
392
393 info!("Library recluster complete");
394 info!("Library brief: {:?}", brief(db).await?);
395
396 Ok(())
397}
398
399#[instrument]
405pub async fn brief<C: Connection>(db: &Surreal<C>) -> Result<LibraryBrief, Error> {
406 Ok(LibraryBrief {
407 artists: count_artists(db).await?,
408 albums: count_albums(db).await?,
409 songs: count_songs(db).await?,
410 playlists: count_playlists(db).await?,
411 collections: count_collections(db).await?,
412 dynamic_playlists: count_dynamic_playlists(db).await?,
413 })
414}
415
416#[instrument]
422pub async fn full<C: Connection>(db: &Surreal<C>) -> Result<LibraryFull, Error> {
423 Ok(LibraryFull {
424 artists: Artist::read_all(db).await?.into(),
425 albums: Album::read_all(db).await?.into(),
426 songs: Song::read_all(db).await?.into(),
427 playlists: Playlist::read_all(db).await?.into(),
428 collections: Collection::read_all(db).await?.into(),
429 dynamic_playlists: DynamicPlaylist::read_all(db).await?.into(),
430 })
431}
432
433#[instrument]
441pub async fn health<C: Connection>(db: &Surreal<C>) -> Result<LibraryHealth, Error> {
442 Ok(LibraryHealth {
443 artists: count_artists(db).await?,
444 albums: count_albums(db).await?,
445 songs: count_songs(db).await?,
446 #[cfg(feature = "analysis")]
447 unanalyzed_songs: Some(count_unanalyzed_songs(db).await?),
448 #[cfg(not(feature = "analysis"))]
449 unanalyzed_songs: None,
450 playlists: count_playlists(db).await?,
451 collections: count_collections(db).await?,
452 dynamic_playlists: count_dynamic_playlists(db).await?,
453 orphaned_artists: count_orphaned_artists(db).await?,
454 orphaned_albums: count_orphaned_albums(db).await?,
455 orphaned_playlists: count_orphaned_playlists(db).await?,
456 orphaned_collections: count_orphaned_collections(db).await?,
457 })
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::test_utils::init;
464
465 use mecomp_core::config::ClusterAlgorithm;
466 use mecomp_storage::db::schemas::song::{SongChangeSet, SongMetadata};
467 use mecomp_storage::test_utils::{
468 arb_analysis_features, arb_song_case, arb_vec, create_song_metadata,
469 create_song_with_overrides, init_test_database, SongCase, ARTIST_NAME_SEPARATOR,
470 };
471 use one_or_many::OneOrMany;
472 use pretty_assertions::assert_eq;
473
474 #[tokio::test]
475 async fn test_rescan() {
476 init();
477 let tempdir = tempfile::tempdir().unwrap();
478 let db = init_test_database().await.unwrap();
479
480 let song_cases = arb_vec(&arb_song_case(), 10..=15)();
482 let metadatas = song_cases
483 .into_iter()
484 .map(|song_case| create_song_metadata(&tempdir, song_case))
485 .collect::<Result<Vec<_>, _>>()
486 .unwrap();
487 let song_with_nonexistent_path = create_song_with_overrides(
490 &db,
491 arb_song_case()(),
492 SongChangeSet {
493 path: Some(tempdir.path().join("nonexistent.mp3")),
494 ..Default::default()
495 },
496 )
497 .await
498 .unwrap();
499 let mut metadata_of_song_with_outdated_metadata =
500 create_song_metadata(&tempdir, arb_song_case()()).unwrap();
501 metadata_of_song_with_outdated_metadata.genre = OneOrMany::None;
502 let song_with_outdated_metadata =
503 Song::try_load_into_db(&db, metadata_of_song_with_outdated_metadata)
504 .await
505 .unwrap();
506 let invalid_song_path = tempdir.path().join("invalid1.mp3");
508 std::fs::write(&invalid_song_path, "this is not a song").unwrap();
509 let invalid_song_path = tempdir.path().join("invalid2.mp3");
511 std::fs::write(&invalid_song_path, "this is not a song").unwrap();
512 let song_with_invalid_metadata = create_song_with_overrides(
513 &db,
514 arb_song_case()(),
515 SongChangeSet {
516 path: Some(tempdir.path().join("invalid2.mp3")),
517 ..Default::default()
518 },
519 )
520 .await
521 .unwrap();
522
523 rescan(
525 &db,
526 &[tempdir.path().to_owned()],
527 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
528 Some(ARTIST_NAME_SEPARATOR),
529 MetadataConflictResolution::Overwrite,
530 )
531 .await
532 .unwrap();
533
534 assert_eq!(
537 Song::read(&db, song_with_nonexistent_path.id)
538 .await
539 .unwrap(),
540 None
541 );
542 assert_eq!(
544 Song::read(&db, song_with_invalid_metadata.id)
545 .await
546 .unwrap(),
547 None
548 );
549 assert!(Song::read(&db, song_with_outdated_metadata.id)
551 .await
552 .unwrap()
553 .unwrap()
554 .genre
555 .is_some());
556 for metadata in metadatas {
559 let song = Song::read_by_path(&db, metadata.path.clone())
561 .await
562 .unwrap();
563 assert!(song.is_some());
564 let song = song.unwrap();
565
566 assert_eq!(SongMetadata::from(&song), metadata);
568
569 let artists = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
571 .await
572 .unwrap();
573 assert_eq!(artists.len(), metadata.artist.len());
574 for artist in &artists {
576 assert!(metadata.artist.contains(&artist.name));
577 assert!(Artist::read_songs(&db, artist.id.clone())
578 .await
579 .unwrap()
580 .contains(&song));
581 }
582 if let Ok(song_artists) = Song::read_artist(&db, song.id.clone()).await {
584 for artist in &artists {
585 assert!(song_artists.contains(artist));
586 }
587 } else {
588 panic!("Error reading song artists");
589 }
590
591 let album = Album::read_by_name_and_album_artist(
593 &db,
594 &metadata.album,
595 metadata.album_artist.clone(),
596 )
597 .await
598 .unwrap();
599 assert!(album.is_some());
600 let album = album.unwrap();
601 assert_eq!(
603 Song::read_album(&db, song.id.clone()).await.unwrap(),
604 Some(album.clone())
605 );
606 assert!(Album::read_songs(&db, album.id.clone())
608 .await
609 .unwrap()
610 .contains(&song));
611
612 let album_artists =
614 Artist::read_by_names(&db, Vec::from(metadata.album_artist.clone()))
615 .await
616 .unwrap();
617 assert_eq!(album_artists.len(), metadata.album_artist.len());
618 for album_artist in album_artists {
620 assert!(metadata.album_artist.contains(&album_artist.name));
621 assert!(Artist::read_albums(&db, album_artist.id.clone())
622 .await
623 .unwrap()
624 .contains(&album));
625 }
626 }
627 }
628
629 #[tokio::test]
630 async fn rescan_deletes_preexisting_orphans() {
631 init();
632 let tempdir = tempfile::tempdir().unwrap();
633 let db = init_test_database().await.unwrap();
634
635 let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
637 let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
638
639 std::fs::remove_file(&song.path).unwrap();
641 Song::delete(&db, (song.id.clone(), false)).await.unwrap();
642
643 rescan(
645 &db,
646 &[tempdir.path().to_owned()],
647 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
648 Some(ARTIST_NAME_SEPARATOR),
649 MetadataConflictResolution::Overwrite,
650 )
651 .await
652 .unwrap();
653
654 assert_eq!(Song::read_all(&db).await.unwrap().len(), 0);
656 assert_eq!(Album::read_all(&db).await.unwrap().len(), 0);
657 assert_eq!(Artist::read_all(&db).await.unwrap().len(), 0);
658 }
659
660 #[tokio::test]
661 async fn rescan_deletes_orphaned_albums_and_artists() {
662 init();
663 let tempdir = tempfile::tempdir().unwrap();
664 let db = init_test_database().await.unwrap();
665
666 let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
668 let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
669 let artist = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
670 .await
671 .unwrap()
672 .pop()
673 .unwrap();
674 let album = Album::read_by_name_and_album_artist(
675 &db,
676 &metadata.album,
677 metadata.album_artist.clone(),
678 )
679 .await
680 .unwrap()
681 .unwrap();
682
683 std::fs::remove_file(&song.path).unwrap();
685
686 rescan(
688 &db,
689 &[tempdir.path().to_owned()],
690 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
691 Some(ARTIST_NAME_SEPARATOR),
692 MetadataConflictResolution::Overwrite,
693 )
694 .await
695 .unwrap();
696
697 assert_eq!(Artist::read(&db, artist.id.clone()).await.unwrap(), None);
699 assert_eq!(Album::read(&db, album.id.clone()).await.unwrap(), None);
700 }
701
702 #[tokio::test]
703 async fn test_analyze() {
704 init();
705 let dir = tempfile::tempdir().unwrap();
706 let db = init_test_database().await.unwrap();
707
708 let song_cases = arb_vec(&arb_song_case(), 10..=15)();
710 let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
711 song: i as u8,
712 ..sc
713 });
714 let metadatas = song_cases
715 .into_iter()
716 .map(|song_case| create_song_metadata(&dir, song_case))
717 .collect::<Result<Vec<_>, _>>()
718 .unwrap();
719 for metadata in &metadatas {
720 Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
721 }
722
723 assert_eq!(
725 Analysis::read_songs_without_analysis(&db)
726 .await
727 .unwrap()
728 .len(),
729 metadatas.len()
730 );
731
732 analyze(&db).await.unwrap();
734
735 assert_eq!(
737 Analysis::read_songs_without_analysis(&db)
738 .await
739 .unwrap()
740 .len(),
741 0
742 );
743 for metadata in &metadatas {
744 let song = Song::read_by_path(&db, metadata.path.clone())
745 .await
746 .unwrap()
747 .unwrap();
748 let analysis = Analysis::read_for_song(&db, song.id.clone()).await.unwrap();
749 assert!(analysis.is_some());
750 }
751
752 for analysis in Analysis::read_all(&db).await.unwrap() {
754 let neighbors = Analysis::nearest_neighbors(&db, analysis.id.clone(), 100)
755 .await
756 .unwrap();
757 assert!(!neighbors.contains(&analysis));
758 assert_eq!(neighbors.len(), metadatas.len() - 1);
759 assert_eq!(
760 neighbors.len(),
761 neighbors
762 .iter()
763 .map(|n| n.id.clone())
764 .collect::<HashSet<_>>()
765 .len()
766 );
767 }
768 }
769
770 #[tokio::test]
771 async fn test_recluster() {
772 init();
773 let dir = tempfile::tempdir().unwrap();
774 let db = init_test_database().await.unwrap();
775 let settings = ReclusterSettings {
776 gap_statistic_reference_datasets: 5,
777 max_clusters: 18,
778 algorithm: ClusterAlgorithm::GMM,
779 };
780
781 let song_cases = arb_vec(&arb_song_case(), 32..=32)();
783 let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
784 song: i as u8,
785 ..sc
786 });
787 let metadatas = song_cases
788 .into_iter()
789 .map(|song_case| create_song_metadata(&dir, song_case))
790 .collect::<Result<Vec<_>, _>>()
791 .unwrap();
792 let mut songs = Vec::with_capacity(metadatas.len());
793 for metadata in &metadatas {
794 songs.push(Song::try_load_into_db(&db, metadata.clone()).await.unwrap());
795 }
796
797 for song in &songs {
799 Analysis::create(
800 &db,
801 song.id.clone(),
802 Analysis {
803 id: Analysis::generate_id(),
804 features: arb_analysis_features()(),
805 },
806 )
807 .await
808 .unwrap();
809 }
810
811 recluster(&db, &settings).await.unwrap();
813
814 let collections = Collection::read_all(&db).await.unwrap();
816 assert!(!collections.is_empty());
817 for collection in collections {
818 let songs = Collection::read_songs(&db, collection.id.clone())
819 .await
820 .unwrap();
821 assert!(!songs.is_empty());
822 }
823 }
824
825 #[tokio::test]
826 async fn test_brief() {
827 init();
828 let db = init_test_database().await.unwrap();
829 let brief = brief(&db).await.unwrap();
830 assert_eq!(brief.artists, 0);
831 assert_eq!(brief.albums, 0);
832 assert_eq!(brief.songs, 0);
833 assert_eq!(brief.playlists, 0);
834 assert_eq!(brief.collections, 0);
835 }
836
837 #[tokio::test]
838 async fn test_full() {
839 init();
840 let db = init_test_database().await.unwrap();
841 let full = full(&db).await.unwrap();
842 assert_eq!(full.artists.len(), 0);
843 assert_eq!(full.albums.len(), 0);
844 assert_eq!(full.songs.len(), 0);
845 assert_eq!(full.playlists.len(), 0);
846 assert_eq!(full.collections.len(), 0);
847 }
848
849 #[tokio::test]
850 async fn test_health() {
851 init();
852 let db = init_test_database().await.unwrap();
853 let health = health(&db).await.unwrap();
854 assert_eq!(health.artists, 0);
855 assert_eq!(health.albums, 0);
856 assert_eq!(health.songs, 0);
857 #[cfg(feature = "analysis")]
858 assert_eq!(health.unanalyzed_songs, Some(0));
859 #[cfg(not(feature = "analysis"))]
860 assert_eq!(health.unanalyzed_songs, None);
861 assert_eq!(health.playlists, 0);
862 assert_eq!(health.collections, 0);
863 assert_eq!(health.orphaned_artists, 0);
864 assert_eq!(health.orphaned_albums, 0);
865 assert_eq!(health.orphaned_playlists, 0);
866 assert_eq!(health.orphaned_collections, 0);
867 }
868}