selene-core 0.8.2

selene-core is the backend for Selene, a local-first music player
Documentation
use std::sync::Arc;

use barber::{ProgressBar, ProgressRenderer};
use futures::{StreamExt, stream::FuturesUnordered};
use lunar_lib::database::{
    DatabaseEntry, DatabaseError, DbHandle, TransactionError, writer::DatabaseWriter,
};
use thiserror::Error;
use tokio::sync::Semaphore;

use crate::{
    database::LibraryDb,
    library::track::{ResolvedTrack, Track, lyric_data::LyricData},
    lrclib::SearchQuery,
};

#[derive(Debug, Error)]
pub enum LyricSearchError {
    #[error("DatabaseError: {0}")]
    Database(#[from] DatabaseError),

    #[error("Transaction Error: {0}")]
    Transaction(#[from] TransactionError),

    #[error("ReqwestError: {0}")]
    Reqwest(#[from] reqwest::Error),
}

/// Scans the input `tracks` for tracks that need lyrics
///
/// Tracks need to be reimported if:
/// - They do not have known lyric information
/// - They have plain lyrics and `synced_only` is enabled
pub fn find_needs_lyrics(synced_only: bool, db: &LibraryDb) -> Result<Vec<Track>, DatabaseError> {
    let mut tracks = Track::db_get_all(db)?;

    tracks.retain(|track| {
        track
            .metadata
            .lyric_data
            .as_ref()
            .is_none_or(|l| matches!(l, LyricData::Plain(_)) && synced_only)
    });

    Ok(tracks)
}

pub async fn find_lyrics_for(
    track: &ResolvedTrack,
    synced_only: bool,
) -> Result<Option<LyricData>, LyricSearchError> {
    let records = {
        let album = track.album_info().map(|(a, ..)| a.name());
        let Some(main_artist) = track.artists().first().map(|a| a.name()) else {
            return Ok(None);
        };
        let Some(title) = track.metadata().title.as_ref() else {
            return Ok(None);
        };

        SearchQuery::new(title, main_artist)
            .album_title(album)
            .run()
            .await?
    };

    for record in &records {
        if let Some(synced_lyrics) = record.synced_lyrics() {
            return Ok(Some(LyricData::Synced(synced_lyrics)));
        } else if let Some(plain_lyrics) = record.plain_lyrics()
            && !synced_only
        {
            return Ok(Some(LyricData::Plain(plain_lyrics)));
        } else if record.is_instrumental() {
            return Ok(Some(LyricData::Instrumental));
        }
    }

    Ok(None)
}

pub async fn find_lyrics_for_batch(
    tracks: Vec<Track>,
    progress_renderer: Arc<dyn ProgressRenderer>,
    synced_only: bool,
) -> Result<(), LyricSearchError> {
    struct QueryItem {
        track: Track,
        query: SearchQuery,
    }

    let db = DbHandle::<LibraryDb>::open()?;

    // Build queries
    let mut prepared = Vec::with_capacity(tracks.len());
    for track in tracks {
        let Some(title) = track.metadata.title.as_deref() else {
            continue;
        };

        let Some(main_artist) = track.metadata().main_artist(&db)? else {
            continue;
        };

        let query = {
            let album = track.album(&db)?;
            SearchQuery::new(title, main_artist.name())
                .album_title(album.as_ref().map(|a| a.album.name.as_str()))
        };

        prepared.push(QueryItem { track, query });
    }

    drop(db);

    let progress_bar = ProgressBar::new(0, prepared.len(), progress_renderer);

    // LRCLIB will respond 503 with a value of 10
    let semaphore = Arc::new(Semaphore::new(5));

    // Create futures
    let mut futures: FuturesUnordered<_> = prepared
        .into_iter()
        .map(|q| {
            let semaphore = semaphore.clone();
            async move {
                let _permit = semaphore.acquire().await.unwrap();
                let result = q.query.run().await?;
                Ok::<_, LyricSearchError>((q.track, result))
            }
        })
        .collect();

    // Handle futures and upsert results
    let writer = DatabaseWriter::<LibraryDb>::spawn();
    'result_loop: while let Some(result) = futures.next().await {
        let (mut track, results) = result?;

        if results.is_empty() {
            progress_bar.set_label(&format!(
                "No results for {track_title}",
                track_title = track.metadata.title.as_ref().unwrap()
            ));
            progress_bar.increment();
            continue;
        }

        progress_bar.set_label(&format!(
            "Found lyrics for {track_title}",
            track_title = track.metadata.title.as_ref().unwrap()
        ));
        progress_bar.increment();

        for result in &results {
            if let Some(lyrics) = result.synced_lyrics() {
                track.metadata.lyric_data = Some(LyricData::Synced(lyrics));
                writer.transaction(move |cas_tx| cas_tx.tx_upsert(track.clone()));
                continue 'result_loop;
            } else if let Some(lyrics) = result.plain_lyrics() {
                if !synced_only {
                    track.metadata.lyric_data = Some(LyricData::Plain(lyrics));
                }
                writer.transaction(move |cas_tx| cas_tx.tx_upsert(track.clone()));
                continue 'result_loop;
            }
        }

        track.metadata.lyric_data = Some(LyricData::Instrumental);
        writer.transaction(move |cas_tx| cas_tx.tx_upsert(track.clone()));
    }

    writer.finish()?;

    Ok(())
}