selene-core 0.6.0

selene-core is the backend for Selene, a local-first music player
Documentation
use std::{borrow::Cow, collections::HashSet, ops::Deref, str::FromStr, sync::LazyLock};

use crate::{
    config::common_config,
    database::{LibraryDb, Patchable, patch_vec},
    library::{
        album::{Album, AlbumId},
        image_art::ImageArt,
        track::{Track, TrackId},
    },
};
use blake3::{Hash, hash};
use lunar_lib::{
    database::{
        CompareAndSwapTransaction, DatabaseEntry, DatabaseError, EntryId, TransactionError,
    },
    formatter::FormatTable,
    paths::sys::sanitize_str,
};
use regex::Regex;
use serde::{Deserialize, Serialize};

pub mod accessors;

pub mod frontend_impls;
pub mod trait_impls;

pub const UNKNOWN_ARTIST: &str = "UNKNOWN ARTIST";

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ArtistId {
    id: Hash,
}

impl EntryId for ArtistId {
    type Entry = Artist;
    type IdDb = LibraryDb;
}

impl Deref for ArtistId {
    type Target = [u8; 32];

    fn deref(&self) -> &Self::Target {
        self.id.as_bytes()
    }
}

impl ArtistId {
    fn new(name: &str) -> Self {
        Self {
            id: hash(name.to_ascii_lowercase().as_bytes()),
        }
    }

    #[must_use]
    pub fn to_selene_id(&self) -> String {
        format!("artist:{}", self.id)
    }
}

impl FromStr for ArtistId {
    type Err = <Hash as FromStr>::Err;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self {
            id: Hash::from_str(s)?,
        })
    }
}

impl<T> From<T> for ArtistId
where
    T: AsRef<str>,
{
    fn from(value: T) -> Self {
        Self::new(value.as_ref())
    }
}

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

    pub cover_art: Option<ImageArt>,
    pub description: Option<String>,

    pub(crate) tracks: Vec<TrackId>,
    pub(crate) albums: Vec<AlbumId>,

    version: usize,
}

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
    #[must_use]
    pub fn new(name: impl AsRef<str>) -> Option<Self> {
        let name = name.as_ref().trim();
        if name.is_empty() {
            return None;
        }

        Some(Self {
            version: 1,
            id: ArtistId::new(name),
            name: name.to_owned(),
            description: None,
            cover_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
    }

    pub fn albums(&self, db: &LibraryDb) -> Result<Vec<Album>, DatabaseError> {
        Album::db_get_batch_from(&self.albums, db)
    }

    pub fn all_tracks(&self, db: &LibraryDb) -> Result<Vec<Track>, DatabaseError> {
        Track::db_get_batch_from(&self.tracks, db)
    }

    fn tx_albums(
        &self,
        cas_tx: &CompareAndSwapTransaction<LibraryDb>,
    ) -> Result<Vec<Album>, TransactionError> {
        cas_tx.tx_get_batch(&self.albums)
    }

    #[must_use]
    pub fn albums_raw(&self) -> &[AlbumId] {
        &self.albums
    }
}

pub fn add_from_artists(format_table: &mut FormatTable, artists: &[Artist], artist_type: &str) {
    if artists.is_empty() {
        return;
    }

    let (main_sep, alt_sep) = {
        let common_config = common_config();
        (
            common_config.track_name.artist_separator.clone(),
            common_config.track_name.alt_artist_separator.clone(),
        )
    };

    let names: Vec<String> = artists.iter().map(|a| sanitize_str(a.name())).collect();

    let main = &names[0];
    format_table.add_entry(format!("main_{artist_type}_artist"), main);

    if names.len() == 1 {
        format_table.add_entry(format!("all_{artist_type}_artists"), &names[0]);
    } else {
        let all = format!(
            "{}{alt_sep}{}",
            names[..names.len() - 1].join(&main_sep),
            names.last().unwrap(),
        );

        format_table.add_entry(format!("all_{artist_type}_artists"), all);
    }

    let featuring = &names[1..];
    match featuring.len() {
        0 => (),
        1 => format_table.add_entry(format!("feat_{artist_type}_artists"), &featuring[0]),
        _ => {
            let feat = format!(
                "{}{alt_sep}{}",
                featuring[..featuring.len() - 1].join(&main_sep),
                featuring.last().unwrap(),
            );

            format_table.add_entry(format!("feat_{artist_type}_artists"), feat);
        }
    }
}

#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
pub struct ArtistGroup {
    artists: Vec<ArtistId>,
}

impl ArtistGroup {
    #[must_use]
    pub fn artist_ids(&self) -> &[ArtistId] {
        &self.artists
    }

    pub fn artists(&self, db: &LibraryDb) -> Result<Vec<Artist>, DatabaseError> {
        Artist::db_get_batch_from(&self.artists, db)
    }

    #[must_use]
    pub fn main_artist_id(&self) -> Option<ArtistId> {
        self.artists.first().copied()
    }

    pub fn main_artist(&self, db: &LibraryDb) -> Result<Option<Artist>, DatabaseError> {
        if let Some(first) = self.artists.first() {
            let artist = Artist::db_get_from(*first, db)?
                .expect("ArtistGroup main artist did not exist in the database");
            Ok(Some(artist))
        } else {
            Ok(None)
        }
    }

    #[must_use]
    pub fn featuring_artist_ids(&self) -> &[ArtistId] {
        self.artists.get(1..).unwrap_or_default()
    }

    pub fn featuring_artists(&self) -> Result<Vec<Artist>, DatabaseError> {
        Artist::db_get_batch(self.featuring_artist_ids())
    }

    pub fn add_artist(&mut self, artist: ArtistId) {
        if !self.artists.contains(&artist) {
            self.artists.push(artist);
        }
    }

    pub fn add_artists(&mut self, artists: impl IntoIterator<Item = ArtistId>) {
        artists.into_iter().for_each(|artist| {
            self.add_artist(artist);
        });
    }

    pub fn remove_artist(&mut self, artist: ArtistId) {
        self.artists.retain(|a| *a != artist);
    }

    pub fn remove_artists(&mut self, artists: impl IntoIterator<Item = ArtistId>) {
        let artists: Vec<ArtistId> = artists.into_iter().collect();
        self.artists.retain(|a| !artists.contains(a));
    }

    pub fn from_artist_ids<I>(value: I) -> ArtistGroup
    where
        I: IntoIterator<Item = ArtistId>,
    {
        Self {
            artists: value
                .into_iter()
                .collect::<HashSet<_>>()
                .into_iter()
                .collect(),
        }
    }

    pub fn from_artists<'a, I>(value: I) -> ArtistGroup
    where
        I: IntoIterator<Item = &'a Artist>,
    {
        let mut seen = HashSet::new();
        Self {
            artists: value
                .into_iter()
                .map(Artist::id)
                .filter(|id| seen.insert(*id))
                .collect(),
        }
    }

    pub(crate) fn from_artists_unchecked<'a, I>(value: I) -> ArtistGroup
    where
        I: IntoIterator<Item = &'a Artist>,
    {
        Self {
            artists: value.into_iter().map(Artist::id).collect(),
        }
    }

    #[must_use]
    pub fn new() -> Self {
        Self {
            artists: Vec::new(),
        }
    }
}

impl std::ops::Deref for ArtistGroup {
    type Target = [ArtistId];

    fn deref(&self) -> &Self::Target {
        &self.artists
    }
}

impl Patchable<ArtistGroup> for ArtistGroup {
    fn patch(&mut self, patch: ArtistGroup) {
        patch_vec(&mut self.artists, patch.to_vec());
    }
}

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

pub fn artists_from_string(artists: impl AsRef<str>) -> Vec<Artist> {
    ARTIST_SPLIT_REGEX
        .split(artists.as_ref())
        .map(str::trim)
        .filter_map(Artist::new)
        .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<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)
}