selene-core 0.9.0-alpha.2

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{
    borrow::{Borrow, Cow},
    sync::LazyLock,
};

use crate::{
    SeleneIdExt,
    library::{Entry, EntryExt, Id, album::Album, image_art::ImageArt, track::Track},
};
use lunar_lib::{
    formatter::FormatTable, iterator_ext::IteratorExtensions, paths::sys::sanitize_str,
};
use regex::Regex;
use serde::{Deserialize, Serialize};

mod core_impls;
#[cfg(feature = "database-impls")]
mod database_impls;
mod trait_impls;

mod artist_group;
pub use artist_group::*;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artist {
    name: String,

    pub art: Option<Id<ImageArt>>,
    pub description: Option<String>,

    /// The tracks belonging to this artist
    ///
    /// # Database Safety
    ///
    /// When changing this value:
    /// - The removed tracks should have its reference to this artist removed
    /// - The added tracks need to have a pointer to this artist added
    pub tracks: Vec<Id<Track>>,

    /// The albums belonging to this artist
    ///
    /// # Database Safety
    ///
    /// When changing this value:
    /// - The removed albums should have its reference to this artist removed
    /// - The added albums need to have a pointer to this artist added
    pub albums: Vec<Id<Album>>,
}

impl Artist {
    pub const UNKNOWN: &str = "UNKNOWN ARTIST";
}

impl Artist {
    /// Creates a new artist with the input name
    ///
    /// # Errors
    ///
    /// This function will return `None` if the input name is empty or is trimmed to an empty string
    pub fn new(name: impl AsRef<str>) -> Result<Self, ArtistCreationError> {
        let name = name.as_ref().trim();
        if name.is_empty() {
            return Err(ArtistCreationError::EmptyName);
        }

        Ok(Self {
            name: name.to_owned(),
            description: None,
            art: None,
            tracks: Vec::new(),
            albums: Vec::new(),
        })
    }

    /// Sets the name of the artist
    ///
    /// # Errors
    ///
    /// Returns `false` if the input name is empty, or is trimmed to an empty string
    pub fn set_name(&mut self, name: impl AsRef<str>) -> bool {
        let name = name.as_ref().trim();
        if name.is_empty() {
            return false;
        }

        name.clone_into(&mut self.name);
        true
    }

    #[must_use]
    pub fn albums(&self) -> &[Id<Album>] {
        &self.albums
    }

    #[must_use]
    pub fn tracks(&self) -> &[Id<Track>] {
        &self.tracks
    }
}

pub fn add_from_artists<I, A>(
    format_table: &mut FormatTable,
    artists: I,
    artist_type: &str,
    main_sep: &str,
    alt_sep: &str,
) where
    I: IntoIterator<Item = A>,
    A: Borrow<Artist>,
{
    let names = artists
        .into_iter()
        .map(|a| sanitize_str(a.borrow().name()))
        .to_vec();

    if names.is_empty() {
        return;
    }

    match names.as_slice() {
        [] => unreachable!(),
        [main] => {
            format_table.add_entry(format!("main_{artist_type}_artist"), main);
            format_table.add_entry(format!("all_{artist_type}_artists"), main);
        }
        [main, duo] => {
            format_table.add_entry(format!("main_{artist_type}_artist"), main);
            format_table.add_entry(
                format!("all_{artist_type}_artists"),
                format!("{main}{alt_sep}{duo}"),
            );
            format_table.add_entry(format!("feat_{artist_type}_artists"), duo);
        }
        [main, many @ .., last] => {
            format_table.add_entry(format!("main_{artist_type}_artist"), main);

            let many = many.join(main_sep);
            format_table.add_entry(
                format!("all_{artist_type}_artists"),
                format!("{main}{main_sep}{many}{alt_sep}{last}"),
            );
            format_table.add_entry(
                format!("feat_{artist_type}_artists"),
                format!("{many}{alt_sep}{last}"),
            );
        }
    }
}

static ARTIST_SPLIT_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?i)\s*(?:,|;|\band\b|\bfeat\.?|\bft\.?|w/)\s*").unwrap());

pub fn artists_from_string(artists: impl AsRef<str>) -> Vec<Entry<Artist>> {
    ARTIST_SPLIT_REGEX
        .split(artists.as_ref())
        .map(str::trim)
        .filter_map(|name| {
            Artist::new(name)
                .ok()
                .map(|a| a.to_entry(Id::from_string_hash(name)))
        })
        .collect()
}

static ARTIST_FEATURING_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?i)\((?:feat|ft|featuring|with)(?:\.|:)?\s+([^)]*?)\)").unwrap()
});

#[must_use]
pub fn extract_from_featuring(str: &str) -> (Cow<'_, str>, Vec<Entry<Artist>>) {
    let Some(artists) = ARTIST_FEATURING_REGEX.captures(str) else {
        return (Cow::Borrowed(str.trim()), Vec::new());
    };

    let returned_str = ARTIST_FEATURING_REGEX.replace(str, "").trim().to_owned();
    let artists = artists_from_string(artists.get(1).unwrap().as_str());

    (Cow::Owned(returned_str), artists)
}

#[derive(Debug, Clone)]
pub struct ArtistCreateArgs {
    pub name: String,
    pub albums: Vec<Id<Album>>,
    pub tracks: Vec<Id<Track>>,
}

impl ArtistCreateArgs {
    #[must_use]
    pub fn new(name: String, albums: Vec<Id<Album>>, tracks: Vec<Id<Track>>) -> Self {
        Self {
            name,
            albums,
            tracks,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ArtistCreationError {
    #[error("Artist name resolved to an empty string")]
    EmptyName,
}