moosicbox_music_api_api 0.1.4

MoosicBox "Music API" API package
Documentation
use std::collections::BTreeMap;

use actix_web::{
    Result, Scope,
    dev::{ServiceFactory, ServiceRequest},
    error::{ErrorBadRequest, ErrorInternalServerError, ErrorNotFound},
    route,
    web::{self, Json},
};
use moosicbox_music_api::{
    MusicApis, SourceToMusicApi as _, models::search::api::ApiSearchResultsResponse,
};
use moosicbox_music_models::ApiSource;
use moosicbox_paging::Page;
use moosicbox_profiles::api::ProfileName;
use serde::Deserialize;

use crate::models::{ApiMusicApi, AuthValues, convert_to_api_music_api};

pub fn bind_services<
    T: ServiceFactory<ServiceRequest, Config = (), Error = actix_web::Error, InitError = ()>,
>(
    scope: Scope<T>,
) -> Scope<T> {
    scope
        .service(music_apis_endpoint)
        .service(auth_music_api_endpoint)
        .service(scan_music_api_endpoint)
        .service(enable_scan_origin_music_api_endpoint)
        .service(search_music_apis_endpoint)
}

#[cfg(feature = "openapi")]
#[derive(utoipa::OpenApi)]
#[openapi(
    tags((name = "MusicApi")),
    paths(
        music_apis_endpoint,
        auth_music_api_endpoint,
        scan_music_api_endpoint,
        enable_scan_origin_music_api_endpoint,
        search_music_apis_endpoint,
    ),
    components(schemas(
        ApiMusicApi,
    ))
)]
pub struct Api;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetMusicApis {
    offset: Option<u32>,
    limit: Option<u32>,
}

#[cfg_attr(
    feature = "openapi", utoipa::path(
        tags = ["MusicApi"],
        get,
        path = "",
        description = "Get the list enabled music APIs",
        params(
            ("moosicbox-profile" = String, Header, description = "MoosicBox profile"),
            ("offset" = Option<u32>, Query, description = "Page offset"),
            ("limit" = Option<u32>, Query, description = "Page limit"),
        ),
        responses(
            (
                status = 200,
                description = "A paginated response of tracks for the music APIs",
                body = Value,
            )
        )
    )
)]
#[route("", method = "GET")]
pub async fn music_apis_endpoint(
    query: web::Query<GetMusicApis>,
    profile_name: ProfileName,
) -> Result<Json<Page<ApiMusicApi>>> {
    let profile_name: String = profile_name.into();
    let offset = query.offset.unwrap_or(0);
    let limit = query.limit.unwrap_or(30);
    let music_apis = moosicbox_music_api::profiles::PROFILES
        .get(&profile_name)
        .ok_or_else(|| ErrorNotFound(format!("Missing profile '{profile_name}'")))?;
    let music_apis = music_apis.iter().collect::<Vec<_>>();
    let total = u32::try_from(music_apis.len()).unwrap();
    let music_apis = futures::future::join_all(
        music_apis
            .into_iter()
            .skip(offset as usize)
            .take(limit as usize)
            .map(convert_to_api_music_api),
    )
    .await;
    let music_apis = music_apis
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .map_err(ErrorInternalServerError)?;

    Ok(Json(Page::WithTotal {
        items: music_apis,
        offset,
        limit,
        total,
    }))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMusicApi {
    api_source: ApiSource,
}

#[cfg_attr(
    feature = "openapi", utoipa::path(
        tags = ["MusicApi"],
        post,
        path = "/auth",
        description = "Authenticate a specific MusicApi",
        params(
            ("moosicbox-profile" = String, Header, description = "MoosicBox profile"),
            ("apiSource" = ApiSource, Query, description = "ApiSource to authenticate"),
        ),
        responses(
            (
                status = 200,
                description = "The updated state for the MusicApi that was authenticated",
                body = ApiMusicApi,
            )
        )
    )
)]
#[route("/auth", method = "POST")]
#[cfg_attr(not(feature = "_auth"), allow(unreachable_code, unused))]
pub async fn auth_music_api_endpoint(
    query: web::Query<AuthMusicApi>,
    form: web::Form<AuthValues>,
    music_apis: MusicApis,
) -> Result<Json<ApiMusicApi>> {
    let music_api = music_apis
        .get(&query.api_source)
        .ok_or_else(|| ErrorNotFound(format!("MusicApi '{}' not found", query.api_source)))?;

    match form.0 {
        AuthValues::UsernamePassword { username, password } => {
            #[cfg(not(feature = "auth-username-password"))]
            return Err(ErrorBadRequest("Auth not supported"));

            #[cfg(feature = "auth-username-password")]
            if let Some(auth) = music_api.auth() {
                let user_pass_auth = auth
                    .clone()
                    .into_username_password()
                    .ok_or_else(|| ErrorBadRequest("Auth not supported"))?;

                auth.attempt_login(move |_| {
                    let user_pass_auth = user_pass_auth.clone();
                    let username = username.clone();
                    let password = password.clone();
                    async move { user_pass_auth.login(username, password).await }
                })
                .await
                .map_err(ErrorInternalServerError)?;
            }
        }
        AuthValues::Poll => {
            #[cfg(not(feature = "auth-poll"))]
            return Err(ErrorBadRequest("Auth not supported"));

            #[cfg(feature = "auth-poll")]
            if let Some(auth) = music_api.auth() {
                let poll_auth = auth
                    .clone()
                    .into_poll()
                    .ok_or_else(|| ErrorBadRequest("Auth not supported"))?;

                auth.attempt_login(move |_| {
                    let poll_auth = poll_auth.clone();
                    async move { poll_auth.login().await }
                })
                .await
                .map_err(ErrorInternalServerError)?;
            }
        }
    }

    Ok(Json(
        convert_to_api_music_api(&**music_api)
            .await
            .map_err(ErrorInternalServerError)?,
    ))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanMusicApi {
    api_source: ApiSource,
}

#[cfg_attr(
    feature = "openapi", utoipa::path(
        tags = ["MusicApi"],
        post,
        path = "/scan",
        description = "Scan a specific MusicApi",
        params(
            ("moosicbox-profile" = String, Header, description = "MoosicBox profile"),
            ("apiSource" = ApiSource, Query, description = "ApiSource to scan"),
        ),
        responses(
            (
                status = 200,
                description = "The updated state for the MusicApi that was scanned",
                body = ApiMusicApi,
            )
        )
    )
)]
#[route("/scan", method = "POST")]
pub async fn scan_music_api_endpoint(
    query: web::Query<ScanMusicApi>,
    music_apis: MusicApis,
) -> Result<Json<ApiMusicApi>> {
    let music_api = music_apis
        .get(&query.api_source)
        .ok_or_else(|| ErrorNotFound(format!("MusicApi '{}' not found", query.api_source)))?;

    music_api.scan().await.map_err(ErrorInternalServerError)?;

    Ok(Json(
        convert_to_api_music_api(&**music_api)
            .await
            .map_err(ErrorInternalServerError)?,
    ))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnableScanMusicApi {
    api_source: ApiSource,
}

#[cfg_attr(
    feature = "openapi", utoipa::path(
        tags = ["MusicApi"],
        post,
        path = "/scan-origins",
        description = "Enable a specific MusicApi scan origin",
        params(
            ("moosicbox-profile" = String, Header, description = "MoosicBox profile"),
            ("apiSource" = ApiSource, Query, description = "ApiSource to scan"),
        ),
        responses(
            (
                status = 200,
                description = "The updated state for the MusicApi that was enabled",
                body = ApiMusicApi,
            )
        )
    )
)]
#[route("/scan-origins", method = "POST")]
pub async fn enable_scan_origin_music_api_endpoint(
    query: web::Query<EnableScanMusicApi>,
    music_apis: MusicApis,
) -> Result<Json<ApiMusicApi>> {
    let music_api = music_apis
        .get(&query.api_source)
        .ok_or_else(|| ErrorNotFound(format!("MusicApi '{}' not found", query.api_source)))?;

    music_api
        .enable_scan()
        .await
        .map_err(ErrorInternalServerError)?;

    Ok(Json(
        convert_to_api_music_api(&**music_api)
            .await
            .map_err(ErrorInternalServerError)?,
    ))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchMusicApis {
    query: String,
    api_source: Option<String>,
    offset: Option<u32>,
    limit: Option<u32>,
}

#[cfg_attr(
    feature = "openapi", utoipa::path(
        tags = ["MusicApi"],
        get,
        path = "/search",
        description = "Search the music APIs",
        params(
            ("moosicbox-profile" = String, Header, description = "MoosicBox profile"),
            ("query" = String, Query, description = "The search query"),
            ("apiSource" = Option<String>, Query, description = "The ApiSource(s) to search"),
            ("offset" = Option<u32>, Query, description = "Page offset"),
            ("limit" = Option<u32>, Query, description = "Page limit"),
        ),
        responses(
            (
                status = 200,
                description = "A paginated response of search results for the music APIs",
                body = Value,
            )
        )
    )
)]
#[route("/search", method = "GET")]
pub async fn search_music_apis_endpoint(
    query: web::Query<SearchMusicApis>,
    profile_name: ProfileName,
) -> Result<Json<BTreeMap<ApiSource, ApiSearchResultsResponse>>> {
    let api_sources = query
        .api_source
        .as_ref()
        .map(|x| {
            x.split(',')
                .map(|x| x.try_into())
                .collect::<Result<Vec<ApiSource>, _>>()
                .map_err(|e| ErrorBadRequest(format!("Invalid apiSource: {e:?}")))
        })
        .transpose()?;
    let profile_name: String = profile_name.into();
    let music_apis = moosicbox_music_api::profiles::PROFILES
        .get(&profile_name)
        .ok_or_else(|| ErrorNotFound(format!("Missing profile '{profile_name}'")))?;
    let music_apis = music_apis
        .iter()
        .filter(|x| x.supports_search())
        .filter(|x| {
            api_sources
                .as_ref()
                .is_none_or(|sources| sources.contains(x.source()))
        })
        .collect::<Vec<_>>();

    let search_results = music_apis
        .iter()
        .map(|x| x.search(&query.query, query.offset, query.limit));
    let search_results = futures::future::join_all(search_results).await;
    let search_results = search_results
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .map_err(ErrorInternalServerError)?;

    let search_results = music_apis
        .into_iter()
        .map(|x| x.source().clone())
        .zip(search_results.into_iter())
        .collect::<BTreeMap<_, _>>();

    Ok(Json(search_results))
}