Compare commits

..
Author SHA1 Message Date
Dark-Alex-17 003f80098a docs: Updated the pull request template 2026-09-30 00:11:56 -06:00
Dark-Alex-17 ad8a6743ab docs: Added documentation on how to configure Chaptarr in Managarr 2026-09-30 00:03:29 -06:00
Dark-Alex-17 c61404dc71 docs: Updated the contribution guidelines and PR template to sound more like AI is allowed but only when disclosed 2026-09-29 23:46:40 -06:00
Dark-Alex-17 a5b95c165a fix(readarr): send an empty images list when adding an author 2026-09-29 23:36:11 -06:00
Dark-Alex-17 8ec4ec018b fix(readarr): accept release searches wrapped in a filter summary envelope 2026-09-29 23:30:30 -06:00
Dark-Alex-17 642a5c0a87 fix(readarr): tolerate book payloads that omit the grabbed flag 2026-09-29 23:25:19 -06:00
Dark-Alex-17 9128e68d0a test(readarr): cover the CLI handler dispatch to each subcommand handler 2026-09-29 16:52:53 -06:00
Dark-Alex-17 ddc18ae21a docs: Updated the README to mention that Readarr has been sunset but Managarr is compatible with any APIs that are compatible with Readarr v1 2026-09-29 15:54:47 -06:00
Dark-Alex-17 4b8b9cde57 docs: Updated README with screenshots of Readarr 2026-09-29 14:24:00 -06:00
Dark-Alex-17 238348f6ec fix(radarr): render the movie overview one size down so the collection details stay visible behind it 2026-09-29 13:49:28 -06:00
Dark-Alex-17 37f697d1b4 docs: reformatted tables in README 2026-09-29 13:47:04 -06:00
Dark-Alex-17 a70019b88f Merge remote-tracking branch 'origin/main' into readarr-support
# Conflicts:
#	src/utils_tests.rs
2026-09-29 12:46:54 -06:00
Alex Clarke fa02b70bf0 Merge pull request #64 from solareon/fix/servarr_name
fix: passes servarr-name global switch to both the tui and cli
2026-09-29 10:54:20 -06:00
Solareon 76f9f4da9a test: remove disable-spinner to improve test coverage 2026-09-29 11:05:37 +02:00
Solareon 49be500b8c style: cargo fmt and nested if fix 2026-09-29 10:44:39 +02:00
Solareon 78918b6d88 test: ensure no servarr-name select first configured instance 2026-09-29 10:40:11 +02:00
Solareon 9b98e53b55 test: servarr-name with whitespace is trimmed properly 2026-09-29 10:31:01 +02:00
Solareon b799fe3865 test: unknown servarr-name fails properly 2026-09-29 10:21:00 +02:00
Solareon 0e869e1e98 test: ensure duplicate servarr names select correct instance 2026-09-29 10:05:37 +02:00
Solareon 196bc55f35 test: validate selected servarr-name type mismatch 2026-09-29 10:00:24 +02:00
Solareon e29bd67a3e test: happy path test for servarr-name 2026-09-29 08:55:04 +02:00
Dark-Alex-17 424ed62229 chore: update dependencies ahead of release 2026-09-28 16:02:28 -06:00
Dark-Alex-17 ab7542651b chore: bump GitHub Actions to Node 24 runtimes 2026-09-28 15:53:26 -06:00
solareon 699e929ed2 fix(app/mod): use correct api for tab when starting on non default tab
Further testing of the servarr_name switch revealed an issue where selecting a server that didn't match the first tab caused the wrong api to be used. Now when a non-default tab is selected the correct api is used
2026-09-25 07:44:51 -04:00
solareon 5279d37ea2 fix: passes servarr-name global switch to both the tui and cli
servarr-name global switch was documented but not fully implemented in commit fd6fcfc. this commit fully implements the feature
2026-09-25 07:30:01 -04:00
38 changed files with 1651 additions and 749 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
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -46,7 +46,7 @@ jobs:
toolchain: [stable, beta]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -54,7 +54,7 @@ jobs:
components: clippy
- name: Run clippy action
uses: clechasseur/rs-clippy-check@v3
uses: clechasseur/rs-clippy-check@v5
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
@@ -65,7 +65,7 @@ jobs:
name: nightly / doc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
@@ -79,7 +79,7 @@ jobs:
name: 1.95.0 / check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install 1.95.0
uses: dtolnay/rust-toolchain@master
+28 -28
View File
@@ -32,19 +32,19 @@ jobs:
- name: Checkout repository
if: env.ACT != 'true'
uses: actions/checkout@v3
uses: actions/checkout@v5
with:
ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }}
fetch-depth: 0
- name: Checkout repository
if: env.ACT == 'true'
uses: actions/checkout@v3
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.10'
@@ -219,13 +219,13 @@ jobs:
git push origin --follow-tags
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: artifacts
- name: Upload the changed Cargo files (Act)
if: env.ACT == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: bumped-cargo-files
path: |
@@ -267,7 +267,7 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -279,18 +279,18 @@ jobs:
- name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
- uses: actions/cache@v3
- uses: actions/cache@v5
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3
- uses: actions/cache@v5
if: startsWith(matrix.job.name, 'linux-')
with:
path: ~/.cargo/bin
@@ -375,7 +375,7 @@ jobs:
cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: artifacts-${{ env.RELEASE_NAME }}
path: artifacts
@@ -393,12 +393,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -421,7 +421,7 @@ jobs:
- name: Create a GitHub Release
if: env.ACT != 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -457,7 +457,7 @@ jobs:
prerelease: false
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: artifacts
overwrite: true
@@ -474,12 +474,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -528,12 +528,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -592,12 +592,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -618,20 +618,20 @@ jobs:
echo "Release version: ${{ env.version }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
if: env.ACT != 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to Docker Hub
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -651,13 +651,13 @@ jobs:
exit 1
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
@@ -668,13 +668,13 @@ jobs:
git fetch --all
git pull
- uses: actions/cache@v3
- uses: actions/cache@v5
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3
- uses: actions/cache@v5
with:
path: ~/.cargo/bin
key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }}
+5 -5
View File
@@ -33,7 +33,7 @@ jobs:
# toolchain
toolchain: [stable, beta]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@master
@@ -74,7 +74,7 @@ jobs:
# runs-on: ubuntu-latest
# name: ubuntu / stable / minimal-versions
# steps:
# - uses: actions/checkout@v4
# - uses: actions/checkout@v5
# - name: Install Rust stable
# uses: dtolnay/rust-toolchain@stable
@@ -110,7 +110,7 @@ jobs:
# - run: vcpkg install openssl:x64-windows-static-md
# if: runner.os == 'Windows'
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -151,7 +151,7 @@ jobs:
name: ubuntu / stable / coverage
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -176,7 +176,7 @@ jobs:
- name: Upload to codecov.io
if: env.ACT != 'true'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v7
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+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.
Generated
+473 -467
View File
File diff suppressed because it is too large. Load diff
+121 -64
View File
@@ -26,6 +26,11 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built
- [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/)
- [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/)
> [!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, 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.
Simply run the following command to start a demo:
@@ -134,8 +139,8 @@ Key:
| Symbol | Status |
|--------|-----------|
| ✅ | Supported |
| ❌ | Missing |
| ✅ | Supported |
| ❌ | Missing |
| 🕒 | Planned |
| 🚫 | Won't Add |
@@ -143,82 +148,82 @@ Key:
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Sonarr
| TUI | CLI | Feature |
|-----|-----|--------------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Lidarr
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, tracks |
| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add artists to your library |
| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files |
| ✅ | ✅ | Trigger automatic searches for artists or albums |
| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads |
| ✅ | ✅ | Manually search for full artist discographies or albums |
| ✅ | ✅ | Edit your artists and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, blocklist, tracks |
| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add artists to your library |
| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files |
| ✅ | ✅ | Trigger automatic searches for artists or albums |
| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads |
| ✅ | ✅ | Manually search for full artist discographies or albums |
| ✅ | ✅ | Edit your artists and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Readarr
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, editions |
| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add authors to your library |
| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files |
| ✅ | ✅ | Trigger automatic searches for authors or books |
| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads |
| ✅ | ✅ | Manually search for author releases or books |
| ✅ | ✅ | Edit your authors and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, blocklist, editions |
| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add authors to your library |
| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files |
| ✅ | ✅ | Trigger automatic searches for authors or books |
| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads |
| ✅ | ✅ | Manually search for author releases or books |
| ✅ | ✅ | Edit your authors and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Whisparr
@@ -261,7 +266,7 @@ To see all available commands, simply run `managarr --help`:
```shell
$ managarr --help
managarr 0.7.0
managarr 0.8.0
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
@@ -459,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
@@ -568,6 +578,46 @@ Managarr supports using environment variables on startup so you don't have to al
| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` |
| `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` |
## Tested Readarr Forks
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
### Radarr
@@ -590,6 +640,13 @@ Managarr supports using environment variables on startup so you don't have to al
![artist_discography_search](screenshots/lidarr/artist_discography_search.png)
![manual_album_search](screenshots/lidarr/manual_album_search.png)
### Readarr
![readarr_library](screenshots/readarr/readarr_library.png)
![author_details](screenshots/readarr/author_details.png)
![book_details](screenshots/readarr/book_details.png)
![manual_author_search](screenshots/readarr/manual_author_search.png)
![manual_album_search](screenshots/readarr/manual_book_search.png)
### General
![logs](screenshots/radarr/logs.png)
![indexers](screenshots/radarr/indexers.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+31
View File
@@ -16,6 +16,7 @@ mod tests {
use crate::models::{HorizontallyScrollableText, TabRoute};
use crate::network::NetworkEvent;
use crate::network::radarr_network::RadarrEvent;
use crate::network::sonarr_network::SonarrEvent;
use tokio_util::sync::CancellationToken;
#[test]
@@ -165,6 +166,36 @@ mod tests {
assert!(app.is_routing);
}
#[tokio::test]
async fn test_named_tab_uses_selected_servarr_route_and_network_events() {
let (network_tx, mut network_rx) = mpsc::channel(500);
let mut app = App::new(
network_tx,
AppConfig {
radarr: Some(vec![ServarrConfig {
name: Some("Movies".to_owned()),
..ServarrConfig::default()
}]),
sonarr: Some(vec![ServarrConfig {
name: Some("Shows".to_owned()),
..ServarrConfig::default()
}]),
..AppConfig::default()
},
CancellationToken::new(),
);
assert!(app.server_tabs.select_tab_by_title("Shows"));
assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into());
app.on_tick().await;
assert_eq!(
network_rx.recv().await.unwrap(),
SonarrEvent::GetQualityProfiles.into()
);
}
#[test]
fn test_reset_cancellation_token() {
let mut app = App {
+5 -8
View File
@@ -263,14 +263,11 @@ impl App<'_> {
}
pub fn get_current_route(&self) -> Route {
*self.navigation_stack.last().unwrap_or(
&self
.server_tabs
.tabs
.first()
.expect("At least one server tab must exist")
.route,
)
self
.navigation_stack
.last()
.copied()
.unwrap_or_else(|| self.server_tabs.get_active_route())
}
}
+44
View File
@@ -9,7 +9,9 @@ mod tests {
use tokio::sync::Mutex;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::readarr::ReadarrCommand;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::readarr_network::ReadarrEvent;
use crate::{
Cli,
app::App,
@@ -24,6 +26,10 @@ mod tests {
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable,
},
readarr_models::{
BlocklistItem as ReadarrBlocklistItem, BlocklistResponse as ReadarrBlocklistResponse,
ReadarrSerdeable,
},
sonarr_models::{
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable,
@@ -68,6 +74,13 @@ mod tests {
assert_ok!(&result);
}
#[test]
fn test_readarr_subcommand_delegates_to_readarr() {
let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "authors"]);
assert_ok!(&result);
}
#[test]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -218,4 +231,35 @@ mod tests {
assert_ok!(&result);
}
#[tokio::test]
async fn test_cli_handler_delegates_readarr_commands_to_the_readarr_cli_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse(
ReadarrBlocklistResponse {
records: vec![ReadarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = ReadarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert_ok!(&result);
}
}
+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,
+244 -3
View File
@@ -1,5 +1,20 @@
#[cfg(test)]
mod tests {
use crate::cli::{
Command,
readarr::{ReadarrCommand, list_command_handler::ReadarrListCommand},
};
use pretty_assertions::assert_eq;
#[test]
fn test_readarr_command_from() {
let command = ReadarrCommand::List(ReadarrListCommand::Authors);
let result = Command::from(command.clone());
assert_eq!(result, Command::Readarr(command));
}
mod cli {
use clap::CommandFactory;
use clap::error::ErrorKind;
@@ -243,20 +258,246 @@ mod tests {
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::readarr::add_command_handler::ReadarrAddCommand;
use crate::cli::readarr::edit_command_handler::ReadarrEditCommand;
use crate::cli::readarr::get_command_handler::ReadarrGetCommand;
use crate::cli::readarr::refresh_command_handler::ReadarrRefreshCommand;
use crate::cli::readarr::trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand;
use crate::models::readarr_models::{
BlocklistItem, BlocklistResponse, ReadarrSerdeable, ReadarrTaskName,
Author, BlocklistItem, BlocklistResponse, DeleteParams, ReadarrSerdeable, ReadarrTaskName,
};
use crate::models::servarr_models::ReleaseDownloadBody;
use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody};
use crate::{
app::App,
cli::{
CliCommandHandler,
readarr::{ReadarrCliHandler, ReadarrCommand},
readarr::{
ReadarrCliHandler, ReadarrCommand, delete_command_handler::ReadarrDeleteCommand,
list_command_handler::ReadarrListCommand,
},
},
models::Serdeable,
network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent},
};
#[tokio::test]
async fn test_readarr_cli_handler_delegates_add_commands_to_the_add_command_handler() {
let expected_tag_name = "test".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = ReadarrCommand::Add(ReadarrAddCommand::Tag {
name: expected_tag_name,
});
let result = ReadarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_get_commands_to_the_get_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = ReadarrCommand::Get(ReadarrGetCommand::SystemStatus);
let result = ReadarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_delete_author_params = DeleteParams {
id: 7,
delete_files: true,
add_import_list_exclusion: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::DeleteAuthor(expected_delete_author_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_author_command = ReadarrCommand::Delete(ReadarrDeleteCommand::Author {
author_id: 7,
delete_files_from_disk: true,
add_list_exclusion: false,
});
let result = ReadarrCliHandler::with(&app_arc, delete_author_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() {
let expected_edit_all_indexer_settings = IndexerSettings {
id: 1,
maximum_size: 26500,
minimum_age: 17,
retention: 43,
rss_sync_interval: 35,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings(
IndexerSettings {
id: 9,
maximum_size: 31200,
minimum_age: 22,
retention: 58,
rss_sync_interval: 90,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command =
ReadarrCommand::Edit(ReadarrEditCommand::AllIndexerSettings {
maximum_size: Some(26500),
minimum_age: Some(17),
retention: Some(43),
rss_sync_interval: Some(35),
});
let result = ReadarrCliHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_list_commands_to_the_list_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::ListAuthors.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Authors(vec![
Author::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_authors_command = ReadarrCommand::List(ReadarrListCommand::Authors);
let result = ReadarrCliHandler::with(&app_arc, list_authors_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::UpdateAllAuthors.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_all_authors_command = ReadarrCommand::Refresh(ReadarrRefreshCommand::AllAuthors);
let result =
ReadarrCliHandler::with(&app_arc, refresh_all_authors_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler()
{
let expected_author_id = 7;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::TriggerAutomaticAuthorSearch(expected_author_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
ReadarrCommand::TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand::Author {
author_id: expected_author_id,
});
let result = ReadarrCliHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_mark_history_item_as_failed_command() {
let expected_history_item_id = 1234i64;
@@ -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,
+29 -2
View File
@@ -171,13 +171,40 @@ async fn main() -> Result<()> {
cancellation_token.clone(),
)));
if args.command.is_none()
&& let Some(name) = args.global.servarr_name.as_deref()
{
let mut app = app.lock().await;
anyhow::ensure!(
app.server_tabs.select_tab_by_title(name.trim()),
"A Servarr titled '{}' was not found in your configuration file",
name.trim()
);
}
match args.command {
Some(command) => match command {
Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) | Command::Readarr(_) => {
if spinner_disabled {
start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await;
start_cli_no_spinner(
config,
reqwest_client,
cancellation_token,
app,
command,
args.global.servarr_name.as_deref(),
)
.await;
} else {
start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await;
start_cli_with_spinner(
config,
reqwest_client,
cancellation_token,
app,
command,
args.global.servarr_name.as_deref(),
)
.await;
}
}
Command::Completions { shell } => {
+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,
@@ -46,7 +46,7 @@ impl DrawUi for CollectionDetailsUi {
draw_popup(f, app, draw_collection_details, Size::Large);
if context_option.unwrap_or(active_radarr_block) == ActiveRadarrBlock::ViewMovieOverview {
draw_popup(f, app, draw_movie_overview, Size::Large);
draw_popup(f, app, draw_movie_overview, Size::Medium);
}
}
}
@@ -8,40 +8,40 @@ expression: output
╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Collection movie blah blah blah │
│ │
│It was shot in Madison, Wisconsin: a city the cast never really left. │
│ │
│The sequel took seven years to finish. │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Overview: Collection blah blah blah │
│Root Folder Path: /nfs/movies │
│Quality Profile: HD - 1080p │
│Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │
│Monitored: Y│Collection movie blah blah blah │ │
│Search on Ad│ │ │
│ │It was shot in Madison, Wisconsin: a city the cast never really left. │ │
│ │ │ │
│ │The sequel took seven years to finish. │ │
│ Movies ──│ │───────────│
│ ✔ Title │ │ │
│=> ✔ Test │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ │
@@ -8,40 +8,40 @@ expression: output
╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ Loading ... │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Overview: Collection blah blah blah │
│Root Folder Path: /nfs/movies │
│Quality Profile: HD - 1080p │
│Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │
│Monitored: Y│ │ │
│Search on Ad│ │ │
│ │ Loading ... │ │
│ │ │ │
│ │ │ │
│ Movies ──│ │───────────│
│ │ │ │
│ │ │ │
│ Loading ...│ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ │
@@ -8,40 +8,40 @@ expression: output
╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ Loading ... │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Overview: Collection blah blah blah │
│Root Folder Path: /nfs/movies │
│Quality Profile: HD - 1080p │
│Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │
│Monitored: Y│ │ │
│Search on Ad│ │ │
│ │ Loading ... │ │
│ │ │ │
│ │ │ │
│ Movies ──│ │───────────│
│ ✔ Title │ │ │
│=> ✔ Test │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ │
@@ -8,40 +8,40 @@ expression: output
╭ Overview ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│It was shot in Madison, Wisconsin: a city the cast never really left. │
│ │
│The sequel took seven years to finish. │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Overview: Collection blah blah blah │
│Root Folder Path: /nfs/movies │
│Quality Profile: HD - 1080p │
│Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │
│Monitored: Y│It was shot in Madison, Wisconsin: a city the cast never really left. │ │
│Search on Ad│ │ │
│ │The sequel took seven years to finish. │ │
│ │ │ │
│ │ │ │
│ Movies ──│ │───────────│
│ ✔ Title │ │ │
│=> ✔ Test │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ │
+26 -10
View File
@@ -23,6 +23,7 @@ use tokio_util::sync::CancellationToken;
use crate::app::{App, AppConfig, log_and_print_error};
use crate::cli::{self, Command};
use crate::models::Route;
use crate::network::Network;
use crate::ui::theme::ThemeDefinitionsWrapper;
@@ -287,12 +288,16 @@ pub(super) async fn start_cli_with_spinner(
cancellation_token: CancellationToken,
app: Arc<Mutex<App<'_>>>,
command: Command,
servarr_name: Option<&str>,
) {
config.verify_config_present_for_cli(&command);
{
let mut app = app.lock().await;
app.cli_mode = true;
select_cli_configuration(&mut app, &config, &command, None);
select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| {
log_and_print_error(error.to_string());
process::exit(1);
});
}
let pb = render_spinner();
let app_nw = Arc::clone(&app);
@@ -316,12 +321,16 @@ pub(super) async fn start_cli_no_spinner(
cancellation_token: CancellationToken,
app: Arc<Mutex<App<'_>>>,
command: Command,
servarr_name: Option<&str>,
) {
config.verify_config_present_for_cli(&command);
{
let mut app = app.lock().await;
app.cli_mode = true;
select_cli_configuration(&mut app, &config, &command, None);
select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| {
log_and_print_error(error.to_string());
process::exit(1);
});
}
let app_nw = Arc::clone(&app);
let mut network = Network::new(&app_nw, cancellation_token, reqwest_client);
@@ -340,16 +349,22 @@ pub fn select_cli_configuration(
app: &mut App<'_>,
config: &AppConfig,
command: &Command,
servarr_name_arg: Option<String>,
) {
servarr_name_arg: Option<&str>,
) -> Result<()> {
if let Some(servarr_name) = servarr_name_arg {
let trimmed_name = servarr_name.trim();
if !app.server_tabs.select_tab_by_title(trimmed_name) {
log_and_print_error(format!(
"A Servarr titled '{trimmed_name}' was not found in your configuration file"
));
process::exit(1);
}
let selected_index = app.server_tabs.tabs.iter().position(|tab| {
tab.title == trimmed_name
&& matches!(
(&tab.route, command),
(Route::Radarr(..), Command::Radarr(_))
| (Route::Sonarr(..), Command::Sonarr(_))
| (Route::Lidarr(..), Command::Lidarr(_))
)
});
app.server_tabs.index = selected_index.ok_or_else(|| {
anyhow!("A Servarr titled '{trimmed_name}' matching this command was not found in your configuration file")
})?;
} else {
match command {
Command::Radarr(_) => {
@@ -377,4 +392,5 @@ pub fn select_cli_configuration(
_ => (),
}
}
Ok(())
}
+40 -1
View File
@@ -1,11 +1,50 @@
#[cfg(test)]
mod tests {
use clap::Parser;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::utils::{convert_runtime, format_size, was_log_rotated};
use crate::{
Cli,
app::{App, AppConfig, ServarrConfig},
utils::{convert_runtime, format_size, select_cli_configuration, was_log_rotated},
};
#[test]
fn test_select_cli_configuration_selects_valid_named_instance() {
let selected_config = ServarrConfig {
name: Some("4K Movies".to_owned()),
..ServarrConfig::default()
};
let config = AppConfig {
radarr: Some(vec![ServarrConfig::default(), selected_config.clone()]),
..AppConfig::default()
};
let (network_tx, _network_rx) = mpsc::channel(1);
let mut app = App::new(network_tx, config.clone(), CancellationToken::new());
let args = Cli::try_parse_from([
"managarr",
"radarr",
"clear-blocklist",
"--servarr-name",
"4K Movies",
])
.unwrap();
select_cli_configuration(
&mut app,
&config,
args.command.as_ref().unwrap(),
args.global.servarr_name.as_deref(),
)
.unwrap();
assert_eq!(app.server_tabs.get_active_config(), &Some(selected_config));
}
#[test]
fn test_format_size() {
+261
View File
@@ -0,0 +1,261 @@
use std::{
fs,
path::{Path, PathBuf},
process, str,
sync::atomic::{AtomicU64, Ordering},
};
use assert_cmd::cargo::cargo_bin_cmd;
use mockito::Server;
static NEXT_CONFIG_ID: AtomicU64 = AtomicU64::new(0);
struct TemporaryConfig {
path: PathBuf,
}
impl TemporaryConfig {
fn new(contents: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"managarr-servarr-name-{}-{}.yaml",
process::id(),
NEXT_CONFIG_ID.fetch_add(1, Ordering::Relaxed)
));
fs::write(&path, contents).unwrap();
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TemporaryConfig {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[test]
fn named_instance_of_different_servarr_type_rejects_command() {
let mut radarr_server = Server::new();
let radarr_mock = radarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"radarr","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(0)
.create();
let mut sonarr_server = Server::new();
let sonarr_mock = sonarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"sonarr","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(0)
.create();
let config = TemporaryConfig::new(&format!(
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\nsonarr:\n - name: Shows\n uri: {}\n api_token: test-token\n",
radarr_server.url(),
sonarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let assertion = command
.arg("--config-file")
.arg(config.path())
.args([
"--disable-spinner",
"--servarr-name",
"Shows",
"radarr",
"get",
"system-status",
])
.assert()
.failure();
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
assert!(
stderr.contains("Shows"),
"CLI error did not identify the rejected instance: {stderr}"
);
radarr_mock.assert();
sonarr_mock.assert();
}
#[test]
fn same_named_servarrs_select_command_matching_instance() {
let mut radarr_server = Server::new();
let radarr_mock = radarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"wrong-instance","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(0)
.create();
let mut sonarr_server = Server::new();
let sonarr_mock = sonarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"expected-instance","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(1)
.create();
let config = TemporaryConfig::new(&format!(
"radarr:\n - name: Servarr\n uri: {}\n api_token: test-token\nsonarr:\n - name: Servarr\n uri: {}\n api_token: test-token\n",
radarr_server.url(),
sonarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let assertion = command
.arg("--config-file")
.arg(config.path())
.args([
"--disable-spinner",
"--servarr-name",
"Servarr",
"sonarr",
"get",
"system-status",
])
.assert()
.success();
let stdout = str::from_utf8(&assertion.get_output().stdout).unwrap();
assert!(
stdout.contains("\"version\": \"expected-instance\""),
"unexpected CLI output: {stdout}"
);
assert!(!stdout.contains("wrong-instance"));
sonarr_mock.assert();
radarr_mock.assert();
}
#[test]
fn unknown_servarr_name_fails() {
let mut radarr_server = Server::new();
let radarr_mock = radarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"radarr","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(0)
.create();
let config = TemporaryConfig::new(&format!(
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n",
radarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let assertion = command
.arg("--config-file")
.arg(config.path())
.args([
"--servarr-name",
"Unknown",
"radarr",
"get",
"system-status",
])
.assert()
.failure();
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
assert!(
stderr.contains("A Servarr titled 'Unknown'"),
"CLI error did not identify the unknown instance: {stderr}"
);
radarr_mock.assert();
}
#[test]
fn whitespace_servarr_name_trimmed() {
let mut radarr_server = Server::new();
let radarr_mock = radarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"radarr-whitespace","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(1)
.create();
let config = TemporaryConfig::new(&format!(
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n",
radarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let assertion = command
.arg("--config-file")
.arg(config.path())
.args([
"--disable-spinner",
"--servarr-name",
" Movies ",
"radarr",
"get",
"system-status",
])
.assert()
.success();
let stdout = str::from_utf8(&assertion.get_output().stdout).unwrap();
assert!(
stdout.contains("\"version\": \"radarr-whitespace\""),
"unexpected CLI output: {stdout}"
);
radarr_mock.assert();
}
#[test]
fn no_servarr_name_uses_first_configured_instance() {
let mut radarr_server = Server::new();
let radarr_mock = radarr_server
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"expected-instance","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(1)
.create();
let mut radarr_server2 = Server::new();
let radarr_mock2 = radarr_server2
.mock("GET", "/api/v3/system/status")
.match_header("X-Api-Key", "test-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"version":"wrong-instance","startTime":"2024-01-01T00:00:00Z"}"#)
.expect(0)
.create();
let config = TemporaryConfig::new(&format!(
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n weight: 2\n - name: 4K-Movies\n uri: {}\n api_token: test-token\n weight: 1\n",
radarr_server.url(),
radarr_server2.url()
));
let mut command = cargo_bin_cmd!("managarr");
let assertion = command
.arg("--config-file")
.arg(config.path())
.args(["--disable-spinner", "radarr", "get", "system-status"])
.assert()
.success();
let stdout = str::from_utf8(&assertion.get_output().stdout).unwrap();
assert!(
stdout.contains("\"version\": \"expected-instance\""),
"unexpected CLI output: {stdout}"
);
assert!(!stdout.contains("wrong-instance"));
radarr_mock.assert();
radarr_mock2.assert();
}