selene-core 0.5.7

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::{
    Database, DatabaseEntry, DatabaseError, TransactionError, writer::DatabaseWriter,
};
use thiserror::Error;
use tokio::sync::Semaphore;

use crate::{
    database::{LibraryDb, tx_extensions::CasTxExtensions},
    library::track::{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) -> Result<Vec<Track>, DatabaseError> {
    let mut tracks = Track::db_get_all()?;

    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(
    tracks: Vec<Track>,
    progress_renderer: Arc<dyn ProgressRenderer>,
    synced_only: bool,
) -> Result<(), LyricSearchError> {
    struct QueryItem {
        track: Track,
        query: SearchQuery,
    }

    let db = 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 query = {
            let album = track.album(&db)?;
            let artists = track.metadata.artists(&db)?;
            SearchQuery::new(title)
                .album_title(album.as_ref().map(|a| a.album.name.as_str()))
                .main_artist(Some(artists[0].name()))
        };

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

    drop(db);

    let progress_bar = ProgressBar::new(0, prepared.len(), progress_renderer);
    let semaphore = Arc::new(Semaphore::new(10));

    // 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_patch(track.clone()));
                continue 'result_loop;
            }
        }

        for result in &results {
            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_patch(track.clone()));
                continue 'result_loop;
            }
        }

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

    writer.finish()?;

    Ok(())
}