Skip to content

Commit ca9fdc3

Browse files
authored
feat(targets): add audiobookshelf (#207)
1 parent b9bc10a commit ca9fdc3

File tree

4 files changed

+194
-6
lines changed

4 files changed

+194
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ We use the following terminology:
5555
- Radarr
5656
- Tdarr
5757
- FileFlows
58+
- Audiobookshelf
5859
- Another autopulse instance
5960

6061
#### Example Flow

crates/service/src/settings/auth.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const fn default_enabled() -> bool {
2020
pub struct Auth {
2121
/// Whether authentication is enabled (default: true)
2222
#[serde(default = "default_enabled")]
23+
#[serde(skip_serializing)]
2324
pub enabled: bool,
2425
/// Username for basic auth (default: admin)
2526
#[serde(default = "default_username")]
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use super::RequestBuilderPerform;
2+
use crate::settings::rewrite::Rewrite;
3+
use crate::settings::targets::TargetProcess;
4+
use autopulse_database::models::ScanEvent;
5+
use autopulse_utils::get_url;
6+
use reqwest::header;
7+
use serde::{Deserialize, Serialize};
8+
use tracing::{debug, error};
9+
10+
#[derive(Clone, Deserialize, Serialize)]
11+
pub struct Audiobookshelf {
12+
/// URL to the audiobookshelf instance
13+
pub url: String,
14+
// /// Authentication credentials
15+
// pub auth: Auth,
16+
/// API token for Audiobookshelf Server (see https://www.audiobookshelf.org/guides/api-keys)
17+
pub token: String,
18+
/// Rewrite path for the file
19+
pub rewrite: Option<Rewrite>,
20+
}
21+
22+
#[derive(Debug, Deserialize)]
23+
pub struct LibraryFolder {
24+
#[serde(rename = "fullPath")]
25+
pub full_path: String,
26+
#[serde(rename = "libraryId")]
27+
pub library_id: String,
28+
}
29+
30+
#[derive(Debug, Deserialize)]
31+
pub struct Library {
32+
pub folders: Vec<LibraryFolder>,
33+
}
34+
35+
#[derive(Debug, Deserialize)]
36+
pub struct LibrariesResponse {
37+
pub libraries: Vec<Library>,
38+
}
39+
40+
impl Audiobookshelf {
41+
async fn get_client(&self) -> anyhow::Result<reqwest::Client> {
42+
let mut headers = header::HeaderMap::new();
43+
44+
// if self.auth.enabled {
45+
// if let Some(token) = token {
46+
// headers.insert("Authorization", format!("Bearer {token}").parse()?);
47+
// }
48+
// }
49+
headers.insert("Authorization", format!("Bearer {}", self.token).parse()?);
50+
51+
reqwest::Client::builder()
52+
.timeout(std::time::Duration::from_secs(10))
53+
.default_headers(headers)
54+
.build()
55+
.map_err(Into::into)
56+
}
57+
58+
// async fn login(&self) -> anyhow::Result<String> {
59+
// let client = self.get_client(None).await?;
60+
// let url = get_url(&self.url)?.join("login")?;
61+
62+
// let res = client
63+
// .post(url)
64+
// .header("Content-Type", "application/json")
65+
// .json(&self.auth)
66+
// .perform()
67+
// .await?;
68+
69+
// let body: AudiobookshelfLoginResponse = res.json().await?;
70+
71+
// Ok(body.user.token)
72+
// }
73+
74+
async fn scan(&self, ev: &ScanEvent, library_id: String) -> anyhow::Result<()> {
75+
let client = self.get_client().await?;
76+
let url = get_url(&self.url)?.join("api/watcher/update")?;
77+
78+
client
79+
.post(url)
80+
.header("Content-Type", "application/json")
81+
.json(&serde_json::json!({
82+
"libraryId": library_id,
83+
"path": ev.get_path(&self.rewrite),
84+
// audiobookshelf will scan for the changes so del/rename *should* be handled
85+
// https://github.com/mikiher/audiobookshelf/blob/master/server/Watcher.js#L268
86+
"type": "add"
87+
}))
88+
.perform()
89+
.await
90+
.map(|_| ())
91+
}
92+
93+
async fn get_libraries(&self) -> anyhow::Result<Vec<Library>> {
94+
let client = self.get_client().await?;
95+
96+
let url = get_url(&self.url)?.join("api/libraries")?;
97+
98+
let res = client.get(url).perform().await?;
99+
100+
let body: LibrariesResponse = res.json().await?;
101+
102+
Ok(body.libraries)
103+
}
104+
105+
async fn choose_library(
106+
&self,
107+
ev: &ScanEvent,
108+
libraries: &[Library],
109+
) -> anyhow::Result<Option<String>> {
110+
for library in libraries {
111+
for folder in library.folders.iter() {
112+
if ev.get_path(&self.rewrite).starts_with(&folder.full_path) {
113+
debug!("found library: {}", folder.library_id);
114+
return Ok(Some(folder.library_id.clone()));
115+
}
116+
}
117+
}
118+
119+
Ok(None)
120+
}
121+
}
122+
123+
impl TargetProcess for Audiobookshelf {
124+
async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
125+
let mut succeeded = Vec::new();
126+
127+
let libraries = self.get_libraries().await?;
128+
129+
if libraries.is_empty() {
130+
error!("no libraries found");
131+
return Ok(succeeded);
132+
}
133+
134+
for ev in evs {
135+
match self.choose_library(ev, &libraries).await {
136+
Ok(Some(library_id)) => {
137+
if let Err(e) = self.scan(ev, library_id).await {
138+
error!("failed to scan audiobookshelf: {}", e);
139+
} else {
140+
succeeded.push(ev.id.clone());
141+
}
142+
}
143+
Ok(None) => {
144+
error!("no library found for {}", ev.get_path(&self.rewrite));
145+
}
146+
Err(e) => {
147+
error!("failed to choose library: {}", e);
148+
}
149+
}
150+
}
151+
152+
Ok(succeeded)
153+
}
154+
}

crates/service/src/settings/targets/mod.rs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/// Audiobookshelf - Audiobookshelf target
2+
///
3+
/// This target is used to send a file to the Audiobookshelf watcher
4+
///
5+
/// # Example
6+
///
7+
/// ```yml
8+
/// targets:
9+
/// audiobookshelf:
10+
/// type: audiobookshelf
11+
/// url: http://localhost:13378
12+
/// token: "<API_KEY>"
13+
/// ```
14+
///
15+
/// See [`Audiobookshelf`] for all options
16+
pub mod audiobookshelf;
117
/// Autopulse - Autopulse target
218
///
319
/// This target is used to process a file in another instance of Autopulse
@@ -169,6 +185,7 @@ pub mod sonarr;
169185
/// See [`Tdarr`] for all options
170186
pub mod tdarr;
171187

188+
use audiobookshelf::Audiobookshelf;
172189
use autopulse_database::models::ScanEvent;
173190
use reqwest::{RequestBuilder, Response};
174191
use serde::{Deserialize, Serialize};
@@ -203,6 +220,7 @@ pub enum Target {
203220
Command(Command),
204221
FileFlows(FileFlows),
205222
Autopulse(Autopulse),
223+
Audiobookshelf(Audiobookshelf),
206224
}
207225

208226
pub trait TargetProcess {
@@ -223,6 +241,7 @@ impl TargetProcess for Target {
223241
Self::Radarr(t) => t.process(evs).await,
224242
Self::FileFlows(t) => t.process(evs).await,
225243
Self::Autopulse(t) => t.process(evs).await,
244+
Self::Audiobookshelf(t) => t.process(evs).await,
226245
}
227246
}
228247
}
@@ -260,12 +279,25 @@ impl RequestBuilderPerform for RequestBuilder {
260279
Ok(response)
261280
}
262281

263-
Err(e) => Err(anyhow::anyhow!(
264-
"failed to {} {}: {}",
265-
built.method(),
266-
built.url(),
267-
e,
268-
)),
282+
Err(e) => {
283+
let status = e.status();
284+
if let Some(status) = status {
285+
return Err(anyhow::anyhow!(
286+
"failed to {} {}: {} - {}",
287+
built.method(),
288+
built.url(),
289+
status,
290+
e
291+
));
292+
}
293+
294+
Err(anyhow::anyhow!(
295+
"failed to {} {}: {}",
296+
built.method(),
297+
built.url(),
298+
e,
299+
))
300+
}
269301
}
270302
}
271303
}

0 commit comments

Comments
 (0)