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