selene-core 0.4.2

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

use barber::{ProgressBar, ProgressRenderer};
use lunar_lib::{
    database::{DatabaseEntry, DatabaseError, writer::DatabaseWriter},
    error,
};
use rayon::{
    ThreadPoolBuilder,
    iter::{IntoParallelIterator, ParallelIterator},
};

use crate::{
    VALID_LIBRARY_CODECS, VALID_LIBRARY_FORMATS,
    config::common::common_config,
    database::{LibraryDb, tx_extensions::CasTxExtensions},
    errors::{CodecError, ContainerError, ImportError, LibraryError},
    ffmpeg::{FfmpegPresets, ffmpeg, output_to_string},
    library::{album::Album, artist::Artist, track::Track},
    media_container::MediaContainer,
    utils::pair_extension,
};

#[derive(Debug, Clone)]
pub struct ExtractResult {
    pub track: Track,
    pub album: Option<Album>,
    pub artists: Vec<Artist>,
}

/// Optional flags when importing a track
#[derive(Debug, Clone, Copy)]
pub struct TrackImportOptions {
    pub dry: bool,
    pub apply_loudnorm: bool,
}

/// Scans the input `tracks` for tracks that need to be reimported
///
/// Tracks need to be reimported if:
/// - Their source file exists, but their library file does not
/// - Loudnorm config is input, but their loudnorm config does not match the input config
pub fn find_needs_import(loudnorm: bool) -> Result<Vec<Track>, DatabaseError> {
    let mut tracks = Track::db_get_all()?;
    let loudnorm_with = loudnorm.then_some(common_config().loudnorm);

    tracks.retain(|track| {
        let missing_lib = track.lib_container().is_none_or(|c| !c.path().is_file());
        let needs_loudnorm = loudnorm_with.is_some_and(|cfg| track.loudnorm() != Some(&cfg));

        missing_lib || needs_loudnorm
    });

    Ok(tracks)
}

/// Reimports the input tracks
///
/// # Errors
///
/// Errors if
pub fn reimport(
    tracks: Vec<Track>,
    progress_renderer: Arc<dyn ProgressRenderer>,
    options: TrackImportOptions,
) -> Result<(), ImportError> {
    if tracks.is_empty() {
        return Ok(());
    }

    let library_dir = common_config()
        .library_dir()
        .ok_or(LibraryError::NoLibrary)?
        .to_path_buf();

    for track in &tracks {
        track.ensure_parent_dirs(&library_dir)?;
    }

    let progress_bar = ProgressBar::new(0, tracks.len(), progress_renderer);
    progress_bar.set_label("Importing 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();

    // Initializes the common config before making threads compete to initialize it
    drop(common_config());

    let writer = DatabaseWriter::<LibraryDb>::spawn();

    thread_pool.install(|| {
        tracks
            .into_par_iter()
            .try_for_each(|track| -> Result<(), DatabaseError> {
                if writer.is_closed() {
                    return Ok(());
                }

                let import_to = library_dir.join(&track.relative_library_path);
                let track_path = track.relative_library_path.display().to_string();

                match import(track, import_to, options) {
                    Ok(track) => {
                        if writer.is_closed() {
                            return Ok(());
                        }

                        writer.transaction(move |cas_tx| cas_tx.tx_patch(track.clone()));
                        progress_bar.set_label(&format!("Imported '{track_path}'",));
                    }
                    Err(err) => {
                        let args = format_args!("Failed to import {track_path}: {err}");
                        error!("{args}");
                        progress_bar.set_label(&format!("{args}"));
                    }
                }

                progress_bar.increment();
                Ok(())
            })
    })?;

    writer.finish()?;

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

pub fn import(
    mut track: Track,
    to: impl AsRef<Path>,
    options: TrackImportOptions,
) -> Result<Track, ImportError> {
    let audio_source = track.src_container().path();

    let src_container = *track.src_container().container();
    let src_codec = *track.src_container().codec();

    let common_config = common_config();
    let transcode_target = common_config.transcode_to(src_container, src_codec);

    let (target_container, target_codec) = if let Some(transcode_target) = transcode_target {
        transcode_target.container_codec()
    } else {
        (src_container, src_codec)
    };

    if !VALID_LIBRARY_FORMATS.contains(&target_container) {
        return Err(ImportError::Container(
            ContainerError::UnsupportedContainer(target_container),
        ));
    }

    if !VALID_LIBRARY_CODECS.contains(&target_codec) {
        return Err(ImportError::Codec(CodecError::UnsupportedCodec(
            target_codec,
        )));
    }

    let mut command = ffmpeg();

    command.input_file(audio_source);

    if let Some(cover) = track.metadata.cover_art() {
        if cover.source() == audio_source {
            command.cover_art_from(0);
        } else {
            command.input_file(cover.source());
            command.cover_art_from(1);
        }
    }

    if let Some(transcode_target) = transcode_target {
        transcode_target.add_ffmpeg_args(&mut command);
    } else {
        command.set_container(target_container.format_name());
        if options.apply_loudnorm {
            command.set_codec(target_codec.ffmpeg_encoder()?);
        } else {
            command.copy_codec();
        }
    }

    if options.apply_loudnorm
        && let Some(loudnorm_analysis) = track.loudnorm_analysis
    {
        command.apply_loudnorm_filter(loudnorm_analysis);
    }

    command.map_audio_from(0);

    command.drop_subtitles();
    command.drop_metadata();

    let metadata = track.metadata_key_values(&target_container)?;
    command.add_metadata_group(metadata);

    let mut output_file = to.as_ref().as_os_str().to_owned();
    output_file.push(".");
    output_file.push(pair_extension(target_container, target_codec).unwrap());
    let output_file = PathBuf::from(output_file);
    command.output_file(&output_file);

    output_to_string(command, false)?;

    let opened_file = fs::File::open(&output_file)?;
    track.lib_container = Some(MediaContainer::from_file(opened_file, output_file)?);

    Ok(track)
}