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::{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/// 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    genre_separator: Option<&str>,
56    conflict_resolution_mode: MetadataConflictResolution,
57) -> Result<(), Error> {
58    // get all the songs in the current library
59    let songs = Song::read_all(db).await?;
60    let mut paths_to_skip = HashSet::new(); // use a hashset because hashing is faster than linear search, especially for large libraries
61
62    // for each song, check if the file still exists
63    async {
64        for song in songs {
65            let path = song.path.clone();
66            if !path.exists() {
67                // remove the song from the library
68                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            // check if the metadata of the file is the same as the metadata in the database
75            match SongMetadata::load_from_path(path.clone(), artist_name_separator, genre_separator) {
76                // if we have metadata and the metadata is different from the song's metadata, and ...
77                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                        // ... we are in "overwrite" mode, update the song's metadata
90                        MetadataConflictResolution::Overwrite => {
91                            // if the file has been modified, update the song's metadata
92                            Song::update(db, song.id.clone(), metadata.merge_with_song(&song)).await?;
93                        }
94                        // ... we are in "skip" mode, do nothing
95                        MetadataConflictResolution::Skip => {
96                            continue;
97                        }
98                    }
99                }
100                // if we have an error, delete the song from the library
101                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                // if the metadata is the same, do nothing
111                _ => {}
112            }
113
114            // now, add the path to the list of paths to skip so that we don't index the song again
115            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    // now, index all the songs in the library that haven't been indexed yet
122    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            // if the file is a song, add it to the library
144            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    // find and delete any remaining orphaned albums and artists
167    // TODO: create a custom query for this
168
169    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/// Analyze the library.
221///
222/// In order, this function will:
223/// - get all the songs that aren't currently analyzed.
224/// - start analyzing those songs in batches.
225/// - update the database with the analyses.
226///
227/// # Errors
228///
229/// This function will return an error if there is an error reading from the database.
230///
231/// # Panics
232///
233/// This function will panic if the thread(s) that analyzes the songs panics.
234#[instrument]
235pub async fn analyze<C: Connection>(db: &Surreal<C>) -> Result<(), Error> {
236    // get all the songs that don't have an analysis
237    let songs_to_analyze: Vec<Song> = Analysis::read_songs_without_analysis(db).await?;
238    // crate a hashmap mapping paths to song ids
239    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    // analyze the songs in batches
249    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/// Recluster the library.
299///
300/// This function will remove and recompute all the "collections" (clusters) in the library.
301///
302/// # Errors
303///
304/// This function will return an error if there is an error reading from the database.
305#[instrument]
306pub async fn recluster<C: Connection>(
307    db: &Surreal<C>,
308    settings: &ReclusterSettings,
309) -> Result<(), Error> {
310    // collect all the analyses
311    let samples = Analysis::read_all(db).await?;
312
313    let entered = tracing::info_span!("Clustering library").entered();
314    // use clustering algorithm to cluster the analyses
315    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    // delete all the collections
344    async {
345        // 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")))"
346        // (this was happening with 892 songs in a collection)
347        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    // get the clusters from the clustering
357    async {
358        let clusters = model.extract_analysis_clusters(samples);
359
360        // create the collections
361        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/// Get a brief overview of the library.
400///
401/// # Errors
402///
403/// This function will return an error if there is an error reading from the database.
404#[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/// Get the full library.
417///
418/// # Errors
419///
420/// This function will return an error if there is an error reading from the database.
421#[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/// Get the health of the library.
434///
435/// This function will return the health of the library, including the number of orphaned items.
436///
437/// # Errors
438///
439/// This function will return an error if there is an error reading from the database.
440#[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        // populate the tempdir with songs that aren't in the database
481        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        // also make some songs that are in the database
488        //  - a song that whose file was deleted
489        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        // also add a "song" that can't be read
507        let invalid_song_path = tempdir.path().join("invalid1.mp3");
508        std::fs::write(&invalid_song_path, "this is not a song").unwrap();
509        // add another invalid song, this time also put it in the database
510        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 the library
524        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        // check that everything was done correctly
535        // - `song_with_nonexistent_path` was deleted
536        assert_eq!(
537            Song::read(&db, song_with_nonexistent_path.id)
538                .await
539                .unwrap(),
540            None
541        );
542        // - `song_with_invalid_metadata` was deleted
543        assert_eq!(
544            Song::read(&db, song_with_invalid_metadata.id)
545                .await
546                .unwrap(),
547            None
548        );
549        // - `song_with_outdated_metadata` was updated
550        assert!(Song::read(&db, song_with_outdated_metadata.id)
551            .await
552            .unwrap()
553            .unwrap()
554            .genre
555            .is_some());
556        // - all the other songs were added
557        //   and their artists, albums, and album_artists were added and linked correctly
558        for metadata in metadatas {
559            // the song was created
560            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            // the song's metadata is correct
567            assert_eq!(SongMetadata::from(&song), metadata);
568
569            // the song's artists were created
570            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            // the song is linked to the artists
575            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            // the artists are linked to the song
583            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            // the song's album was created
592            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            // the song is linked to the album
602            assert_eq!(
603                Song::read_album(&db, song.id.clone()).await.unwrap(),
604                Some(album.clone())
605            );
606            // the album is linked to the song
607            assert!(Album::read_songs(&db, album.id.clone())
608                .await
609                .unwrap()
610                .contains(&song));
611
612            // the album's album artists were created
613            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            // the album is linked to the album artists
619            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        // create a song with an artist and an album
636        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        // delete the song, leaving orphaned artist and album
640        std::fs::remove_file(&song.path).unwrap();
641        Song::delete(&db, (song.id.clone(), false)).await.unwrap();
642
643        // rescan the library
644        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        // check that the album and artist deleted
655        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        // create a song with an artist and an album
667        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        // delete the song, leaving orphaned artist and album
684        std::fs::remove_file(&song.path).unwrap();
685
686        // rescan the library
687        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        // check that the artist and album were deleted
698        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        // load some songs into the database
709        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        // check that there are no analyses before.
724        assert_eq!(
725            Analysis::read_songs_without_analysis(&db)
726                .await
727                .unwrap()
728                .len(),
729            metadatas.len()
730        );
731
732        // analyze the library
733        analyze(&db).await.unwrap();
734
735        // check that all the songs have analyses
736        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        // check that if we ask for the nearest neighbors of one of these songs, we get all the other songs
753        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        // load some songs into the database
782        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        // load some dummy analyses into the database
798        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 the library
812        recluster(&db, &settings).await.unwrap();
813
814        // check that there are collections
815        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}