selene-core 0.7.1

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

use barber::{ProgressBar, ProgressRenderer};
use image::ImageError;
use lunar_lib::{
    database::{DatabaseEntry, DbHandle, TransactionError, db_transaction},
    iterator_ext::IteratorExtensions,
    paths::sys::sanitize_str,
};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use thiserror::Error;

use crate::{
    data_dir,
    database::LibraryDb,
    library::{
        album::{Album, AlbumId},
        image_art::{CacheableArt, ImageArt},
        track::{Track, TrackId},
    },
    lyrics::synced_lyrics::LyricParseError,
};

use super::track::lyric_data::LyricData;

#[derive(Debug, Error)]
pub enum LinkingError {
    #[error("IoError: {0}")]
    Io(#[from] std::io::Error),

    #[error("ImageError: {0}")]
    Image(#[from] ImageError),

    #[error("Transaction Error: {0}")]
    Transaction(#[from] TransactionError),

    #[error("LyricParse Error: {0}")]
    LyricParse(#[from] LyricParseError),
}

pub fn smart_link(
    progress_renderer: Arc<dyn ProgressRenderer>,
    db: DbHandle<LibraryDb>,
) -> Result<(), LinkingError> {
    let mut all_albums: HashMap<AlbumId, Album> = Album::db_get_all(&db)?
        .into_iter()
        .map(|a| (a.id(), a))
        .collect();
    let mut all_tracks: HashMap<TrackId, Track> = Track::db_get_all(&db)?
        .into_iter()
        .map(|t| (t.id(), t))
        .collect();

    let no_art = all_albums
        .iter_mut()
        .filter_map(|(_, a)| {
            if a.art.as_ref().is_none_or(|a| !a.source().exists()) {
                Some(a)
            } else {
                None
            }
        })
        .to_vec();

    find_album_art(no_art, &all_tracks, progress_renderer.clone())?;
    find_lyrics(all_tracks.values_mut(), progress_renderer)?;

    db_transaction(
        |cas_tx| {
            for track in all_tracks.values() {
                cas_tx.tx_upsert(track.clone())?;
            }
            for album in all_albums.values() {
                cas_tx.tx_upsert(album.clone())?;
            }
            Ok(())
        },
        db,
        false,
    )
    .map_err(TransactionError::from)?;

    Ok(())
}

fn find_lyrics<'a>(
    tracks: impl IntoIterator<Item = &'a mut Track> + ExactSizeIterator,
    progress_renderer: Arc<dyn ProgressRenderer + 'static>,
) -> Result<(), LinkingError> {
    let has_lyric_file = tracks
        .into_iter()
        .filter_map(|track| {
            let mut lrc_path = track.container().path().to_path_buf();
            lrc_path.set_extension("slrc");
            if lrc_path.exists() {
                return Some((track, lrc_path));
            }

            lrc_path.set_extension("lrc");
            if lrc_path.exists() {
                return Some((track, lrc_path));
            }

            None
        })
        .to_vec();

    let progress_bar = ProgressBar::new(0, has_lyric_file.len(), progress_renderer);
    for (track, lrc_file) in has_lyric_file {
        let string = fs::read_to_string(lrc_file)?;
        track.metadata.lyric_data = Some(LyricData::infer_from_string(string)?);
        progress_bar.set_label(&format!(
            "Linked lyric files for {}",
            track.metadata.safe_title()
        ));
        progress_bar.increment();
    }

    Ok(())
}

fn find_album_art(
    albums: Vec<&mut Album>,
    tracks: &HashMap<TrackId, Track>,
    progress_renderer: Arc<dyn ProgressRenderer>,
) -> Result<(), ImageError> {
    let progress_bar = ProgressBar::new(0, albums.len(), progress_renderer);
    albums
        .into_par_iter()
        .try_for_each(|album| -> Result<(), ImageError> {
            if album.tracks.is_empty() {
                return Ok(());
            }

            let album_tracks: Vec<&Track> = album
                .tracks
                .iter()
                .map(|tr| tracks.get(&tr.id).expect("Dangling pointer"))
                .collect();

            let parent_dir = album_tracks[0]
                .container()
                .path()
                .parent()
                .expect("Files must have parents");

            let all_same_parent = album_tracks
                .iter()
                .all(|t| t.container().path().parent() == Some(parent_dir));

            if all_same_parent
                && let Some(image_art) = art_from_dir(parent_dir, album.tracks.len())?
            {
                album.art = Some(image_art);
                progress_bar.set_label(&format!("Found cover art file for {}", album.name));
                progress_bar.increment();

                return Ok(());
            }

            if let Some(cover_art) = &album_tracks[0].metadata().art
                && album_tracks.iter().all(|t| {
                    t.metadata()
                        .art
                        .as_ref()
                        .is_some_and(|a| a.hash() == cover_art.hash())
                })
            {
                let export_path =
                    data_dir().join(format!("album_art/{}", sanitize_str(&album.name)));
                album.art = Some(cover_art.export_to_image_art(&export_path)?);

                progress_bar.set_label(&format!("Assumed cover art for {}", album.name));
                progress_bar.increment();

                return Ok(());
            }

            progress_bar.set_label(&format!("Couldn't find cover art for {}", album.name));
            progress_bar.increment();

            Ok(())
        })?;

    progress_bar.flush();

    Ok(())
}

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

    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)
}