Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ We use the following terminology:
- Radarr
- Tdarr
- FileFlows
- Audiobookshelf
- Another autopulse instance

#### Example Flow
Expand Down
1 change: 1 addition & 0 deletions crates/service/src/settings/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
154 changes: 154 additions & 0 deletions crates/service/src/settings/targets/audiobookshelf.rs
Original file line number Diff line number Diff line change
@@ -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<Rewrite>,
}

#[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<LibraryFolder>,
}

#[derive(Debug, Deserialize)]
pub struct LibrariesResponse {
pub libraries: Vec<Library>,
}

impl Audiobookshelf {
async fn get_client(&self) -> anyhow::Result<reqwest::Client> {
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<String> {
// 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<Vec<Library>> {
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<Option<String>> {
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<Vec<String>> {
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)
}
}
44 changes: 38 additions & 6 deletions crates/service/src/settings/targets/mod.rs
Original file line number Diff line number Diff line change
@@ -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: "<API_KEY>"
/// ```
///
/// See [`Audiobookshelf`] for all options
pub mod audiobookshelf;
/// Autopulse - Autopulse target
///
/// This target is used to process a file in another instance of Autopulse
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -203,6 +220,7 @@ pub enum Target {
Command(Command),
FileFlows(FileFlows),
Autopulse(Autopulse),
Audiobookshelf(Audiobookshelf),
}

pub trait TargetProcess {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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,
))
}
}
}
}
Loading