Compare commits

...
15 changed files with 240 additions and 20 deletions

No files matched your search

@@ -1,11 +0,0 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented below
+18
View File
@@ -0,0 +1,18 @@
### Description
<!-- What problem is this PR solving? -->
### Alternatives Considered (Optional)
<!-- What alternatives did you consider? Why did you choose this approach? -->
### AI assistance (if any)
<!-- Required if AI assistance was used to write or modify code in this PR. -->
<!-- - List tools here -->
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself. If I didn't, I gave proper attribution to AI assistance above.
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented above
+2 -2
View File
@@ -93,8 +93,8 @@ act -W .github/workflows/release.yml --input_type bump=minor
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is permitted only when explicitly disclosed and approved. For example: disclosing the use of AI-generated code in a PR description.
Submissions must certify that the contributor understands and can maintain the code they submit.
+38 -1
View File
@@ -29,7 +29,7 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built
> [!NOTE]
> The official Readarr repository has been archived, but Managarr's Readarr support is compatible with any Readarr forks
> that maintain compatibility with the official Readarr v1 API. See [Tested Readarr Forks](#tested-readarr-forks) below
> for forks that have been explicitly tested with Managarr.
> for forks that have been explicitly tested with Managarr, and any oddities in configuring them to work with Managarr.
## Try Out the Demo
To try out Managarr before linking it to your HTPC, you can use the purpose built [managarr-demo](https://github.com/Dark-Alex-17/managarr-demo) repository.
@@ -464,6 +464,11 @@ lidarr:
custom_headers: # Example of adding custom headers to all requests to the Servarr instance
traefik-auth-bypass-key: someBypassKey1234567890
SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE}
readarr:
- name: Chaptarr (Ebooks) # Example of how to use the Readarr support with a Readarr fork like Chaptarr
uri: http://192.168.0.87:8789/readarr/gr/ebook
api_token: someApiToken1234567890
```
### SSL Configuration
@@ -580,6 +585,38 @@ The following Readarr forks have been explicitly tested with Managarr:
* [Bookshelf](https://github.com/pennydreadful/bookshelf)
* [Readarr-Resurrected](https://github.com/ricetim/readarr-rresurrected)
* [Librarr](https://github.com/Rorqualx/Librarr)
* [Chaptarr](https://github.com/Chaptarr/Chaptarr)
> [!IMPORTANT]
> Chaptarr houses a dedicated Readarr facade middleware that activates on a path prefix, meaning a bare config doesn't
> work. Instead, users need to configure both Audiobooks and Ebooks separately, and based on their desired metadata
> fetcher (i.e. Goodreads and Hardcover IDs). Because of this, Chaptarr-based configurations must use the `uri` key
> rather than the `host` and `port` keys. The below configuration file demonstrates all the supported variations for
> connecting Managarr to Chaptarr.
### Chaptarr Example Configuration
```yaml
readarr:
# Because Chaptarr supports both eBooks and audiobooks, the same instance must be provisioned twice if you're using
# both so Managarr can differentiate between the two. Thus, for Chaptarr, you need an eBook server configured and an
# audiobook server configured like below.
- name: eBooks (Goodreads) # Readarr originally used Goodreads as its metadata provider
uri: http://192.168.0.87:8789/readarr/gr/ebook # Note the `gr` in the path prefix indicating "GoodReads"
api_token: someApiToken1234567890
- name: Chaptarr Audiobooks (Goodreads)
uri: http://192.168.0.105:8789/readarr/gr/audiobook
api_token: someApiToken1234567890
- name: Chaptarr eBooks (Hardcover)
uri: http://192.168.0.105:8789/readarr/hc/ebook # Note the `hc` in the path prefix indicating "HardCover"
api_token: someApiToken1234567890
- name: Chaptarr Audiobooks (Hardcover)
uri: http://192.168.0.105:8789/readarr/hc/audiobook
api_token: someApiToken1234567890
```
## Screenshots
+1
View File
@@ -171,6 +171,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH
quality_profile_id,
metadata_profile_id,
tags,
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor,
@@ -447,6 +447,7 @@ mod tests {
quality_profile_id: 1,
metadata_profile_id: 1,
tags: vec![1, 2],
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor: MonitorType::All,
@@ -88,6 +88,7 @@ impl AddAuthorHandler<'_, '_> {
quality_profile_id,
metadata_profile_id,
tags: Vec::new(),
images: Vec::new(),
tag_input_string: Some(tags),
add_options: AddAuthorOptions {
monitor,
@@ -1237,6 +1237,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -1628,6 +1629,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -1795,6 +1797,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
+26
View File
@@ -222,6 +222,8 @@ pub struct AddAuthorBody {
pub quality_profile_id: i64,
pub metadata_profile_id: i64,
pub tags: Vec<i64>,
// Readarr-Resurrected persists the add body directly and rejects it when the images list is absent
pub images: Vec<Value>,
#[serde(skip_serializing, skip_deserializing)]
pub tag_input_string: Option<String>,
pub add_options: AddAuthorOptions,
@@ -280,6 +282,7 @@ pub struct Book {
pub ratings: Option<Ratings>,
pub release_date: Option<DateTime<Utc>>,
pub statistics: Option<BookStatistics>,
#[serde(default)]
pub grabbed: bool,
}
@@ -454,6 +457,29 @@ pub struct ReadarrRelease {
pub quality: QualityWrapper,
}
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(untagged)]
pub enum ReadarrReleaseResponse {
Releases(Vec<ReadarrRelease>),
// Chaptarr wraps the release list in a filter summary envelope, even behind its Readarr facade
Envelope { releases: Vec<ReadarrRelease> },
}
impl Default for ReadarrReleaseResponse {
fn default() -> Self {
ReadarrReleaseResponse::Releases(Vec::new())
}
}
impl ReadarrReleaseResponse {
pub fn into_releases(self) -> Vec<ReadarrRelease> {
match self {
ReadarrReleaseResponse::Releases(releases)
| ReadarrReleaseResponse::Envelope { releases } => releases,
}
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BlocklistItem {
+40 -1
View File
@@ -9,7 +9,7 @@ mod tests {
AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord,
DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType,
ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrRelease,
ReadarrTask, ReadarrTaskName,
ReadarrReleaseResponse, ReadarrTask, ReadarrTaskName,
};
use crate::models::servarr_models::{
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log,
@@ -497,6 +497,29 @@ mod tests {
assert_eq!(readarr_serdeable, ReadarrSerdeable::Releases(releases));
}
#[test]
fn test_readarr_release_response_default_is_an_empty_release_list() {
assert_eq!(
ReadarrReleaseResponse::default(),
ReadarrReleaseResponse::Releases(Vec::new())
);
}
#[test]
fn test_readarr_release_response_into_releases() {
let releases = vec![ReadarrRelease {
guid: "test".to_owned(),
..ReadarrRelease::default()
}];
let list = ReadarrReleaseResponse::Releases(releases.clone());
let envelope = ReadarrReleaseResponse::Envelope {
releases: releases.clone(),
};
assert_eq!(list.into_releases(), releases);
assert_eq!(envelope.into_releases(), releases);
}
#[test]
fn test_readarr_serdeable_from_security_config() {
let security_config = SecurityConfig {
@@ -939,6 +962,22 @@ mod tests {
assert_none!(&book.statistics);
}
#[test]
fn test_book_deserialization_defaults_grabbed_when_absent() {
let book_json = json!({
"id": 1,
"title": "The Name of the Wind",
"authorId": 3,
"foreignBookId": "test-foreign-book-id",
"monitored": true
});
let book: Book = serde_json::from_value(book_json).unwrap();
assert!(!book.grabbed);
assert!(book.monitored);
}
#[test]
fn test_edition_deserialization() {
let edition_json = json!({
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use crate::models::Route;
use crate::models::readarr_models::{
AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody,
ReadarrHistoryItem, ReadarrRelease,
ReadarrHistoryItem, ReadarrRelease, ReadarrReleaseResponse,
};
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::models::stateful_table::StatefulTable;
@@ -235,10 +235,15 @@ impl Network<'_, '_> {
.await;
self
.handle_request::<(), Vec<ReadarrRelease>>(request_props, |release_vec, mut app| {
app.data.readarr_data.author_releases.set_items(release_vec);
.handle_request::<(), ReadarrReleaseResponse>(request_props, |response, mut app| {
app
.data
.readarr_data
.author_releases
.set_items(response.into_releases());
})
.await
.map(ReadarrReleaseResponse::into_releases)
}
pub(in crate::network::readarr_network) async fn get_readarr_author_history(
@@ -133,6 +133,48 @@ mod tests {
);
}
#[tokio::test]
async fn test_handle_get_author_releases_event_unwraps_the_release_envelope() {
let expected_releases = vec![torrent_release()];
let releases_json = json!({
"releases": [serde_json::from_str::<Value>(RELEASE_JSON).unwrap()],
"hiddenReleases": [],
"filterSummary": { "totalResults": 1, "displayedCount": 1 },
"siblingToggleEnabled": false
});
let (mock, app, _server) = MockServarrApi::get()
.returns(releases_json)
.query("authorId=3")
.build_for(ReadarrEvent::GetAuthorReleases(3))
.await;
{
let mut app = app.lock().await;
app.server_tabs.set_index(3);
app
.data
.readarr_data
.author_releases
.set_items(vec![stale_release()]);
}
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::GetAuthorReleases(3))
.await;
mock.assert_async().await;
let ReadarrSerdeable::Releases(releases) = result.unwrap() else {
panic!("Expected Releases")
};
assert_eq!(releases, expected_releases);
assert_eq!(
app.lock().await.data.readarr_data.author_releases.items,
expected_releases
);
}
#[tokio::test]
async fn test_handle_get_author_releases_event_failure() {
let stale_releases = vec![stale_release()];
@@ -638,6 +680,7 @@ mod tests {
"qualityProfileId": 1,
"metadataProfileId": 1,
"tags": [1, 2],
"images": [],
"addOptions": {
"monitor": "all",
"monitorNewItems": "all",
@@ -680,6 +723,7 @@ mod tests {
"qualityProfileId": 1,
"metadataProfileId": 1,
"tags": [1, 2],
"images": [],
"addOptions": {
"monitor": "all",
"monitorNewItems": "all",
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use crate::models::Route;
use crate::models::readarr_models::{
Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, ReadarrRelease,
ReadarrReleaseResponse,
};
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::network::readarr_network::ReadarrEvent;
@@ -170,16 +171,19 @@ impl Network<'_, '_> {
.await;
self
.handle_request::<(), Vec<ReadarrRelease>>(request_props, |release_vec, mut app| {
.handle_request::<(), ReadarrReleaseResponse>(request_props, |response, mut app| {
let book_details_modal = app
.data
.readarr_data
.book_details_modal
.get_or_insert_default();
book_details_modal.book_releases.set_items(release_vec);
book_details_modal
.book_releases
.set_items(response.into_releases());
})
.await
.map(ReadarrReleaseResponse::into_releases)
}
pub(in crate::network::readarr_network) async fn get_books(
@@ -532,6 +532,57 @@ mod tests {
);
}
#[tokio::test]
async fn test_handle_get_book_releases_event_unwraps_the_release_envelope() {
let expected_releases = vec![torrent_release()];
let releases_json = json!({
"releases": [serde_json::from_str::<Value>(RELEASE_JSON).unwrap()],
"hiddenReleases": [],
"filterSummary": { "totalResults": 1, "displayedCount": 1 },
"siblingToggleEnabled": false
});
let (mock, app, _server) = MockServarrApi::get()
.returns(releases_json)
.query("bookId=7")
.build_for(ReadarrEvent::GetBookReleases(7))
.await;
{
let mut app = app.lock().await;
app.server_tabs.set_index(3);
let mut book_details_modal = BookDetailsModal::default();
book_details_modal
.book_releases
.set_items(vec![stale_release()]);
app.data.readarr_data.book_details_modal = Some(book_details_modal);
}
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::GetBookReleases(7))
.await;
mock.assert_async().await;
let ReadarrSerdeable::Releases(releases) = result.unwrap() else {
panic!("Expected Releases")
};
assert_eq!(releases, expected_releases);
assert_eq!(
app
.lock()
.await
.data
.readarr_data
.book_details_modal
.as_ref()
.unwrap()
.book_releases
.items,
expected_releases
);
}
#[tokio::test]
async fn test_handle_get_book_releases_event_empty_book_details_modal() {
let expected_releases = vec![torrent_release()];
@@ -394,6 +394,7 @@ pub mod test_utils {
quality_profile_id: 1,
metadata_profile_id: 1,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::All,