Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 3 additions & 2 deletions crates/service/src/settings/auth.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use base64::prelude::*;
use serde::Deserialize;
use serde::{Deserialize, Serialize};

#[doc(hidden)]
fn default_username() -> String {
Expand All @@ -16,10 +16,11 @@ const fn default_enabled() -> bool {
true
}

#[derive(Deserialize, Clone, Debug)]
#[derive(Deserialize, Clone, Debug, Serialize)]
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
163 changes: 163 additions & 0 deletions crates/service/src/settings/targets/audiobookshelf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use super::RequestBuilderPerform;
use crate::settings::rewrite::Rewrite;
use crate::settings::{auth::Auth, targets::TargetProcess};
use autopulse_database::models::ScanEvent;
use autopulse_utils::get_url;
use reqwest::header;
use serde::Deserialize;
use tracing::{debug, error};

#[derive(Clone, Deserialize)]
pub struct Audiobookshelf {
/// URL to the audiobookshelf instance
pub url: String,
/// Authentication credentials
pub auth: Auth,
/// Rewrite path for the file
pub rewrite: Option<Rewrite>,
}

#[derive(Clone, Deserialize)]
struct AudiobookshelfUser {
token: String,
}

#[doc(hidden)]
#[derive(Deserialize)]
struct AudiobookshelfLoginResponse {
user: AudiobookshelfUser,
}

#[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, token: Option<String>) -> 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()?);
}
}

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, token: String, ev: &ScanEvent, library_id: String) -> anyhow::Result<()> {
let client = self.get_client(Some(token)).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, token: String) -> anyhow::Result<Vec<Library>> {
let client = self.get_client(Some(token)).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 token = self.login().await?;

let libraries = self.get_libraries(token.clone()).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(token.clone(), ev, library_id).await {
error!("failed to scan audiobookshelf: {}", e);
} else {
succeeded.push(ev.get_path(&self.rewrite));
}
}
Ok(None) => {
error!("no library found for {}", ev.get_path(&self.rewrite));
}
Err(e) => {
error!("failed to choose library: {}", e);
}
}
}

Ok(succeeded)
}
}
6 changes: 3 additions & 3 deletions crates/service/src/settings/targets/autopulse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ impl Autopulse {

impl TargetProcess for Autopulse {
async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
let mut succeded = Vec::new();
let mut succeeded = Vec::new();

for ev in evs {
match self.scan(ev).await {
Ok(()) => {
succeded.push(ev.id.clone());
succeeded.push(ev.id.clone());
debug!("file scanned: {}", ev.get_path(&self.rewrite));
}
Err(e) => {
Expand All @@ -68,6 +68,6 @@ impl TargetProcess for Autopulse {
}
}

Ok(succeded)
Ok(succeeded)
}
}
6 changes: 3 additions & 3 deletions crates/service/src/settings/targets/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,16 @@ impl Command {

impl TargetProcess for Command {
async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
let mut succeded = Vec::new();
let mut succeeded = Vec::new();

for ev in evs {
if let Err(e) = self.run(ev).await {
error!("failed to process '{}': {}", ev.get_path(&self.rewrite), e);
} else {
succeded.push(ev.id.clone());
succeeded.push(ev.id.clone());
}
}

Ok(succeded)
Ok(succeeded)
}
}
8 changes: 4 additions & 4 deletions crates/service/src/settings/targets/emby.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ impl TargetProcess for Emby {
.await
.context("failed to fetch libraries")?;

let mut succeded = Vec::new();
let mut succeeded = Vec::new();

let mut to_find = HashMap::new();
let mut to_refresh = Vec::new();
Expand Down Expand Up @@ -388,7 +388,7 @@ impl TargetProcess for Emby {
match self.refresh_item(&item).await {
Ok(()) => {
debug!("refreshed item: {}", item.id);
succeded.push(ev.id.clone());
succeeded.push(ev.id.clone());
}
Err(e) => {
error!("failed to refresh item: {}", e);
Expand All @@ -407,8 +407,8 @@ impl TargetProcess for Emby {
}
}

succeded.extend(to_scan.iter().map(|ev| ev.id.clone()));
succeeded.extend(to_scan.iter().map(|ev| ev.id.clone()));

Ok(succeded)
Ok(succeeded)
}
}
46 changes: 40 additions & 6 deletions crates/service/src/settings/targets/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
/// 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
/// auth:
/// username: "admin"
/// password: "password"
/// ```
///
/// 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 +187,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;
Expand All @@ -189,6 +208,7 @@ pub enum Target {
Command(Command),
FileFlows(FileFlows),
Autopulse(Autopulse),
Audiobookshelf(Audiobookshelf),
}

pub trait TargetProcess {
Expand All @@ -209,6 +229,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 @@ -246,12 +267,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