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
43use crate::termination::InterruptReceiver;
44
45/// Index the library.
46///
47/// # Errors
48///
49/// This function will return an error if there is an error reading from the database.
50/// or if there is an error reading from the file system.
51/// or if there is an error writing to the database.
52#[instrument]
53#[inline]
54pub async fn rescan<C: Connection>(
55    db: &Surreal<C>,
56    paths: &[PathBuf],
57    artist_name_separator: &OneOrMany<String>,
58    protected_artist_names: &OneOrMany<String>,
59    genre_separator: Option<&str>,
60    conflict_resolution_mode: MetadataConflictResolution,
61) -> Result<(), Error> {
62    // get all the songs in the current library
63    let songs = Song::read_all(db).await?;
64    let mut paths_to_skip = HashSet::new(); // use a hashset because hashing is faster than linear search, especially for large libraries
65
66    // for each song, check if the file still exists
67    async {
68        for song in songs {
69            let path = song.path.clone();
70            if !path.exists() { // remove the song from the library
71                warn!("Song {} no longer exists, deleting", path.to_string_lossy());
72                Song::delete(db, song.id).await?;
73                continue;
74            }
75
76            debug!("loading metadata for {}", path.to_string_lossy());
77            // check if the metadata of the file is the same as the metadata in the database
78            match SongMetadata::load_from_path(path.clone(), artist_name_separator,protected_artist_names, genre_separator) {
79                // if we have metadata and the metadata is different from the song's metadata, and ...
80                Ok(metadata) if metadata != SongMetadata::from(&song) => {
81                    let log_postfix = if conflict_resolution_mode == MetadataConflictResolution::Skip {
82                        "but conflict resolution mode is \"skip\", so we do nothing"
83                    } else {
84                        "resolving conflict"
85                    };
86                    info!(
87                        "{} has conflicting metadata with index, {log_postfix}",
88                        path.display(),
89                    );
90
91                    match conflict_resolution_mode {
92                        // ... we are in "overwrite" mode, update the song's metadata
93                        MetadataConflictResolution::Overwrite => {
94                            // if the file has been modified, update the song's metadata
95                            Song::update(db, song.id.clone(), metadata.merge_with_song(&song)).await?;
96                        }
97                        // ... we are in "skip" mode, do nothing
98                        MetadataConflictResolution::Skip => {}
99                    }
100                }
101                // if we have an error, delete the song from the library
102                Err(e) => {
103                    warn!(
104                        "Error reading metadata for {}: {e}",
105                        path.display()
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.insert(path.path().to_owned()) {
138                continue;
139            }
140
141            let displayable_path = path.path().display();
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                protected_artist_names,
148                genre_separator,
149            ) {
150                Ok(metadata) => Song::try_load_into_db(db, metadata).await.map_or_else(
151                    |e| warn!("Error indexing {displayable_path}: {e}"),
152                    |_| debug!("Indexed {displayable_path}"),
153                ),
154                Err(e) => warn!("Error reading metadata for {displayable_path}: {e}"),
155            }
156        }
157
158        <Result<(), Error>>::Ok(())
159    }
160    .instrument(tracing::info_span!("Indexing new songs"))
161    .await?;
162
163    // find and delete any remaining orphaned albums and artists
164    macro_rules! delete_orphans {
165        ($model:ident, $db:expr) => {
166            let orphans = $model::delete_orphaned($db)
167                .instrument(tracing::info_span!(concat!(
168                    "Deleting orphaned ",
169                    stringify!($model)
170                )))
171                .await?;
172            if !orphans.is_empty() {
173                info!("Deleted orphaned {}: {orphans:?}", stringify!($model));
174            }
175        };
176    }
177
178    delete_orphans!(Album, db);
179    delete_orphans!(Artist, db);
180    delete_orphans!(Collection, db);
181    delete_orphans!(Playlist, db);
182
183    info!("Library rescan complete");
184    info!("Library health: {:?}", health(db).await?);
185
186    Ok(())
187}
188
189/// Analyze the library.
190///
191/// In order, this function will:
192/// - if `overwrite` is true, delete all existing analyses.
193/// - get all the songs that aren't currently analyzed.
194/// - start analyzing those songs in batches.
195/// - update the database with the analyses.
196///
197/// # Errors
198///
199/// This function will return an error if there is an error reading from the database.
200///
201/// # Panics
202///
203/// This function will panic if the thread(s) that analyzes the songs panics.
204#[instrument]
205#[inline]
206pub async fn analyze<C: Connection>(
207    db: &Surreal<C>,
208    mut interrupt: InterruptReceiver,
209    overwrite: bool,
210) -> Result<(), Error> {
211    if overwrite {
212        // delete all the analyses
213        async {
214            for analysis in Analysis::read_all(db).await? {
215                Analysis::delete(db, analysis.id.clone()).await?;
216            }
217            <Result<(), Error>>::Ok(())
218        }
219        .instrument(tracing::info_span!("Deleting existing analyses"))
220        .await?;
221    }
222
223    // get all the songs that don't have an analysis
224    let songs_to_analyze: Vec<Song> = Analysis::read_songs_without_analysis(db).await?;
225    // crate a hashmap mapping paths to song ids
226    let paths = songs_to_analyze
227        .iter()
228        .map(|song| (song.path.clone(), song.id.clone()))
229        .collect::<HashMap<_, _>>();
230
231    let keys = paths.keys().cloned().collect::<Vec<_>>();
232
233    let (tx, rx) = std::sync::mpsc::channel();
234
235    let Ok(decoder) = MecompDecoder::new() else {
236        error!("Error creating decoder");
237        return Ok(());
238    };
239
240    // analyze the songs in batches, this is a blocking operation
241    let handle = tokio::task::spawn_blocking(move || decoder.analyze_paths_with_callback(keys, tx));
242    let abort = handle.abort_handle();
243
244    async {
245        for (song_path, maybe_analysis) in rx {
246            if interrupt.is_stopped() {
247                info!("Analysis interrupted");
248                break;
249            }
250
251            let displayable_path = song_path.display();
252
253            let Some(song_id) = paths.get(&song_path) else {
254                error!("No song id found for path: {displayable_path}");
255                continue;
256            };
257
258            match maybe_analysis {
259                Ok(analysis) => Analysis::create(
260                    db,
261                    song_id.clone(),
262                    Analysis {
263                        id: Analysis::generate_id(),
264                        features: *analysis.inner(),
265                    },
266                )
267                .await?
268                .map_or_else(
269                    || {
270                        warn!(
271                        "Error analyzing {displayable_path}: song either wasn't found or already has an analysis"
272                    );
273                    },
274                    |_| debug!("Analyzed {displayable_path}"),
275                ),
276                Err(e) => {
277                    error!("Error analyzing {displayable_path}: {e}");
278                }
279            }
280        }
281
282        <Result<(), Error>>::Ok(())
283    }
284    .instrument(tracing::info_span!("Adding analyses to database"))
285    .await?;
286
287    tokio::select! {
288        // wait for the interrupt signal
289        _ = interrupt.wait() => {
290            info!("Analysis interrupted");
291            abort.abort();
292        }
293        // wait for the analysis to finish
294        result = handle => match result {
295            Ok(Ok(())) => {
296                info!("Analysis complete");
297                info!("Library health: {:?}", health(db).await?);
298            }
299            Ok(Err(e)) => {
300                error!("Error analyzing songs: {e}");
301            }
302            Err(e) => {
303                error!("Error joining task: {e}");
304            }
305        }
306    }
307
308    Ok(())
309}
310
311/// Recluster the library.
312///
313/// This function will remove and recompute all the "collections" (clusters) in the library.
314///
315/// # Errors
316///
317/// This function will return an error if there is an error reading from the database.
318#[instrument]
319#[inline]
320pub async fn recluster<C: Connection>(
321    db: &Surreal<C>,
322    settings: ReclusterSettings,
323    mut interrupt: InterruptReceiver,
324) -> Result<(), Error> {
325    // collect all the analyses
326    let samples = Analysis::read_all(db).await?;
327
328    if samples.is_empty() {
329        info!("No analyses found, nothing to recluster");
330        return Ok(());
331    }
332
333    let samples_ref = samples.clone();
334
335    // use clustering algorithm to cluster the analyses
336    let clustering = move || {
337        let model: ClusteringHelper<NotInitialized> = match ClusteringHelper::new(
338            samples_ref
339                .iter()
340                .map(Into::into)
341                .collect::<Vec<mecomp_analysis::Analysis>>()
342                .into(),
343            settings.max_clusters,
344            KOptimal::GapStatistic {
345                b: settings.gap_statistic_reference_datasets,
346            },
347            settings.algorithm.into(),
348            settings.projection_method.into(),
349        ) {
350            Err(e) => {
351                error!("There was an error creating the clustering helper: {e}",);
352                return None;
353            }
354            Ok(kmeans) => kmeans,
355        };
356
357        let model = match model.initialize() {
358            Err(e) => {
359                error!("There was an error initializing the clustering helper: {e}",);
360                return None;
361            }
362            Ok(kmeans) => kmeans.cluster(),
363        };
364
365        Some(model)
366    };
367
368    // use clustering algorithm to cluster the analyses
369    let handle = tokio::task::spawn_blocking(clustering)
370        .instrument(tracing::info_span!("Clustering library"));
371    let abort = handle.inner().abort_handle();
372
373    // wait for the clustering to finish
374    let model = tokio::select! {
375        _ = interrupt.wait() => {
376            info!("Reclustering interrupted");
377            abort.abort();
378            return Ok(());
379        }
380        result = handle => match result {
381            Ok(Some(model)) => model,
382            Ok(None) => {
383                return Ok(());
384            }
385            Err(e) => {
386                error!("Error joining task: {e}");
387                return Ok(());
388            }
389        }
390    };
391
392    // delete all the collections
393    async {
394        // 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")))"
395        // (this was happening with 892 songs in a collection)
396        for collection in Collection::read_all(db).await? {
397            Collection::delete(db, collection.id.clone()).await?;
398        }
399
400        <Result<(), Error>>::Ok(())
401    }
402    .instrument(tracing::info_span!("Deleting old collections"))
403    .await?;
404
405    // get the clusters from the clustering
406    async {
407        let clusters = model.extract_analysis_clusters(samples);
408
409        // create the collections
410        for (i, cluster) in clusters.iter().filter(|c| !c.is_empty()).enumerate() {
411            let collection = Collection::create(
412                db,
413                Collection {
414                    id: Collection::generate_id(),
415                    name: format!("Collection {i}"),
416                    runtime: Duration::default(),
417                    song_count: Default::default(),
418                },
419            )
420            .await?
421            .ok_or(Error::NotCreated)?;
422
423            let mut songs = Vec::with_capacity(cluster.len());
424
425            async {
426                for analysis in cluster {
427                    songs.push(Analysis::read_song(db, analysis.id.clone()).await?.id);
428                }
429
430                Collection::add_songs(db, collection.id.clone(), songs).await?;
431
432                <Result<(), Error>>::Ok(())
433            }
434            .instrument(tracing::info_span!("Adding songs to collection"))
435            .await?;
436        }
437        Ok::<(), Error>(())
438    }
439    .instrument(tracing::info_span!("Creating new collections"))
440    .await?;
441
442    info!("Library recluster complete");
443    info!("Library health: {:?}", health(db).await?);
444
445    Ok(())
446}
447
448/// Get a brief overview of the library.
449///
450/// # Errors
451///
452/// This function will return an error if there is an error reading from the database.
453#[instrument]
454#[inline]
455pub async fn brief<C: Connection>(db: &Surreal<C>) -> Result<LibraryBrief, Error> {
456    Ok(LibraryBrief {
457        artists: Artist::read_all_brief(db).await?.into_boxed_slice(),
458        albums: Album::read_all_brief(db).await?.into_boxed_slice(),
459        songs: Song::read_all_brief(db).await?.into_boxed_slice(),
460        playlists: Playlist::read_all_brief(db).await?.into_boxed_slice(),
461        collections: Collection::read_all_brief(db).await?.into_boxed_slice(),
462        dynamic_playlists: DynamicPlaylist::read_all(db).await?.into_boxed_slice(),
463    })
464}
465
466/// Get the full library.
467///
468/// # Errors
469///
470/// This function will return an error if there is an error reading from the database.
471#[instrument]
472#[inline]
473pub async fn full<C: Connection>(db: &Surreal<C>) -> Result<LibraryFull, Error> {
474    Ok(LibraryFull {
475        artists: Artist::read_all(db).await?.into(),
476        albums: Album::read_all(db).await?.into(),
477        songs: Song::read_all(db).await?.into(),
478        playlists: Playlist::read_all(db).await?.into(),
479        collections: Collection::read_all(db).await?.into(),
480        dynamic_playlists: DynamicPlaylist::read_all(db).await?.into(),
481    })
482}
483
484/// Get the health of the library.
485///
486/// This function will return the health of the library, including the number of orphaned items.
487///
488/// # Errors
489///
490/// This function will return an error if there is an error reading from the database.
491#[instrument]
492#[inline]
493pub async fn health<C: Connection>(db: &Surreal<C>) -> Result<LibraryHealth, Error> {
494    Ok(LibraryHealth {
495        artists: count_artists(db).await?,
496        albums: count_albums(db).await?,
497        songs: count_songs(db).await?,
498        unanalyzed_songs: Some(count_unanalyzed_songs(db).await?),
499        playlists: count_playlists(db).await?,
500        collections: count_collections(db).await?,
501        dynamic_playlists: count_dynamic_playlists(db).await?,
502        orphaned_artists: count_orphaned_artists(db).await?,
503        orphaned_albums: count_orphaned_albums(db).await?,
504        orphaned_playlists: count_orphaned_playlists(db).await?,
505        orphaned_collections: count_orphaned_collections(db).await?,
506    })
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::test_utils::init;
513
514    use mecomp_core::config::{ClusterAlgorithm, ProjectionMethod};
515    use mecomp_storage::db::schemas::song::{SongChangeSet, SongMetadata};
516    use mecomp_storage::test_utils::{
517        ARTIST_NAME_SEPARATOR, SongCase, arb_analysis_features, arb_song_case, arb_vec,
518        create_song_metadata, create_song_with_overrides, init_test_database,
519    };
520    use one_or_many::OneOrMany;
521    use pretty_assertions::assert_eq;
522    use rstest::rstest;
523
524    #[tokio::test]
525    #[allow(clippy::too_many_lines)]
526    async fn test_rescan() {
527        init();
528        let tempdir = tempfile::tempdir().unwrap();
529        let db = init_test_database().await.unwrap();
530
531        // populate the tempdir with songs that aren't in the database
532        let song_cases = arb_vec(&arb_song_case(), 10..=15)();
533        let metadatas = song_cases
534            .into_iter()
535            .map(|song_case| create_song_metadata(&tempdir, song_case))
536            .collect::<Result<Vec<_>, _>>()
537            .unwrap();
538        // also make some songs that are in the database
539        //  - a song that whose file was deleted
540        let song_with_nonexistent_path = create_song_with_overrides(
541            &db,
542            arb_song_case()(),
543            SongChangeSet {
544                path: Some(tempdir.path().join("nonexistent.mp3")),
545                ..Default::default()
546            },
547        )
548        .await
549        .unwrap();
550        let mut metadata_of_song_with_outdated_metadata =
551            create_song_metadata(&tempdir, arb_song_case()()).unwrap();
552        metadata_of_song_with_outdated_metadata.genre = OneOrMany::None;
553        let song_with_outdated_metadata =
554            Song::try_load_into_db(&db, metadata_of_song_with_outdated_metadata)
555                .await
556                .unwrap();
557        // also add a "song" that can't be read
558        let invalid_song_path = tempdir.path().join("invalid1.mp3");
559        std::fs::write(&invalid_song_path, "this is not a song").unwrap();
560        // add another invalid song, this time also put it in the database
561        let invalid_song_path = tempdir.path().join("invalid2.mp3");
562        std::fs::write(&invalid_song_path, "this is not a song").unwrap();
563        let song_with_invalid_metadata = create_song_with_overrides(
564            &db,
565            arb_song_case()(),
566            SongChangeSet {
567                path: Some(tempdir.path().join("invalid2.mp3")),
568                ..Default::default()
569            },
570        )
571        .await
572        .unwrap();
573
574        // rescan the library
575        rescan(
576            &db,
577            &[tempdir.path().to_owned()],
578            &ARTIST_NAME_SEPARATOR.to_string().into(),
579            &OneOrMany::None,
580            Some(ARTIST_NAME_SEPARATOR),
581            MetadataConflictResolution::Overwrite,
582        )
583        .await
584        .unwrap();
585
586        // check that everything was done correctly
587        // - `song_with_nonexistent_path` was deleted
588        assert_eq!(
589            Song::read(&db, song_with_nonexistent_path.id)
590                .await
591                .unwrap(),
592            None
593        );
594        // - `song_with_invalid_metadata` was deleted
595        assert_eq!(
596            Song::read(&db, song_with_invalid_metadata.id)
597                .await
598                .unwrap(),
599            None
600        );
601        // - `song_with_outdated_metadata` was updated
602        assert!(
603            Song::read(&db, song_with_outdated_metadata.id)
604                .await
605                .unwrap()
606                .unwrap()
607                .genre
608                .is_some()
609        );
610        // - all the other songs were added
611        //   and their artists, albums, and album_artists were added and linked correctly
612        for metadata in metadatas {
613            // the song was created
614            let song = Song::read_by_path(&db, metadata.path.clone())
615                .await
616                .unwrap();
617            assert!(song.is_some());
618            let song = song.unwrap();
619
620            // the song's metadata is correct
621            assert_eq!(SongMetadata::from(&song), metadata);
622
623            // the song's artists were created
624            let artists = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
625                .await
626                .unwrap();
627            assert_eq!(artists.len(), metadata.artist.len());
628            // the song is linked to the artists
629            for artist in &artists {
630                assert!(metadata.artist.contains(&artist.name));
631                assert!(
632                    Artist::read_songs(&db, artist.id.clone())
633                        .await
634                        .unwrap()
635                        .contains(&song)
636                );
637            }
638            // the artists are linked to the song
639            if let Ok(song_artists) = Song::read_artist(&db, song.id.clone()).await {
640                for artist in artists {
641                    assert!(song_artists.contains(&artist));
642                }
643            } else {
644                panic!("Error reading song artists");
645            }
646
647            // the song's album was created
648            let album = Album::read_by_name_and_album_artist(
649                &db,
650                &metadata.album,
651                metadata.album_artist.clone(),
652            )
653            .await
654            .unwrap();
655            assert!(album.is_some());
656            let album = album.unwrap();
657            // the song is linked to the album
658            assert_eq!(
659                Song::read_album(&db, song.id.clone()).await.unwrap(),
660                Some(album.clone())
661            );
662            // the album is linked to the song
663            assert!(
664                Album::read_songs(&db, album.id.clone())
665                    .await
666                    .unwrap()
667                    .contains(&song)
668            );
669
670            // the album's album artists were created
671            let album_artists =
672                Artist::read_by_names(&db, Vec::from(metadata.album_artist.clone()))
673                    .await
674                    .unwrap();
675            assert_eq!(album_artists.len(), metadata.album_artist.len());
676            // the album is linked to the album artists
677            for album_artist in album_artists {
678                assert!(metadata.album_artist.contains(&album_artist.name));
679                assert!(
680                    Artist::read_albums(&db, album_artist.id.clone())
681                        .await
682                        .unwrap()
683                        .contains(&album)
684                );
685            }
686        }
687    }
688
689    #[tokio::test]
690    async fn rescan_deletes_preexisting_orphans() {
691        init();
692        let tempdir = tempfile::tempdir().unwrap();
693        let db = init_test_database().await.unwrap();
694
695        // create a song with an artist and an album
696        let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
697        let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
698
699        // delete the song, leaving orphaned artist and album
700        std::fs::remove_file(&song.path).unwrap();
701        Song::delete(&db, (song.id.clone(), false)).await.unwrap();
702
703        // rescan the library
704        rescan(
705            &db,
706            &[tempdir.path().to_owned()],
707            &ARTIST_NAME_SEPARATOR.to_string().into(),
708            &OneOrMany::None,
709            Some(ARTIST_NAME_SEPARATOR),
710            MetadataConflictResolution::Overwrite,
711        )
712        .await
713        .unwrap();
714
715        // check that the album and artist deleted
716        assert_eq!(Song::read_all(&db).await.unwrap().len(), 0);
717        assert_eq!(Album::read_all(&db).await.unwrap().len(), 0);
718        let artists = Artist::read_all(&db).await.unwrap();
719        for artist in artists {
720            assert_eq!(artist.album_count, 0);
721            assert_eq!(artist.song_count, 0);
722        }
723        assert_eq!(Artist::read_all(&db).await.unwrap().len(), 0);
724    }
725
726    #[tokio::test]
727    async fn rescan_deletes_orphaned_albums_and_artists() {
728        init();
729        let tempdir = tempfile::tempdir().unwrap();
730        let db = init_test_database().await.unwrap();
731
732        // create a song with an artist and an album
733        let metadata = create_song_metadata(&tempdir, arb_song_case()()).unwrap();
734        let song = Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
735        let artist = Artist::read_by_names(&db, Vec::from(metadata.artist.clone()))
736            .await
737            .unwrap()
738            .pop()
739            .unwrap();
740        let album = Album::read_by_name_and_album_artist(
741            &db,
742            &metadata.album,
743            metadata.album_artist.clone(),
744        )
745        .await
746        .unwrap()
747        .unwrap();
748
749        // delete the song, leaving orphaned artist and album
750        std::fs::remove_file(&song.path).unwrap();
751
752        // rescan the library
753        rescan(
754            &db,
755            &[tempdir.path().to_owned()],
756            &ARTIST_NAME_SEPARATOR.to_string().into(),
757            &OneOrMany::None,
758            Some(ARTIST_NAME_SEPARATOR),
759            MetadataConflictResolution::Overwrite,
760        )
761        .await
762        .unwrap();
763
764        // check that the artist and album were deleted
765        assert_eq!(Artist::read(&db, artist.id.clone()).await.unwrap(), None);
766        assert_eq!(Album::read(&db, album.id.clone()).await.unwrap(), None);
767    }
768
769    #[tokio::test]
770    async fn test_analyze() {
771        init();
772        let dir = tempfile::tempdir().unwrap();
773        let db = init_test_database().await.unwrap();
774        let interrupt = InterruptReceiver::dummy();
775
776        // load some songs into the database
777        let song_cases = arb_vec(&arb_song_case(), 10..=15)();
778        let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
779            song: u8::try_from(i).unwrap(),
780            ..sc
781        });
782        let metadatas = song_cases
783            .into_iter()
784            .map(|song_case| create_song_metadata(&dir, song_case))
785            .collect::<Result<Vec<_>, _>>()
786            .unwrap();
787        for metadata in &metadatas {
788            Song::try_load_into_db(&db, metadata.clone()).await.unwrap();
789        }
790
791        // check that there are no analyses before.
792        assert_eq!(
793            Analysis::read_songs_without_analysis(&db)
794                .await
795                .unwrap()
796                .len(),
797            metadatas.len()
798        );
799
800        // analyze the library
801        analyze(&db, interrupt, true).await.unwrap();
802
803        // check that all the songs have analyses
804        assert_eq!(
805            Analysis::read_songs_without_analysis(&db)
806                .await
807                .unwrap()
808                .len(),
809            0
810        );
811        for metadata in &metadatas {
812            let song = Song::read_by_path(&db, metadata.path.clone())
813                .await
814                .unwrap()
815                .unwrap();
816            let analysis = Analysis::read_for_song(&db, song.id.clone()).await.unwrap();
817            assert!(analysis.is_some());
818        }
819
820        // check that if we ask for the nearest neighbors of one of these songs, we get all the other songs
821        for analysis in Analysis::read_all(&db).await.unwrap() {
822            let neighbors = Analysis::nearest_neighbors(&db, analysis.id.clone(), 100)
823                .await
824                .unwrap();
825            assert!(!neighbors.contains(&analysis));
826            assert_eq!(neighbors.len(), metadatas.len() - 1);
827            assert_eq!(
828                neighbors.len(),
829                neighbors
830                    .iter()
831                    .map(|n| n.id.clone())
832                    .collect::<HashSet<_>>()
833                    .len()
834            );
835        }
836    }
837
838    #[rstest]
839    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
840    async fn test_recluster(
841        #[values(ProjectionMethod::TSne, ProjectionMethod::None, ProjectionMethod::Pca)]
842        projection_method: ProjectionMethod,
843    ) {
844        init();
845        let dir = tempfile::tempdir().unwrap();
846        let db = init_test_database().await.unwrap();
847        let settings = ReclusterSettings {
848            gap_statistic_reference_datasets: 5,
849            max_clusters: 18,
850            algorithm: ClusterAlgorithm::GMM,
851            projection_method,
852        };
853
854        // load some songs into the database
855        let song_cases = arb_vec(&arb_song_case(), 32..=32)();
856        let song_cases = song_cases.into_iter().enumerate().map(|(i, sc)| SongCase {
857            song: u8::try_from(i).unwrap(),
858            ..sc
859        });
860        let metadatas = song_cases
861            .into_iter()
862            .map(|song_case| create_song_metadata(&dir, song_case))
863            .collect::<Result<Vec<_>, _>>()
864            .unwrap();
865        let mut songs = Vec::with_capacity(metadatas.len());
866        for metadata in &metadatas {
867            songs.push(Song::try_load_into_db(&db, metadata.clone()).await.unwrap());
868        }
869
870        // load some dummy analyses into the database
871        for song in &songs {
872            Analysis::create(
873                &db,
874                song.id.clone(),
875                Analysis {
876                    id: Analysis::generate_id(),
877                    features: arb_analysis_features()(),
878                },
879            )
880            .await
881            .unwrap();
882        }
883
884        // recluster the library
885        recluster(&db, settings, InterruptReceiver::dummy())
886            .await
887            .unwrap();
888
889        // check that there are collections
890        let collections = Collection::read_all(&db).await.unwrap();
891        assert!(!collections.is_empty());
892        for collection in collections {
893            let songs = Collection::read_songs(&db, collection.id.clone())
894                .await
895                .unwrap();
896            assert!(!songs.is_empty());
897        }
898    }
899
900    #[tokio::test]
901    async fn test_brief() {
902        init();
903        let db = init_test_database().await.unwrap();
904        let brief = brief(&db).await.unwrap();
905        assert_eq!(brief.artists, Box::default());
906        assert_eq!(brief.albums, Box::default());
907        assert_eq!(brief.songs, Box::default());
908        assert_eq!(brief.playlists, Box::default());
909        assert_eq!(brief.collections, Box::default());
910    }
911
912    #[tokio::test]
913    async fn test_full() {
914        init();
915        let db = init_test_database().await.unwrap();
916        let full = full(&db).await.unwrap();
917        assert_eq!(full.artists.len(), 0);
918        assert_eq!(full.albums.len(), 0);
919        assert_eq!(full.songs.len(), 0);
920        assert_eq!(full.playlists.len(), 0);
921        assert_eq!(full.collections.len(), 0);
922    }
923
924    #[tokio::test]
925    async fn test_health() {
926        init();
927        let db = init_test_database().await.unwrap();
928        let health = health(&db).await.unwrap();
929        assert_eq!(health.artists, 0);
930        assert_eq!(health.albums, 0);
931        assert_eq!(health.songs, 0);
932        assert_eq!(health.unanalyzed_songs, Some(0));
933        assert_eq!(health.playlists, 0);
934        assert_eq!(health.collections, 0);
935        assert_eq!(health.orphaned_artists, 0);
936        assert_eq!(health.orphaned_albums, 0);
937        assert_eq!(health.orphaned_playlists, 0);
938        assert_eq!(health.orphaned_collections, 0);
939    }
940}