selene-core 0.8.0

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

use barber::{ProgressBar, ProgressRenderer};
use lunar_lib::iterator_ext::IteratorExtensions;

use crate::{
    database::album_add_track,
    library::{
        LinkingError,
        album::{Album, AlbumId},
        image_art::ImageArt,
        track::Track,
    },
};

pub(crate) fn album_track_linking(
    progress_renderer: Arc<dyn ProgressRenderer + 'static>,
    all_albums: &mut HashMap<AlbumId, (Album, bool)>,
    track_groups: &mut HashMap<PathBuf, Vec<(&mut Track, &mut bool)>>,
) -> Result<(), LinkingError> {
    let album_map = build_folder_album_map(&track_groups);
    let progress_bar = ProgressBar::new(0, album_map.len(), progress_renderer);
    Ok(for (directory, album_id) in &album_map {
        let (album, album_changed) = all_albums.get_mut(album_id).unwrap();
        progress_bar.set_label(&format!("Linking album data for '{}'", album.name()));
        progress_bar.increment();

        let group = track_groups.get_mut(directory).unwrap();

        if album.art.is_none() {
            album.art = art_from_dir(directory, group.len()).ok().flatten();
        }
        *album_changed = true;

        for (track, track_changed) in group {
            album_add_track(album, track.id());
            **track_changed = true;

            track.metadata.album = Some(*album_id)
        }
    })
}

pub fn build_folder_album_map(
    track_groups: &HashMap<PathBuf, Vec<(&mut Track, &mut bool)>>,
) -> HashMap<PathBuf, AlbumId> {
    let mut map = HashMap::new();

    for (parent, group) in track_groups {
        let Some(first_album) = group.iter().find_map(|(track, _)| track.metadata().album) else {
            continue;
        };

        if group
            .iter()
            .all(|(track, _)| track.metadata().album.is_none_or(|a| a == first_album))
        {
            map.insert(parent.to_owned(), first_album);
        }
    }

    let mut value_counts: HashMap<AlbumId, usize> = HashMap::new();
    for album_id in map.values() {
        *value_counts.entry(*album_id).or_insert(0) += 1;
    }
    map.retain(|_, album_id| value_counts[album_id] == 1);

    map
}

fn art_from_dir(dir: impl AsRef<Path>, expected_file_count: usize) -> io::Result<Option<ImageArt>> {
    const COVER_LOOKUP_EXT: [&str; 3] = ["png", "jpeg", "jpg"];

    let read_dir = fs::read_dir(dir.as_ref())?.flatten().to_vec();

    if read_dir.len() != expected_file_count && read_dir.len() != expected_file_count + 1 {
        return Ok(None);
    }

    let mut image = None;
    for entry in read_dir {
        if !entry.file_type().is_ok_and(|t| t.is_file()) {
            continue;
        }

        let path = entry.path();

        if path
            .extension()
            .and_then(std::ffi::OsStr::to_str)
            .is_some_and(|ext| COVER_LOOKUP_EXT.contains(&ext))
            && let Ok(image_art) = ImageArt::from_file(path)
        {
            image = Some(image_art);
        }
    }

    Ok(image)
}