diff --git a/README.md b/README.md index 8e7aae7d..e5f7f71c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ We use the following terminology: - Radarr - Tdarr - FileFlows + - Audiobookshelf - Another autopulse instance #### Example Flow diff --git a/crates/service/src/settings/auth.rs b/crates/service/src/settings/auth.rs index 8457b1c6..2281aaa4 100644 --- a/crates/service/src/settings/auth.rs +++ b/crates/service/src/settings/auth.rs @@ -20,6 +20,7 @@ const fn default_enabled() -> bool { pub struct Auth { /// Whether authentication is enabled (default: true) #[serde(default = "default_enabled")] + #[serde(skip_serializing)] pub enabled: bool, /// Username for basic auth (default: admin) #[serde(default = "default_username")] diff --git a/crates/service/src/settings/targets/audiobookshelf.rs b/crates/service/src/settings/targets/audiobookshelf.rs new file mode 100644 index 00000000..63fe0720 --- /dev/null +++ b/crates/service/src/settings/targets/audiobookshelf.rs @@ -0,0 +1,154 @@ +use super::RequestBuilderPerform; +use crate::settings::rewrite::Rewrite; +use crate::settings::targets::TargetProcess; +use autopulse_database::models::ScanEvent; +use autopulse_utils::get_url; +use reqwest::header; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error}; + +#[derive(Clone, Deserialize, Serialize)] +pub struct Audiobookshelf { + /// URL to the audiobookshelf instance + pub url: String, + // /// Authentication credentials + // pub auth: Auth, + /// API token for Audiobookshelf Server (see https://www.audiobookshelf.org/guides/api-keys) + pub token: String, + /// Rewrite path for the file + pub rewrite: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LibraryFolder { + #[serde(rename = "fullPath")] + pub full_path: String, + #[serde(rename = "libraryId")] + pub library_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct Library { + pub folders: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct LibrariesResponse { + pub libraries: Vec, +} + +impl Audiobookshelf { + async fn get_client(&self) -> anyhow::Result { + let mut headers = header::HeaderMap::new(); + + // if self.auth.enabled { + // if let Some(token) = token { + // headers.insert("Authorization", format!("Bearer {token}").parse()?); + // } + // } + headers.insert("Authorization", format!("Bearer {}", self.token).parse()?); + + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .default_headers(headers) + .build() + .map_err(Into::into) + } + + // async fn login(&self) -> anyhow::Result { + // let client = self.get_client(None).await?; + // let url = get_url(&self.url)?.join("login")?; + + // let res = client + // .post(url) + // .header("Content-Type", "application/json") + // .json(&self.auth) + // .perform() + // .await?; + + // let body: AudiobookshelfLoginResponse = res.json().await?; + + // Ok(body.user.token) + // } + + async fn scan(&self, ev: &ScanEvent, library_id: String) -> anyhow::Result<()> { + let client = self.get_client().await?; + let url = get_url(&self.url)?.join("api/watcher/update")?; + + client + .post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "libraryId": library_id, + "path": ev.get_path(&self.rewrite), + // audiobookshelf will scan for the changes so del/rename *should* be handled + // https://github.com/mikiher/audiobookshelf/blob/master/server/Watcher.js#L268 + "type": "add" + })) + .perform() + .await + .map(|_| ()) + } + + async fn get_libraries(&self) -> anyhow::Result> { + let client = self.get_client().await?; + + let url = get_url(&self.url)?.join("api/libraries")?; + + let res = client.get(url).perform().await?; + + let body: LibrariesResponse = res.json().await?; + + Ok(body.libraries) + } + + async fn choose_library( + &self, + ev: &ScanEvent, + libraries: &[Library], + ) -> anyhow::Result> { + for library in libraries { + for folder in library.folders.iter() { + if ev.get_path(&self.rewrite).starts_with(&folder.full_path) { + debug!("found library: {}", folder.library_id); + return Ok(Some(folder.library_id.clone())); + } + } + } + + Ok(None) + } +} + +impl TargetProcess for Audiobookshelf { + async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result> { + let mut succeeded = Vec::new(); + + let libraries = self.get_libraries().await?; + + if libraries.is_empty() { + error!("no libraries found"); + return Ok(succeeded); + } + + for ev in evs { + match self.choose_library(ev, &libraries).await { + Ok(Some(library_id)) => { + if let Err(e) = self.scan(ev, library_id).await { + error!("failed to scan audiobookshelf: {}", e); + } else { + succeeded.push(ev.id.clone()); + } + } + Ok(None) => { + error!("no library found for {}", ev.get_path(&self.rewrite)); + } + Err(e) => { + error!("failed to choose library: {}", e); + } + } + } + + Ok(succeeded) + } +} diff --git a/crates/service/src/settings/targets/mod.rs b/crates/service/src/settings/targets/mod.rs index 08d9e00d..1db24752 100644 --- a/crates/service/src/settings/targets/mod.rs +++ b/crates/service/src/settings/targets/mod.rs @@ -1,3 +1,19 @@ +/// Audiobookshelf - Audiobookshelf target +/// +/// This target is used to send a file to the Audiobookshelf watcher +/// +/// # Example +/// +/// ```yml +/// targets: +/// audiobookshelf: +/// type: audiobookshelf +/// url: http://localhost:13378 +/// token: "" +/// ``` +/// +/// See [`Audiobookshelf`] for all options +pub mod audiobookshelf; /// Autopulse - Autopulse target /// /// This target is used to process a file in another instance of Autopulse @@ -169,6 +185,7 @@ pub mod sonarr; /// See [`Tdarr`] for all options pub mod tdarr; +use audiobookshelf::Audiobookshelf; use autopulse_database::models::ScanEvent; use reqwest::{RequestBuilder, Response}; use serde::{Deserialize, Serialize}; @@ -203,6 +220,7 @@ pub enum Target { Command(Command), FileFlows(FileFlows), Autopulse(Autopulse), + Audiobookshelf(Audiobookshelf), } pub trait TargetProcess { @@ -223,6 +241,7 @@ impl TargetProcess for Target { Self::Radarr(t) => t.process(evs).await, Self::FileFlows(t) => t.process(evs).await, Self::Autopulse(t) => t.process(evs).await, + Self::Audiobookshelf(t) => t.process(evs).await, } } } @@ -260,12 +279,25 @@ impl RequestBuilderPerform for RequestBuilder { Ok(response) } - Err(e) => Err(anyhow::anyhow!( - "failed to {} {}: {}", - built.method(), - built.url(), - e, - )), + Err(e) => { + let status = e.status(); + if let Some(status) = status { + return Err(anyhow::anyhow!( + "failed to {} {}: {} - {}", + built.method(), + built.url(), + status, + e + )); + } + + Err(anyhow::anyhow!( + "failed to {} {}: {}", + built.method(), + built.url(), + e, + )) + } } } }