#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
use std::ops::Deref;
use std::sync::{Arc, LazyLock};
use std::{path::PathBuf, sync::atomic::AtomicUsize};
use db::get_enabled_scan_origins;
use event::{ProgressEvent, ScanTask, PROGRESS_LISTENERS};
use moosicbox_config::get_cache_dir_path;
use moosicbox_core::sqlite::{
db::DbError,
models::{ApiSource, TrackApiSource},
};
use moosicbox_database::profiles::LibraryDatabase;
use moosicbox_music_api::{MusicApi, MusicApis, MusicApisError, SourceToMusicApi as _};
use serde::{Deserialize, Serialize};
use strum_macros::{AsRefStr, EnumString};
use thiserror::Error;
use tokio_util::sync::CancellationToken;
#[cfg(feature = "api")]
pub mod api;
#[cfg(feature = "local")]
pub mod local;
pub mod db;
pub mod event;
pub mod music_api;
pub mod output;
static CACHE_DIR: LazyLock<PathBuf> =
LazyLock::new(|| get_cache_dir_path().expect("Could not get cache directory"));
static CANCELLATION_TOKEN: LazyLock<CancellationToken> = LazyLock::new(CancellationToken::new);
pub fn cancel() {
CANCELLATION_TOKEN.cancel();
}
#[derive(Debug, Serialize, Deserialize, EnumString, AsRefStr, PartialEq, Clone, Copy)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum ScanOrigin {
#[cfg(feature = "local")]
Local,
Tidal,
Qobuz,
Yt,
}
impl From<ScanOrigin> for ApiSource {
fn from(value: ScanOrigin) -> Self {
match value {
#[cfg(feature = "local")]
ScanOrigin::Local => {
moosicbox_assert::die_or_panic!("Local ScanOrigin cant map to ApiSource")
}
ScanOrigin::Tidal => ApiSource::Tidal,
ScanOrigin::Qobuz => ApiSource::Qobuz,
ScanOrigin::Yt => ApiSource::Yt,
}
}
}
impl From<ApiSource> for ScanOrigin {
fn from(value: ApiSource) -> Self {
match value {
ApiSource::Library => {
moosicbox_assert::die_or_panic!("Library ApiSource cant map to ScanOrigin")
}
ApiSource::Tidal => ScanOrigin::Tidal,
ApiSource::Qobuz => ScanOrigin::Qobuz,
ApiSource::Yt => ScanOrigin::Yt,
}
}
}
impl From<TrackApiSource> for ScanOrigin {
fn from(value: TrackApiSource) -> Self {
match value {
TrackApiSource::Local => {
#[cfg(feature = "local")]
{
ScanOrigin::Local
}
#[cfg(not(feature = "local"))]
{
moosicbox_assert::die_or_panic!("Local TrackApiSource cant map to ScanOrigin")
}
}
TrackApiSource::Tidal => ScanOrigin::Tidal,
TrackApiSource::Qobuz => ScanOrigin::Qobuz,
TrackApiSource::Yt => ScanOrigin::Yt,
}
}
}
impl From<ScanOrigin> for TrackApiSource {
fn from(value: ScanOrigin) -> Self {
match value {
#[cfg(feature = "local")]
ScanOrigin::Local => TrackApiSource::Local,
ScanOrigin::Tidal => TrackApiSource::Tidal,
ScanOrigin::Qobuz => TrackApiSource::Qobuz,
ScanOrigin::Yt => TrackApiSource::Yt,
}
}
}
#[allow(unused)]
async fn get_origins_or_default(
db: &LibraryDatabase,
origins: Option<Vec<ScanOrigin>>,
) -> Result<Vec<ScanOrigin>, DbError> {
let enabled_origins = get_enabled_scan_origins(db).await?;
Ok(if let Some(origins) = origins {
origins
.iter()
.filter(|o| enabled_origins.iter().any(|enabled| enabled == *o))
.cloned()
.collect::<Vec<_>>()
} else {
enabled_origins
})
}
#[derive(Debug, Error)]
pub enum ScanError {
#[error(transparent)]
Db(#[from] DbError),
#[cfg(feature = "local")]
#[error(transparent)]
Local(#[from] local::ScanError),
#[error(transparent)]
MusicApis(#[from] MusicApisError),
#[error(transparent)]
MusicApi(#[from] music_api::ScanError),
}
#[derive(Clone)]
pub struct Scanner {
scanned: Arc<AtomicUsize>,
total: Arc<AtomicUsize>,
task: Arc<ScanTask>,
}
impl Scanner {
#[allow(unused)]
pub async fn from_origin(db: &LibraryDatabase, origin: ScanOrigin) -> Result<Self, DbError> {
let task = match origin {
#[cfg(feature = "local")]
ScanOrigin::Local => {
use crate::db::get_scan_locations_for_origin;
let locations = get_scan_locations_for_origin(db, origin).await?;
let paths = locations
.iter()
.map(|location| {
location
.path
.as_ref()
.expect("Local ScanLocation is missing path")
})
.cloned()
.collect::<Vec<_>>();
ScanTask::Local { paths }
}
_ => ScanTask::Api { origin },
};
Ok(Self::new(task).await)
}
pub async fn new(task: ScanTask) -> Self {
Self {
scanned: Arc::new(AtomicUsize::new(0)),
total: Arc::new(AtomicUsize::new(0)),
task: Arc::new(task),
}
}
#[allow(unused)]
async fn increase_total(&self, count: usize) {
let total = self.total.load(std::sync::atomic::Ordering::SeqCst) + count;
self.on_total_updated(total).await
}
#[allow(unused)]
async fn on_total_updated(&self, total: usize) {
let scanned = self.scanned.load(std::sync::atomic::Ordering::SeqCst);
self.total.store(total, std::sync::atomic::Ordering::SeqCst);
let event = ProgressEvent::ScanCountUpdated {
scanned,
total,
task: self.task.deref().clone(),
};
for listener in PROGRESS_LISTENERS.read().await.clone().iter_mut() {
listener(&event).await;
}
}
#[allow(unused)]
async fn on_scanned_track(&self) {
let total = self.total.load(std::sync::atomic::Ordering::SeqCst);
let scanned = self
.scanned
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
let event = ProgressEvent::ItemScanned {
scanned,
total,
task: self.task.deref().clone(),
};
for listener in PROGRESS_LISTENERS.read().await.clone().iter_mut() {
listener(&event).await;
}
}
pub async fn on_scan_finished(&self) {
let scanned = self.scanned.load(std::sync::atomic::Ordering::SeqCst);
let total = self.total.load(std::sync::atomic::Ordering::SeqCst);
let event = ProgressEvent::ScanFinished {
scanned,
total,
task: self.task.deref().clone(),
};
for listener in PROGRESS_LISTENERS.read().await.clone().iter_mut() {
listener(&event).await;
}
}
pub async fn scan(&self, music_apis: MusicApis, db: &LibraryDatabase) -> Result<(), ScanError> {
self.scanned.store(0, std::sync::atomic::Ordering::SeqCst);
self.total.store(0, std::sync::atomic::Ordering::SeqCst);
match self.task.deref() {
#[cfg(feature = "local")]
ScanTask::Local { paths } => self.scan_local(db, paths).await?,
ScanTask::Api { origin } => {
self.scan_music_api(&**music_apis.get((*origin).into())?, db)
.await?
}
}
self.on_scan_finished().await;
Ok(())
}
#[cfg(feature = "local")]
pub async fn scan_local(
&self,
db: &LibraryDatabase,
paths: &[String],
) -> Result<(), local::ScanError> {
let handles = paths.iter().cloned().map(|path| {
let db = db.clone();
let scanner = self.clone();
moosicbox_task::spawn(&format!("scan_local: scan '{path}'"), async move {
local::scan(&path, &db, CANCELLATION_TOKEN.clone(), scanner).await
})
});
for resp in futures::future::join_all(handles).await {
resp??
}
Ok(())
}
pub async fn scan_music_api(
&self,
api: &dyn MusicApi,
db: &LibraryDatabase,
) -> Result<(), music_api::ScanError> {
let enabled_origins = get_enabled_scan_origins(db).await?;
let enabled = enabled_origins
.into_iter()
.any(|origin| origin == api.source().into());
let scanner = self.clone();
if !enabled {
log::debug!(
"scan_music_api: scan origin is not enabled: {}",
api.source()
);
return Ok(());
}
music_api::scan(api, db, CANCELLATION_TOKEN.clone(), Some(scanner)).await?;
Ok(())
}
}
pub async fn get_scan_origins(db: &LibraryDatabase) -> Result<Vec<ScanOrigin>, DbError> {
get_enabled_scan_origins(db).await
}
pub async fn enable_scan_origin(db: &LibraryDatabase, origin: ScanOrigin) -> Result<(), DbError> {
#[cfg(feature = "local")]
if origin == ScanOrigin::Local {
return Ok(());
}
let locations = db::get_scan_locations(db).await?;
if locations.iter().any(|location| location.origin == origin) {
return Ok(());
}
db::enable_scan_origin(db, origin).await
}
pub async fn disable_scan_origin(db: &LibraryDatabase, origin: ScanOrigin) -> Result<(), DbError> {
let locations = db::get_scan_locations(db).await?;
if locations.iter().all(|location| location.origin != origin) {
return Ok(());
}
db::disable_scan_origin(db, origin).await
}
pub async fn run_scan(
origins: Option<Vec<ScanOrigin>>,
db: &LibraryDatabase,
music_apis: MusicApis,
) -> Result<(), ScanError> {
log::debug!("run_scan: origins={origins:?}");
let origins = get_origins_or_default(db, origins).await?;
log::debug!("run_scan: get_origins_or_default={origins:?}");
for origin in origins {
Scanner::from_origin(db, origin)
.await?
.scan(music_apis.clone(), db)
.await?;
}
Ok(())
}
#[cfg(feature = "local")]
pub async fn get_scan_paths(db: &LibraryDatabase) -> Result<Vec<String>, DbError> {
let locations = db::get_scan_locations_for_origin(db, ScanOrigin::Local).await?;
Ok(locations
.iter()
.map(|location| {
location
.path
.as_ref()
.expect("Local ScanLocation is missing path")
})
.cloned()
.collect::<Vec<_>>())
}
#[cfg(feature = "local")]
pub async fn add_scan_path(db: &LibraryDatabase, path: &str) -> Result<(), DbError> {
let locations = db::get_scan_locations(db).await?;
if locations
.iter()
.any(|location| location.path.as_ref().is_some_and(|p| p.as_str() == path))
{
return Ok(());
}
db::add_scan_path(db, path).await
}
#[cfg(feature = "local")]
pub async fn remove_scan_path(db: &LibraryDatabase, path: &str) -> Result<(), DbError> {
let locations = db::get_scan_locations(db).await?;
if locations
.iter()
.all(|location| !location.path.as_ref().is_some_and(|p| p.as_str() == path))
{
return Ok(());
}
db::remove_scan_path(db, path).await
}