selene-core 0.9.0-alpha.2

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

use lofty::{
    file::TaggedFileExt,
    picture::PictureType,
    tag::{ItemKey, TagItem, TagType},
};
use lunar_lib::{hash_file, iterator_ext::IteratorExtensions, vec_ext::VecExtensions};
use thiserror::Error;

use crate::{
    library::{
        Entry, Id,
        album::{Album, TrackReference},
        artist::Artist,
        image_art::ImageArt,
        metadata::{LoftyTagTakeAccessors, extract_instrumental},
        track::{Track, lyric_data::LyricData, track_meta::TrackMeta},
    },
    media_container::ContainerError,
    symphonia_helpers::{ContainerExtractResult, extract_from_file},
};

#[derive(Clone)]
pub struct ExtractResult {
    pub track: Entry<Track>,
    pub cover_art: Option<ImageArt>,

    pub album: Option<Entry<Album>>,
    pub artists: Vec<Entry<Artist>>,
}

impl ExtractResult {
    #[inline]
    #[cfg(feature = "database-impls")]
    pub fn tx_upsert_items(
        self,
        cas_tx: &mut lunar_lib::database::CompareAndSwapTransaction<crate::database::Library>,
    ) -> Result<(), lunar_lib::database::TransactionError> {
        use crate::database::SeleneEntryExt;

        self.track.to_db_entry().tx_upsert(cas_tx)?;

        if let Some(album) = self.album {
            album.to_db_entry().tx_patch(cas_tx)?;
        }

        for artist in self.artists {
            artist.to_db_entry().tx_patch(cas_tx)?;
        }

        Ok(())
    }

    #[must_use]
    pub fn image(&self) -> Option<Entry<ImageArt>> {
        self.cover_art.clone().map(|art| Entry {
            id: art.id(),
            entry: art,
        })
    }
}

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

    #[error("LoftyError: {0}")]
    Lofty(#[from] lofty::error::LoftyError),

    #[error("Container Error: {0}")]
    Container(#[from] ContainerError),
}

/// Analyzes and extracts the input file metadata into an [`ExtractResult`]
pub fn extract_metadata(source_file: impl AsRef<Path>) -> Result<ExtractResult, ExtractError> {
    let source_file = source_file.as_ref();
    let track_id = Id::<Track>::from(*hash_file(source_file)?.as_bytes());

    let ContainerExtractResult { container, .. } = extract_from_file(source_file)?;

    let mut metadata = lofty::read_from_path(source_file)?;
    let tags = metadata.primary_tag_mut().unwrap();

    let (title, mut track_artists) = tags.track_title_and_artists();
    for artist in &mut track_artists {
        artist.tracks.push(track_id);
    }

    let (track_title, instrumental) = match title {
        Some(t) => {
            let (extracted, instrumental) = extract_instrumental(&t);
            (Some(extracted.into_owned()), Some(instrumental))
        }
        None => (None, None),
    };

    let track_artist_ids = track_artists.iter().map(|t| t.id).to_vec();
    let mut all_artists: HashMap<_, _> = track_artists.into_iter().map(|a| (a.id(), a)).collect();

    let date = tags.date();

    let track_num = tags.track_num();
    let disc_num = tags.disc_num();

    let genre: Vec<String> = tags.take_strings(ItemKey::Genre).collect();

    let mut album = if let Some((mut album, album_artists)) =
        tags.album(track_title.as_deref(), track_num, disc_num)
    {
        album.date = date;
        album.genre = genre.clone();

        for artist in album_artists {
            match all_artists.entry(artist.id()) {
                hash_map::Entry::Occupied(mut e) => {
                    e.get_mut().albums.push_unique(album.id());
                }
                hash_map::Entry::Vacant(e) => {
                    e.insert(artist);
                }
            }
        }

        Some(album)
    } else {
        None
    };

    let lyrics = tags.lyrics();
    let lyrics = if instrumental == Some(true) {
        Some(LyricData::Instrumental)
    } else {
        lyrics
    };

    let cover_art = if tags.picture_count() != 0 {
        let picture_idx = tags
            .pictures()
            .iter()
            .position(|p| p.pic_type() == PictureType::CoverFront)
            .unwrap_or(0);
        let picture = tags.remove_picture(picture_idx);
        ImageArt::try_from_picture(picture)
    } else {
        None
    };

    let other = tags
        .items()
        .cloned()
        .map(TagItem::consume)
        .filter_map(|(k, v)| {
            let v = v.text()?.to_owned();
            let k = k.map_key(TagType::VorbisComments)?.to_owned();

            Some((k, v))
        });

    let metadata = TrackMeta {
        album: album.as_ref().map(Entry::id),
        artists: track_artist_ids,
        date,
        genre,
        lyric_data: lyrics,
        other: other.collect(),
        title: track_title,
        art: cover_art.as_ref().map(ImageArt::id),
    };

    let track = Track::new(container, metadata)?;

    if let Some(album) = &mut album {
        album.tracks.push(TrackReference {
            id: track_id,
            track_num,
            disc_num,
        });
    }

    #[cfg(debug_assertions)]
    {
        assert!(track.metadata.album == album.as_ref().map(Entry::id));
        if let Some(album) = &album {
            assert!(album.tracks.first().unwrap().id == track_id);
        }

        for artist in &track.metadata.artists {
            assert!(all_artists.get(artist).unwrap().tracks[0] == track_id);
        }

        for artist in all_artists.values() {
            if let Some(next) = artist.albums.first() {
                assert!(album.as_ref().unwrap().id() == *next);
            }

            if let Some(next) = artist.tracks.first() {
                assert!(track.metadata.artists.contains(&artist.id()));
                assert!(track_id == *next);
            }
        }
    }

    Ok(ExtractResult {
        track: Entry {
            entry: track,
            id: track_id,
        },
        album,
        artists: all_artists.into_values().collect(),
        cover_art,
    })
}