mecomp_daemon/services/
library.rs

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/// Index the library.
44///
45/// # Errors
46///
47/// This function will return an error if there is an error reading from the database.
48/// or if there is an error reading from the file system.
49/// or if there is an error writing to the database.
50#[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    // get all the songs in the current library
60    let songs = Song::read_all(db).await?;
61    let mut paths_to_skip = HashSet::new(); // use a hashset because hashing is faster than linear search, especially for large libraries
62
63    // for each song, check if the file still exists
64    async {
65        for song in songs {
66            let path = song.path.clone();
67            if !path.exists() {
68                // remove the song from the library
69                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            // check if the metadata of the file is the same as the metadata in the database
76            match SongMetadata::load_from_path(path.clone(), artist_name_separator,protected_artist_names, genre_separator) {
77                // if we have metadata and the metadata is different from the song's metadata, and ...
78                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                        // ... we are in "overwrite" mode, update the song's metadata
91                        MetadataConflictResolution::Overwrite => {
92                            // if the file has been modified, update the song's metadata
93                            Song::update(db, song.id.clone(), metadata.merge_with_song(&song)).await?;
94                        }
95                        // ... we are in "skip" mode, do nothing
96                        MetadataConflictResolution::Skip => {
97                            continue;
98                        }
99                    }
100                }
101                // if we have an error, delete the song from the library
102                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                // if the metadata is the same, do nothing
112                _ => {}
113            }
114
115            // now, add the path to the list of paths to skip so that we don't index the song again
116            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    // now, index all the songs in the library that haven't been indexed yet
123    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            // if the file is a song, add it to the library
145            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    // find and delete any remaining orphaned albums and artists
169    // TODO: create a custom query for this
170
171    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/// Analyze the library.
223///
224/// In order, this function will:
225/// - if `overwrite` is true, delete all existing analyses.
226/// - get all the songs that aren't currently analyzed.
227/// - start analyzing those songs in batches.
228/// - update the database with the analyses.
229///
230/// # Errors
231///
232/// This function will return an error if there is an error reading from the database.
233///
234/// # Panics
235///
236/// This function will panic if the thread(s) that analyzes the songs panics.
237#[instrument]
238pub async fn analyze<C: Connection>(db: &Surreal<C>, overwrite: bool) -> Result<(), Error> {
239    if overwrite {
240        // delete all the analyses
241        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    // get all the songs that don't have an analysis
252    let songs_to_analyze: Vec<Song> = Analysis::read_songs_without_analysis(db).await?;
253    // crate a hashmap mapping paths to song ids
254    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    // analyze the songs in batches
269    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/// Recluster the library.
319///
320/// This function will remove and recompute all the "collections" (clusters) in the library.
321///
322/// # Errors
323///
324/// This function will return an error if there is an error reading from the database.
325#[instrument]
326pub async fn recluster<C: Connection>(
327    db: &Surreal<C>,
328    settings: &ReclusterSettings,
329) -> Result<(), Error> {
330    // collect all the analyses
331    let samples = Analysis::read_all(db).await?;
332
333    let entered = tracing::info_span!("Clustering library").entered();
334    // use clustering algorithm to cluster the analyses
335    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    // delete all the collections
364    async {
365        // NOTE: For some reason, if a collection has too many songs, it will fail to delete with "DbError(Db(Tx("Max transaction entries limit exceeded")))"
366        // (this was happening with 892 songs in a collection)
367        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    // get the clusters from the clustering
377    async {
378        let clusters = model.extract_analysis_clusters(samples);
379
380        // create the collections
381        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/// Get a brief overview of the library.
420///
421/// # Errors
422///
423/// This function will return an error if there is an error reading from the database.
424#[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/// Get the full library.
437///
438/// # Errors
439///
440/// This function will return an error if there is an error reading from the database.
441#[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/// Get the health of the library.
454///
455/// This function will return the health of the library, including the number of orphaned items.
456///
457/// # Errors
458///
459/// This function will return an error if there is an error reading from the database.
460#[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        // populate the tempdir with songs that aren't in the database
502        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        // also make some songs that are in the database
509        //  - a song that whose file was deleted
510        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        // also add a "song" that can't be read
528        let invalid_song_path = tempdir.path().join("invalid1.mp3");
529        std::fs::write(&invalid_song_path, "this is not a song").unwrap();
530        // add another invalid song, this time also put it in the database
531        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 the library
545        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        // check that everything was done correctly
557        // - `song_with_nonexistent_path` was deleted
558        assert_eq!(
559            Song::read(&db, song_with_nonexistent_path.id)
560                .await
561                .unwrap(),
562            None
563        );
564        // - `song_with_invalid_metadata` was deleted
565        assert_eq!(
566            Song::read(&db, song_with_invalid_metadata.id)
567                .await
568                .unwrap(),
569            None
570        );
571        // - `song_with_outdated_metadata` was updated
572        assert!(
573            Song::read(&db, song_with_outdated_metadata.id)
574                .await
575                .unwrap()
576                .unwrap()
577                .genre
578                .is_some()
579        );
580        // - all the other songs were added
581        //   and their artists, albums, and album_artists were added and linked correctly
582        for metadata in metadatas {
583            // the song was created
584            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            // the song's metadata is correct
591            assert_eq!(SongMetadata::from(&song), metadata);
592
593            // the song's artists were created
594            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            // the song is linked to the artists
599            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            // the artists are linked to the song
609            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            // the song's album was created
618            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            // the song is linked to the album
628            assert_eq!(
629                Song::read_album(&db, song.id.clone()).await.unwrap(),
630                Some(album.clone())
631            );
632            // the album is linked to the song
633            assert!(
634                Album::read_songs(&db, album.id.clone())
635                    .await
636                    .unwrap()
637                    .contains(&song)
638            );
639
640            // the album's album artists were created
641            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            // the album is linked to the album artists
647            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        // create a song with an artist and an album
666        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        // delete the song, leaving orphaned artist and album
670        std::fs::remove_file(&song.path).unwrap();
671        Song::delete(&db, (song.id.clone(), false)).await.unwrap();
672
673        // rescan the library
674        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        // check that the album and artist deleted
686        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        // create a song with an artist and an album
698        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        // delete the song, leaving orphaned artist and album
715        std::fs::remove_file(&song.path).unwrap();
716
717        // rescan the library
718        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        // check that the artist and album were deleted
730        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        // load some songs into the database
741        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        // check that there are no analyses before.
756        assert_eq!(
757            Analysis::read_songs_without_analysis(&db)
758                .await
759                .unwrap()
760                .len(),
761            metadatas.len()
762        );
763
764        // analyze the library
765        analyze(&db, true).await.unwrap();
766
767        // check that all the songs have analyses
768        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        // check that if we ask for the nearest neighbors of one of these songs, we get all the other songs
785        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        // load some songs into the database
814        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        // load some dummy analyses into the database
830        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 the library
844        recluster(&db, &settings).await.unwrap();
845
846        // check that there are collections
847        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}