selene-core 0.3.1

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{
    collections::{HashMap, HashSet},
    path::{Path, PathBuf},
    sync::Arc,
};

use barber::{ProgressBar, ProgressRenderer};
use lunar_lib::formatter::{FormatTable, format_str};
use rayon::{
    ThreadPoolBuilder,
    iter::{IntoParallelRefIterator, ParallelIterator},
};

use crate::{
    config::common::common_config,
    database::{DatabaseError, writer::db_sync_transaction},
    errors::ExtractError,
    ffmpeg::{ffprobe_format_tags, loudnorm::LoudnormAnalysis},
    library::{
        album::{Album, TrackReference, UNKNOWN_ALBUM},
        artist::{Artist, ArtistGroup, add_from_artists, extract_from_featuring},
        cover_art::{CoverArt, has_video_stream},
        import::ExtractResult,
        metadata::extract_date_str,
        track::{Track, TrackId, track_meta::TrackMeta},
    },
    media_container::MediaContainer,
    utils::{hash_file, pair_extension},
};

#[must_use]
pub fn scan_for_extract<'a>(
    known_tracks: &'a [TrackId],
    sources: &'a HashMap<TrackId, PathBuf>,
) -> Vec<&'a Path> {
    sources
        .iter()
        .filter_map(|(id, path)| {
            if known_tracks.contains(id) {
                None
            } else {
                Some(path.as_path())
            }
        })
        .collect()
}

pub fn extract(
    files: &[&Path],
    analyze_loudnorm: bool,
    progress_renderer: Arc<dyn ProgressRenderer>,
) -> Result<(), ExtractError> {
    if files.is_empty() {
        return Ok(());
    }

    let progress_bar = ProgressBar::new(0, files.len(), progress_renderer);
    progress_bar.set_label("Extracting metadata from files...");

    let max_threads = (rayon::current_num_threads() as f32 * 0.33).ceil() as usize;

    let thread_pool = ThreadPoolBuilder::new()
        .num_threads(max_threads)
        .build()
        .unwrap();

    thread_pool.install(|| {
        files
            .par_iter()
            .try_for_each(|source| -> Result<(), ExtractError> {
                let ExtractResult {
                    track,
                    album,
                    artists,
                } = extract_metadata(source, analyze_loudnorm)?;

                db_sync_transaction(
                    move |cas_tx| -> Result<(), DatabaseError> {
                        cas_tx.tx_patch(track.clone())?;

                        if let Some(album) = &album {
                            cas_tx.tx_patch(album.clone())?;
                        }

                        for artist in &artists {
                            cas_tx.tx_patch(artist.clone())?;
                        }

                        Ok(())
                    },
                    true,
                )?;

                progress_bar.set_label(&format!(
                    "Extracted metadata from '{path}'",
                    path = source.display()
                ));
                progress_bar.increment();

                Ok(())
            })
    })?;

    progress_bar.flush();
    Ok(())
}

/// Analyzes and extracts the input file metadata into an [`ExtractResult`]
pub fn extract_metadata(
    source_file: impl AsRef<Path>,
    loudnorm: bool,
) -> Result<ExtractResult, ExtractError> {
    let source_file = source_file.as_ref();

    let src_container = MediaContainer::from_file(source_file.to_path_buf())?;
    let (container, codec) = src_container
        .transcode_to()
        .ok_or(ExtractError::InvalidContainer)?;

    let raw_metadata = ffprobe_format_tags(source_file)?;

    let mut all_artists = HashSet::new();
    let mut track_artists = raw_metadata.extract_track_artists();

    let (track_num, track_total) = raw_metadata.extract_track_num();
    let (disc_num, disc_total) = raw_metadata.extract_disc_num();
    let date = raw_metadata.date.as_deref().and_then(extract_date_str);
    let genre = raw_metadata.genre.clone();
    let lyric_data = raw_metadata.extract_lyric_data();

    let mut album = {
        let mut album_artists = raw_metadata.extract_album_artists();

        // Returns true if the album name != track name
        let album_name_differs = raw_metadata
            .album
            .as_deref()
            .zip(raw_metadata.title.as_deref())
            .is_none_or(|(a, b)| a != b);

        // Returns true if the album artists are not the same as the track artists
        let album_artist_differs = album_artists != track_artists;

        // Returns true if the track/disc value or total is greater is some and is greater than 1
        let multiple_tracks_or_discs = {
            track_num.is_some_and(|v| v > 1)
                || track_total.is_some_and(|v| v > 1)
                || disc_num.is_some_and(|v| v > 1)
                || disc_total.is_some_and(|v| v > 1)
        };

        if album_name_differs || multiple_tracks_or_discs || album_artist_differs {
            let mut album = Album::new(
                raw_metadata.album.unwrap_or(UNKNOWN_ALBUM.to_owned()),
                ArtistGroup::from_artists(&album_artists),
                Vec::new(),
            );
            for artist in &mut album_artists {
                artist.albums.push(album.id());
            }

            all_artists.extend(album_artists);
            album.date = date;
            album.track_total = track_total;
            album.disc_total = disc_total;
            album.genre = genre.clone();
            Some(album)
        } else {
            None
        }
    };

    let title = if let Some(title) = raw_metadata.title {
        let (title, other_artists) = extract_from_featuring(&title);

        track_artists.extend(other_artists);
        Some(title.to_string())
    } else {
        None
    };

    let cover_art = has_video_stream(source_file)
        .then_some(CoverArt::from_file(source_file))
        .transpose()?;

    let metadata = TrackMeta {
        album: album.as_ref().map(Album::id),
        artists: ArtistGroup::from_artists(&track_artists),
        date,
        genre,
        lyric_data,
        other: raw_metadata.other,
        title,
        cover_art,
    };

    let relative_path = {
        let mut format_table = FormatTable::new();
        format_table.extend_from_taggable(&metadata);
        add_from_artists(&mut format_table, &track_artists, "track");
        if let Some(album) = &album {
            format_table.extend_from_taggable(album);
        }

        let mut path = format_str(
            &common_config().track_name_config.format_string,
            &format_table,
        )?;

        path.push('.');
        path.push_str(pair_extension(&container, &codec).unwrap());
        PathBuf::from(path)
    };

    let mut track = Track::new(
        hash_file(source_file)?,
        src_container,
        metadata,
        relative_path,
    );

    if let Some(album) = &mut album {
        album.tracks.push(TrackReference {
            id: track.id(),
            track_num,
            disc_num,
        });
    }

    if loudnorm {
        track.loudnorm_analysis = Some(LoudnormAnalysis::from_file(source_file)?);
    }

    all_artists.extend(track_artists.clone());
    let mut all_artists: Vec<Artist> = all_artists.into_iter().collect();

    for artist in &mut all_artists {
        if album
            .as_ref()
            .is_none_or(|a| !a.artist_group.artist_ids().contains(&artist.id()))
        {
            artist.tracks.push(track.id());
        }
    }

    let result = ExtractResult {
        track,
        album,
        artists: all_artists,
    };

    Ok(result)
}