selene-core 0.7.1

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

use crate::{
    database::{LibraryDb, Resolveable},
    library::{
        album::{Album, AlbumId},
        image_art::ImageArt,
        track::{Track, TrackId},
    },
};
use blake3::{Hash, hash};
use lunar_lib::{
    database::{
        CompareAndSwapTransaction, DatabaseEntry, EntryId, TransactionError, caching::Cacheable,
    },
    formatter::FormatTable,
    iterator_ext::IteratorExtensions,
    paths::sys::sanitize_str,
};
use regex::Regex;
use serde::{Deserialize, Serialize};

pub mod accessors;

pub mod frontend_impls;
pub mod trait_impls;

mod artist_group;
pub use artist_group::*;

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

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ArtistId(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.0.as_bytes()
    }
}

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

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

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

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self(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>,
}

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 {
            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>, TransactionError> {
        Album::db_get_batch(&self.albums, db)
    }

    pub fn albums_cache(&self, db: &LibraryDb) -> Result<Vec<Arc<Album>>, TransactionError> {
        Album::cache_get_batch_from(&self.albums, db)
    }

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

    pub fn all_tracks_cache(&self, db: &LibraryDb) -> Result<Vec<Arc<Track>>, TransactionError> {
        Track::cache_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<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<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)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedArtist {
    pub album: Arc<Artist>,

    pub tracks: Vec<(Arc<Track>, Vec<Arc<Artist>>)>,
    pub albums: Vec<(Arc<Album>, Vec<Arc<Artist>>)>,
}

impl Deref for ResolvedArtist {
    type Target = Artist;

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

impl Resolveable for Artist {
    type Resolved = ResolvedArtist;

    fn resolve(artist: Arc<Self>, db: &Self::Db) -> Result<Self::Resolved, TransactionError> {
        let tracks = artist.all_tracks_cache(db)?;
        let track_artists = tracks
            .iter()
            .try_map(|t| t.metadata.artists_cache(db))?
            .to_vec();

        let albums = artist.albums_cache(db)?;
        let album_artists = albums.iter().try_map(|a| a.artists_cache(db))?.to_vec();

        Ok(ResolvedArtist {
            album: artist,
            tracks: tracks.into_iter().zip(track_artists).collect(),
            albums: albums.into_iter().zip(album_artists).collect(),
        })
    }
}