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