selene-core 0.3.1

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

use blake3::Hash;
use lunar_lib::formatter::{FormatError, FormatTable, format_str};
use thiserror::Error;

use crate::{
    config::common::{LoudnormConfig, common_config},
    database::{CompareAndSwapTransaction, DatabaseEntry, DatabaseError},
    errors::{LibraryError, MetadataError},
    library::{
        album::Album,
        artist::{Artist, ArtistGroup, add_from_artists},
        metadata::{
            ALBUM_ARTIST_KEY, ALBUM_KEY, ARTIST_KEY, DATE_KEY, DISC_NUM_KEY, GENRE_KEY,
            MetadataKey, TITLE_KEY, TRACK_NUM_KEY,
        },
        track::{
            Track, TrackId,
            lyric_data::LyricData,
            track_meta::{TrackAlbumInfo, TrackMeta},
        },
    },
    media_container::MediaContainer,
    utils::pair_extension,
};

// Core
impl Track {
    #[must_use]
    pub fn new(
        hash: Hash,
        src_container: MediaContainer,
        metadata: TrackMeta,
        relative_path: PathBuf,
    ) -> Self {
        Self {
            id: TrackId::new(hash),
            src_container,
            lib_container: None,
            relative_library_path: relative_path,
            metadata,
            loudnorm_analysis: None,
            applied_loudnorm: None,
            version: Track::VERSION_NUMBER,
        }
    }
}

// Accessors
impl Track {
    #[must_use]
    pub fn id(&self) -> TrackId {
        self.id
    }

    #[must_use]
    pub fn loudnorm(&self) -> Option<&LoudnormConfig> {
        self.applied_loudnorm.as_ref()
    }

    #[must_use]
    pub fn src_container(&self) -> &MediaContainer {
        &self.src_container
    }

    #[must_use]
    pub fn lib_container(&self) -> Option<&MediaContainer> {
        self.lib_container.as_ref()
    }

    pub fn album(&self) -> Result<Option<TrackAlbumInfo>, DatabaseError> {
        if let Some(album_id) = self.metadata.album {
            Ok(Some({
                let album = Album::db_get(album_id)?.expect("Dangling album reference");

                let reference = album
                    .track_refs()
                    .iter()
                    .find(|t| t.id == self.id)
                    .expect("Track not found in album");

                let track_num = reference.track_num;
                let disc_num = reference.disc_num;

                TrackAlbumInfo {
                    album,
                    track_num,
                    disc_num,
                }
            }))
        } else {
            Ok(None)
        }
    }

    pub fn tx_album(
        &self,
        cas_tx: &mut CompareAndSwapTransaction,
    ) -> Result<Option<TrackAlbumInfo>, DatabaseError> {
        if let Some(album_id) = self.metadata.album {
            Ok(Some({
                let album = cas_tx.tx_get(album_id)?.expect("Dangling album reference");

                let reference = album
                    .track_refs()
                    .iter()
                    .find(|t| t.id == self.id)
                    .expect("Dangling album>track reference");

                let track_num = reference.track_num;
                let disc_num = reference.disc_num;

                TrackAlbumInfo {
                    album,
                    track_num,
                    disc_num,
                }
            }))
        } else {
            Ok(None)
        }
    }
}

// Mutators
impl Track {
    pub fn metadata_key_values(&self) -> Result<HashMap<String, String>, DatabaseError> {
        let mut map = HashMap::new();

        if let Some(track_album_info) = self.album()? {
            map.insert(ALBUM_KEY, track_album_info.album.name.clone());

            let artists = track_album_info
                .album
                .artists()
                .artists()?
                .iter()
                .map(Artist::name)
                .collect::<Vec<_>>()
                .join(";");
            map.insert(ALBUM_ARTIST_KEY, artists);

            if let Some(track_num) = track_album_info.track_num
                && let Some(track_total) = track_album_info.album.track_total
            {
                map.insert(TRACK_NUM_KEY, format!("{track_num}/{track_total}"));
            }

            if let Some(disc_num) = track_album_info.disc_num
                && let Some(disc_total) = track_album_info.album.disc_total
            {
                map.insert(DISC_NUM_KEY, format!("{disc_num}/{disc_total}"));
            }
        }

        let artists = self
            .metadata
            .artists
            .artists()?
            .iter()
            .map(Artist::name)
            .collect::<Vec<_>>()
            .join(";");
        map.insert(ARTIST_KEY, artists);

        if let Some(date) = self.metadata.date {
            map.insert(DATE_KEY, date.to_string());
        }

        if let Some(genre) = &self.metadata.genre {
            map.insert(GENRE_KEY, genre.to_owned());
        }

        if let Some(title) = &self.metadata.title {
            map.insert(TITLE_KEY, title.to_owned());
        }

        if let Some(lyric_data) = &self.metadata.lyric_data {
            let (k, v) = lyric_data.get_metadata_value();
            map.insert(k, v);
        }

        let mut collected = self.metadata.other.clone();
        collected.extend(map.into_iter().map(|(k, v)| (k.to_owned(), v)));

        Ok(collected)
    }

    pub fn tx_apply_metadata_key(
        &self,
        key: MetadataKey,
        cas_tx: &mut CompareAndSwapTransaction,
    ) -> Result<(), MetadataError> {
        let mut track = cas_tx
            .tx_get(self.id())?
            .ok_or(DatabaseError::MissingEntry)?;

        match key {
            MetadataKey::Album(v) => {
                cas_tx.tracks_set_album(v.as_ref().map(Album::id), std::iter::once(&track.id()))?;
            }
            MetadataKey::Artist(v) => track.metadata.artists = ArtistGroup::from_artists(&v),
            MetadataKey::Date(v) => track.metadata.date = v,
            MetadataKey::DiscNum(v) => {
                if let Some(album_id) = track.metadata.album {
                    let mut album = cas_tx.tx_get(album_id)?.expect("Dangling album reference");

                    let track_reference = album
                        .tracks
                        .iter_mut()
                        .find(|t| t.id == track.id())
                        .expect("Dangling album>track reference");

                    track_reference.disc_num = v;

                    cas_tx.tx_upsert(album_id, Some(album))?;
                } else {
                    return Err(MetadataError::MissingAlbum(
                        "disc cannot be set because track has no album".to_owned(),
                    ));
                }
            }
            MetadataKey::Genre(v) => track.metadata.genre = v,
            MetadataKey::Lyrics(v) => track.metadata.lyric_data = v,
            MetadataKey::Instrumental(v) => {
                track.metadata.lyric_data = v.then_some(LyricData::Instrumental);
            }
            MetadataKey::Title(v) => track.metadata.title = v,
            MetadataKey::TrackNum(v) => {
                if let Some(album_id) = track.metadata.album {
                    let mut album = cas_tx.tx_get(album_id)?.expect("Dangling album reference");

                    let track_reference = album
                        .tracks
                        .iter_mut()
                        .find(|t| t.id == track.id())
                        .expect("Dangling album>track reference");

                    track_reference.track_num = v;

                    cas_tx.tx_upsert(album_id, Some(album))?;
                } else {
                    return Err(MetadataError::MissingAlbum(
                        "track cannot be set because track has no album".to_owned(),
                    ));
                }
            }
            MetadataKey::Other(key, v) => {
                if let Some(v) = v {
                    track.metadata.other.insert(key, v);
                } else {
                    track.metadata.other.remove(&key);
                }
            }
            _ => {
                return Err(MetadataError::KeyNotAllowed(format!(
                    "{key} cannot be used on track metadata",
                    key = key.to_key()
                )));
            }
        }

        cas_tx.tx_upsert(track.id(), Some(track))?;

        Ok(())
    }
}

impl Track {
    /// Migrates a track from its relative path to the input library directory
    ///
    /// # Errors
    ///
    /// This function will error if:
    /// - The track does not exist in the library.
    /// - [`std::fs::rename()`] fails.
    /// - The track fails to patch using [`DatabaseEntry::db_patch()`].
    ///
    /// The database is patched AFTER the file is renamed, if the file renames succesfully, but the patch fails, this could lead to a minor desync between the internal storage and the filesystem.
    /// This can easily be revalidated via orphan relinking
    pub fn migrate(&mut self, library_dir: impl AsRef<Path>) -> Result<(), TrackRenameError> {
        let Some(lib_container) = &mut self.lib_container else {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                "A library container was not found for the input track".to_string(),
            )
            .into());
        };

        let absolute_path = library_dir.as_ref().join(&self.relative_library_path);

        fs::create_dir_all(absolute_path.parent().expect("File cannot be root"))?;
        if let Err(err) = fs::rename(lib_container.path(), &absolute_path) {
            match err.kind() {
                io::ErrorKind::NotFound if fs::symlink_metadata(&absolute_path)?.is_file() => {}
                _ => return Err(err.into()),
            }
        }

        lib_container.set_path(absolute_path);
        self.db_patch()?;
        Ok(())
    }
}

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

    #[error("DatabaseError: {0}")]
    Database(#[from] DatabaseError),

    #[error("FormatError: {0}")]
    Format(#[from] FormatError),

    #[error("LibraryError: {0}")]
    Library(#[from] LibraryError),

    #[error("{0}")]
    ConflictingNames(String),
}

/// Calculates the relative path for a track.
///
/// # Errors
///
/// Returns an error if database references cannot be obtained
pub fn calculate_rel_path(
    metadata: &TrackMeta,
    container_ref: &MediaContainer,
) -> Result<PathBuf, TrackRenameError> {
    let artists = metadata.artists.artists()?;
    let album = metadata.album()?;

    let mut format_table = FormatTable::new();
    format_table.extend_from_taggable(metadata);
    add_from_artists(&mut format_table, &artists, "track");
    if let Some(album) = album {
        format_table.extend_from_taggable(&album);
    }

    let mut path = PathBuf::from(format_str(
        &common_config().track_name_config.format_string,
        &format_table,
    )?);

    path.add_extension(
        pair_extension(container_ref.container(), container_ref.codec())
            .expect("Invalid container/codec pair when renaming"),
    );

    Ok(path)
}