Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21749a62ea
|
||
|
|
947b3c2eda
|
||
|
|
9992a2bff0 | ||
|
|
c5ac78a310 | ||
|
|
0f230a1a4f
|
||
|
|
f6071f73b5
|
||
|
|
11524bb232
|
||
|
|
72ace0f2d5 | ||
|
|
9375e04ebf | ||
|
|
7a79e1c05e
|
||
|
|
06e504732d | ||
|
|
003f80098a
|
||
|
|
ad8a6743ab
|
||
|
|
c61404dc71
|
||
|
|
a5b95c165a
|
||
|
|
8ec4ec018b
|
||
|
|
642a5c0a87
|
||
|
|
9128e68d0a
|
||
|
|
ddc18ae21a
|
||
|
|
4b8b9cde57
|
||
|
|
238348f6ec
|
||
|
|
37f697d1b4
|
||
|
|
a70019b88f
|
||
|
|
fa02b70bf0 | ||
|
|
76f9f4da9a | ||
|
|
49be500b8c | ||
|
|
78918b6d88 | ||
|
|
9b98e53b55 | ||
|
|
b799fe3865 | ||
|
|
0e869e1e98 | ||
|
|
196bc55f35 | ||
|
|
e29bd67a3e | ||
|
|
424ed62229
|
||
|
|
ab7542651b
|
||
|
|
699e929ed2 | ||
|
|
5279d37ea2 |
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -88,3 +88,30 @@ jobs:
|
||||
|
||||
- name: cargo +1.95.0 check
|
||||
run: cargo check
|
||||
|
||||
dockerfile-msrv:
|
||||
# The Dockerfile's pinned Rust image is only built at release time, so a
|
||||
# rust-version bump that misses it fails silently until the next release.
|
||||
# Catch the drift on every push instead.
|
||||
name: dockerfile / msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check Dockerfile Rust image against Cargo.toml rust-version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
msrv=$(sed -nE 's/^rust-version = "([0-9.]+)"$/\1/p' Cargo.toml)
|
||||
image_tag=$(sed -nE 's/^FROM rust:([0-9.]+) AS builder$/\1/p' Dockerfile)
|
||||
: "${msrv:?failed to extract rust-version from Cargo.toml}"
|
||||
: "${image_tag:?failed to extract the rust image tag from the Dockerfile}"
|
||||
# The rust image tag pins major.minor, so compare on major.minor only
|
||||
msrv_mm=$(echo "$msrv" | cut -d. -f1,2)
|
||||
image_mm=$(echo "$image_tag" | cut -d. -f1,2)
|
||||
if [ "$(printf '%s\n' "$msrv_mm" "$image_mm" | sort -V | head -n1)" != "$msrv_mm" ]; then
|
||||
echo "Dockerfile builds with rust:$image_tag, but Cargo.toml declares rust-version = \"$msrv\"."
|
||||
echo "Cargo refuses to build under an older toolchain, so the release Docker publish step will fail."
|
||||
echo "Update the FROM rust:* line in the Dockerfile to at least $msrv_mm."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Dockerfile rust:$image_tag satisfies rust-version $msrv"
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -5,6 +5,157 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v0.8.1 (2026-10-12)
|
||||
|
||||
### Feat
|
||||
|
||||
- all CLI-based errors now come out in the same JSON format to further improve scripting and LLM integration
|
||||
|
||||
### Fix
|
||||
|
||||
- never panic when the log directory is unwritable; disable file logging instead [#67]
|
||||
|
||||
## v0.8.0 (2026-09-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- show sizes under a gigabyte in megabytes
|
||||
- **lidarr**: highlight tracks whose album is downloading or awaiting import
|
||||
- **readarr**: show the author overview in the add author prompt
|
||||
- **radarr**: render the movie overview popup at the large size
|
||||
- **radarr**: make the collection movie overview popup scrollable
|
||||
- **sonarr**: add a scrollable overview popup to series details
|
||||
- **lidarr**: add a scrollable overview popup to artist details
|
||||
- **readarr**: add a scrollable overview popup to author details
|
||||
- **readarr**: add the indexers UI
|
||||
- **readarr**: add the indexers handlers
|
||||
- **readarr**: add the system UI
|
||||
- **readarr**: add the system handlers
|
||||
- **readarr**: add the root folders UI
|
||||
- **readarr**: add the add root folder handler
|
||||
- **readarr**: add the root folders handler
|
||||
- **readarr**: add the history UI
|
||||
- **readarr**: add the downloads UI
|
||||
- **readarr**: add the blocklist UI
|
||||
- **readarr**: add the history handler
|
||||
- **readarr**: add the downloads handler
|
||||
- **readarr**: add the blocklist handler
|
||||
- **readarr**: add the delete book UI
|
||||
- **readarr**: add the delete author UI
|
||||
- **readarr**: add the edit author UI
|
||||
- **readarr**: add the add author UI
|
||||
- **readarr**: add the delete book handler
|
||||
- **readarr**: add the delete author handler
|
||||
- **readarr**: add the edit author handler
|
||||
- **readarr**: add the add author handler
|
||||
- **readarr**: render the edition details popup
|
||||
- **readarr**: render the book details popup
|
||||
- **readarr**: reach the book file info tab from the book details tabs
|
||||
- **readarr**: scroll and dismiss the edition details popup
|
||||
- **readarr**: handle the book details tabs and open the edition details popup
|
||||
- **readarr**: render the author details view
|
||||
- **readarr**: handle key events for the author details view
|
||||
- **readarr**: handle key events for the authors library table
|
||||
- **readarr**: fetch data on tick for the active block
|
||||
- **readarr**: edit the shared indexer settings and expose the command on the CLI
|
||||
- **readarr**: fetch the shared indexer settings and expose them on the CLI
|
||||
- **readarr**: test all indexers and expose the command on the CLI
|
||||
- **readarr**: test an indexer and expose the command on the CLI
|
||||
- **readarr**: delete an indexer and expose the command on the CLI
|
||||
- **readarr**: edit an indexer and expose the command on the CLI
|
||||
- **readarr**: fetch all indexers and expose them on the CLI
|
||||
- **readarr**: clear the blocklist and expose the command on the CLI
|
||||
- **readarr**: delete an item from the blocklist and expose the command on the CLI
|
||||
- **readarr**: fetch the blocklist and expose it on the CLI
|
||||
- **readarr**: manually download a release and expose the command on the CLI
|
||||
- **readarr**: fetch releases for an author and expose them on the CLI
|
||||
- **readarr**: fetch releases for a book and expose them on the CLI
|
||||
- **readarr**: mark a history item as failed and expose the command on the CLI
|
||||
- **readarr**: fetch Readarr history and expose it on the CLI
|
||||
- **readarr**: refresh Readarr downloads and expose the command on the CLI
|
||||
- **readarr**: delete a download and expose the command on the CLI
|
||||
- **readarr**: fetch Readarr downloads and expose them on the CLI
|
||||
- **readarr**: delete a book file and expose the command on the CLI
|
||||
- **readarr**: delete a book and expose the command on the CLI
|
||||
- **readarr**: toggle book monitoring and expose the command on the CLI
|
||||
- **readarr**: trigger an automatic book search and expose the command on the CLI
|
||||
- **readarr**: fetch Readarr book history and expose it on the CLI
|
||||
- **readarr**: fetch Readarr book files and expose them on the CLI
|
||||
- **readarr**: fetch Readarr book editions and expose them on the CLI
|
||||
- **readarr**: fetch Readarr book details and expose them on the CLI
|
||||
- **readarr**: fetch Readarr books and expose them on the CLI
|
||||
- **readarr**: fetch Readarr author history and expose it on the CLI
|
||||
- **readarr**: refresh all Readarr authors and expose the command on the CLI
|
||||
- **readarr**: refresh and rescan a Readarr author and expose the command on the CLI
|
||||
- **readarr**: trigger automatic author searches and expose the command on the CLI
|
||||
- **readarr**: toggle Readarr author monitoring and expose the command on the CLI
|
||||
- **readarr**: delete Readarr authors and expose the command on the CLI
|
||||
- **readarr**: edit Readarr authors and expose the command on the CLI
|
||||
- **readarr**: add authors to Readarr and expose the command on the CLI
|
||||
- **readarr**: search for new Readarr authors and expose the command on the CLI
|
||||
- **readarr**: fetch Readarr author details and expose them on the CLI
|
||||
- **readarr**: fetch Readarr authors and expose them on the CLI
|
||||
- **readarr**: delete Readarr root folders and expose the command on the CLI
|
||||
- **readarr**: add Readarr root folders and expose the command on the CLI
|
||||
- **readarr**: delete Readarr tags and expose the command on the CLI
|
||||
- **readarr**: add Readarr tags and expose the command on the CLI
|
||||
- **readarr**: fetch Readarr root folders and expose them on the CLI
|
||||
- **readarr**: fetch Readarr tags and expose them on the CLI
|
||||
- **readarr**: fetch Readarr metadata profiles and expose them on the CLI
|
||||
- **readarr**: fetch Readarr quality profiles and expose them on the CLI
|
||||
- **readarr**: fetch Readarr queued events and expose them on the CLI
|
||||
- **readarr**: trigger Readarr tasks and expose the command on the CLI
|
||||
- **readarr**: fetch Readarr tasks and expose them on the CLI
|
||||
- **readarr**: fetch Readarr logs and expose them on the CLI
|
||||
- **readarr**: fetch Readarr updates and expose them on the CLI
|
||||
- **readarr**: fetch the Readarr security config and expose it on the CLI
|
||||
- **readarr**: fetch the Readarr host config and expose it on the CLI
|
||||
- **readarr**: fetch Readarr disk space and expose it on the CLI
|
||||
- **readarr**: fetch the Readarr system status and expose it on the CLI
|
||||
- **readarr**: add the Readarr health check network call
|
||||
- scaffold Readarr support across the state, app, handler, UI, and CLI layers
|
||||
- updated gitignore to not include coyote memory or workspace config
|
||||
|
||||
### Fix
|
||||
|
||||
- **readarr**: send an empty images list when adding an author
|
||||
- **readarr**: accept release searches wrapped in a filter summary envelope
|
||||
- **readarr**: tolerate book payloads that omit the grabbed flag
|
||||
- **radarr**: render the movie overview one size down so the collection details stay visible behind it
|
||||
- **app/mod**: use correct api for tab when starting on non default tab
|
||||
- passes servarr-name global switch to both the tui and cli
|
||||
- **radarr**: model the movie status as an enum
|
||||
- page up and down stop at the table edges instead of wrapping
|
||||
- **radarr**: serialize the credit type under the same key it is read from
|
||||
- **radarr**: model the download status as a DownloadStatus enum
|
||||
- fall back to the default when a servarr returns an unknown enum value
|
||||
- show N/A instead of zeros when a season, album or book has no statistics
|
||||
- stop paging up or down on a single row table from crashing
|
||||
- keep history readable when an event type is unrecognised
|
||||
- **readarr**: attach editions when toggling book monitoring
|
||||
- **sonarr**: stop marking seasons fully downloaded when no episodes are known
|
||||
- keep a space between overview words that span a line break
|
||||
- parse the log level from the unscrolled log text
|
||||
- **radarr**: show a loading block while the updates popup refreshes
|
||||
- show confirmation prompt clues on delete prompts
|
||||
- drop redundant references in format arguments
|
||||
- **readarr**: stop a long author overview hiding the other fields
|
||||
- **ui**: render detail popup labels in bold
|
||||
- **readarr**: stop appending stray colons to the edition overview
|
||||
- **readarr**: move the book file delete to the File tab
|
||||
- **readarr**: render the update all authors confirmation prompt
|
||||
- **readarr**: drop the always-empty author type column from the authors table
|
||||
- tolerate update records that omit the changes list
|
||||
- address reviewer feedback — rename scroll field to bool, relocate static, drop env var
|
||||
- revert draw gating; replace frame-count scroll with wall-clock timer
|
||||
- address reviewer feedback — regex OnceLock and restore tick rate
|
||||
- reduce TUI idle CPU and improve server error banners
|
||||
|
||||
### Refactor
|
||||
|
||||
- share one DownloadStatus enum across the servarr models
|
||||
- move shared servarr types into servarr_models
|
||||
|
||||
## v0.7.3 (2026-06-25)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "managarr"
|
||||
version = "0.7.3"
|
||||
version = "0.8.1"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "A TUI and CLI to manage your Servarrs"
|
||||
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
|
||||
|
||||
@@ -26,6 +26,11 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built
|
||||
- [ ]  [Bazarr](https://www.bazarr.media/)
|
||||
- [ ]  [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
|
||||

|
||||

|
||||
|
||||
### Readarr
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### General
|
||||

|
||||

|
||||
|
||||
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 186 KiB |
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,8 @@ pub enum Command {
|
||||
},
|
||||
|
||||
#[command(about = indoc!{"
|
||||
Print the full path to the default configuration file.
|
||||
This file can be changed to another location using the '--config-file' flag
|
||||
Print the full path to the configuration file in use.
|
||||
This is the default configuration file location unless overridden with the '--config-file' flag
|
||||
"})]
|
||||
ConfigPath,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,7 +131,10 @@ async fn main() -> Result<()> {
|
||||
let default_config_path = config_file_path.display().to_string();
|
||||
|
||||
if matches!(args.command, Some(Command::ConfigPath)) {
|
||||
println!("{default_config_path}");
|
||||
match args.global.config_file {
|
||||
Some(ref config_file) => println!("{}", config_file.display()),
|
||||
None => println!("{default_config_path}"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -153,7 +156,6 @@ async fn main() -> Result<()> {
|
||||
config.validate(&config_path);
|
||||
config.post_process_initialization();
|
||||
|
||||
let reqwest_client = build_network_client(&config);
|
||||
let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let ctrlc_cancellation_token = cancellation_token.clone();
|
||||
@@ -171,13 +173,41 @@ 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(_) => {
|
||||
let reqwest_client = build_network_client(&config);
|
||||
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 } => {
|
||||
@@ -192,6 +222,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let reqwest_client = build_network_client(&config);
|
||||
let app_nw = Arc::clone(&app);
|
||||
std::thread::spawn(move || {
|
||||
start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::fmt::Debug;
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Debug};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
@@ -156,8 +157,22 @@ impl<'a, 'b> Network<'a, 'b> {
|
||||
let error_body = format_error_body(&response_body);
|
||||
|
||||
error!("Request failed. Received {status} response code with body: {response_body}");
|
||||
self.app.lock().await.handle_error(anyhow!("Request failed. Received {status} response code with body: {error_body}"));
|
||||
Err(anyhow!("Request failed. Received {status} response code with body: {error_body}"))
|
||||
let message = format!("Request failed. Received {status} response code with body: {error_body}");
|
||||
let body = if response_body.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
serde_json::from_str::<Value>(&response_body)
|
||||
.unwrap_or(Value::String(response_body)),
|
||||
)
|
||||
};
|
||||
let network_error = NetworkError {
|
||||
message,
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
};
|
||||
self.app.lock().await.handle_error(network_error.clone().into());
|
||||
Err(network_error.into())
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -304,6 +319,21 @@ fn format_error_body(response_body: &str) -> String {
|
||||
re.replace_all(&body, " ").to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NetworkError {
|
||||
pub message: String,
|
||||
pub status: u16,
|
||||
pub body: Option<Value>,
|
||||
}
|
||||
|
||||
impl fmt::Display for NetworkError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for NetworkError {}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Display, PartialEq, Eq)]
|
||||
pub enum RequestMethod {
|
||||
Get,
|
||||
|
||||
@@ -10,7 +10,7 @@ mod tests {
|
||||
use reqwest::header::HeaderMap;
|
||||
use rstest::rstest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -22,7 +22,9 @@ mod tests {
|
||||
use crate::network::network_tests::test_utils::test_network;
|
||||
use crate::network::radarr_network::RadarrEvent;
|
||||
use crate::network::sonarr_network::SonarrEvent;
|
||||
use crate::network::{Network, NetworkEvent, NetworkTrait, RequestMethod, RequestProps};
|
||||
use crate::network::{
|
||||
Network, NetworkError, NetworkEvent, NetworkTrait, RequestMethod, RequestProps,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_network_event_radarr_event() {
|
||||
@@ -398,6 +400,104 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_request_non_success_code_downcasts_to_network_error_with_json_body() {
|
||||
let (async_server, app_arc, server) = mock_api(RequestMethod::Get, 404, true).await;
|
||||
let mut network = test_network(&app_arc);
|
||||
|
||||
let resp = network
|
||||
.handle_request::<(), Test>(
|
||||
RequestProps {
|
||||
uri: format!("{}/test", server.url()),
|
||||
method: RequestMethod::Get,
|
||||
body: None,
|
||||
api_token: "test1234".to_owned(),
|
||||
ignore_status_code: false,
|
||||
custom_headers: HeaderMap::new(),
|
||||
},
|
||||
|_, _| (),
|
||||
)
|
||||
.await;
|
||||
|
||||
async_server.assert_async().await;
|
||||
let network_error = resp.unwrap_err().downcast::<NetworkError>().unwrap();
|
||||
assert_str_eq!(
|
||||
network_error.message,
|
||||
r#"Request failed. Received 404 Not Found response code with body: { "value": "Test" }"#
|
||||
);
|
||||
assert_eq!(network_error.status, 404);
|
||||
assert_some_eq_x!(network_error.body.as_ref(), &json!({ "value": "Test" }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_request_non_success_code_downcasts_to_network_error_with_string_body() {
|
||||
let mut server = Server::new_async().await;
|
||||
let async_server = server
|
||||
.mock("GET", "/test")
|
||||
.match_header("X-Api-Key", "test1234")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
let app_arc = Arc::new(Mutex::new(App::test_default()));
|
||||
let mut network = test_network(&app_arc);
|
||||
|
||||
let resp = network
|
||||
.handle_request::<(), Test>(
|
||||
RequestProps {
|
||||
uri: format!("{}/test", server.url()),
|
||||
method: RequestMethod::Get,
|
||||
body: None,
|
||||
api_token: "test1234".to_owned(),
|
||||
ignore_status_code: false,
|
||||
custom_headers: HeaderMap::new(),
|
||||
},
|
||||
|_, _| (),
|
||||
)
|
||||
.await;
|
||||
|
||||
async_server.assert_async().await;
|
||||
let network_error = resp.unwrap_err().downcast::<NetworkError>().unwrap();
|
||||
assert_str_eq!(
|
||||
network_error.message,
|
||||
"Request failed. Received 503 Service Unavailable response code with body: Service Unavailable"
|
||||
);
|
||||
assert_eq!(network_error.status, 503);
|
||||
assert_some_eq_x!(
|
||||
network_error.body.as_ref(),
|
||||
&Value::String("Service Unavailable".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_request_non_success_code_downcasts_to_network_error_with_empty_body() {
|
||||
let (async_server, app_arc, server) = mock_api(RequestMethod::Post, 404, false).await;
|
||||
let mut network = test_network(&app_arc);
|
||||
|
||||
let resp = network
|
||||
.handle_request::<(), Test>(
|
||||
RequestProps {
|
||||
uri: format!("{}/test", server.url()),
|
||||
method: RequestMethod::Post,
|
||||
body: None,
|
||||
api_token: "test1234".to_owned(),
|
||||
ignore_status_code: false,
|
||||
custom_headers: HeaderMap::new(),
|
||||
},
|
||||
|_, _| (),
|
||||
)
|
||||
.await;
|
||||
|
||||
async_server.assert_async().await;
|
||||
let network_error = resp.unwrap_err().downcast::<NetworkError>().unwrap();
|
||||
assert_str_eq!(
|
||||
network_error.message,
|
||||
r#"Request failed. Received 404 Not Found response code with body: "#
|
||||
);
|
||||
assert_eq!(network_error.status, 404);
|
||||
assert_none!(network_error.body);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_call_api(
|
||||
|
||||
@@ -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 │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Once};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, anyhow};
|
||||
use anyhow::{Context, Error, anyhow};
|
||||
use colored::Colorize;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use log::{LevelFilter, error};
|
||||
@@ -18,12 +18,15 @@ use log4rs::config::{Appender, Root};
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use regex::Regex;
|
||||
use reqwest::{Certificate, Client};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::app::{App, AppConfig, log_and_print_error};
|
||||
use crate::cli::{self, Command};
|
||||
use crate::network::Network;
|
||||
use crate::models::Route;
|
||||
use crate::network::{Network, NetworkError};
|
||||
use crate::ui::theme::ThemeDefinitionsWrapper;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -31,52 +34,92 @@ use crate::ui::theme::ThemeDefinitionsWrapper;
|
||||
mod utils_tests;
|
||||
|
||||
pub fn get_log_path() -> PathBuf {
|
||||
let mut log_path = if cfg!(target_os = "linux") {
|
||||
let preferred = if cfg!(target_os = "linux") {
|
||||
dirs_next::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
dirs_next::home_dir().unwrap().join("Library/Logs")
|
||||
dirs_next::home_dir()
|
||||
.map(|h| h.join("Library/Logs"))
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
} else {
|
||||
dirs_next::data_local_dir().unwrap_or_else(|| PathBuf::from("C:\\Logs"))
|
||||
};
|
||||
}
|
||||
.join("managarr");
|
||||
|
||||
log_path.push("managarr");
|
||||
resolve_log_dir(preferred).join("managarr.log")
|
||||
}
|
||||
|
||||
if let Err(e) = fs::create_dir_all(&log_path) {
|
||||
eprintln!("Failed to create log directory: {e:?}");
|
||||
fn resolve_log_dir(preferred: PathBuf) -> PathBuf {
|
||||
static WARN_ONCE: Once = Once::new();
|
||||
|
||||
if let Err(e) = fs::create_dir_all(&preferred) {
|
||||
WARN_ONCE.call_once(|| {
|
||||
eprintln!(
|
||||
"Failed to create log directory {}: {e}; file logging will be disabled",
|
||||
preferred.display()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
log_path.push("managarr.log");
|
||||
log_path
|
||||
preferred
|
||||
}
|
||||
|
||||
pub fn init_logging_config() -> log4rs::Config {
|
||||
let log_path = get_log_path();
|
||||
build_logging_config(get_log_path())
|
||||
}
|
||||
|
||||
fn build_logging_config(log_path: PathBuf) -> log4rs::Config {
|
||||
let archive_pattern = log_path
|
||||
.with_file_name("managarr.{}.log")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let trigger = SizeTrigger::new(10 * 1024 * 1024);
|
||||
let roller = FixedWindowRoller::builder()
|
||||
.build(&archive_pattern, 3)
|
||||
.expect("Failed to build log roller");
|
||||
let roller = match FixedWindowRoller::builder().build(&archive_pattern, 3) {
|
||||
Ok(roller) => roller,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Failed to build log roller for {archive_pattern}: {e}; file logging will be disabled"
|
||||
);
|
||||
return disabled_logging_config();
|
||||
}
|
||||
};
|
||||
let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
|
||||
|
||||
let logfile = RollingFileAppender::builder()
|
||||
let logfile = match RollingFileAppender::builder()
|
||||
.encoder(Box::new(PatternEncoder::new(
|
||||
"{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}",
|
||||
)))
|
||||
.build(log_path, Box::new(policy))
|
||||
.expect("Failed to build rolling file appender");
|
||||
.build(&log_path, Box::new(policy))
|
||||
{
|
||||
Ok(logfile) => logfile,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Failed to build rolling file appender for {}: {e}; file logging will be disabled",
|
||||
log_path.display()
|
||||
);
|
||||
return disabled_logging_config();
|
||||
}
|
||||
};
|
||||
|
||||
log4rs::Config::builder()
|
||||
match log4rs::Config::builder()
|
||||
.appender(Appender::builder().build("logfile", Box::new(logfile)))
|
||||
.build(
|
||||
Root::builder()
|
||||
.appender("logfile")
|
||||
.build(LevelFilter::Debug),
|
||||
)
|
||||
.unwrap()
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to build logging config: {e}; file logging will be disabled");
|
||||
disabled_logging_config()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_logging_config() -> log4rs::Config {
|
||||
log4rs::Config::builder()
|
||||
.build(Root::builder().build(LevelFilter::Debug))
|
||||
.expect("appender-free logging config is infallibly valid")
|
||||
}
|
||||
|
||||
pub fn format_size(bytes: i64, precision: usize) -> String {
|
||||
@@ -99,7 +142,12 @@ pub fn convert_runtime(runtime: i64) -> (i64, i64) {
|
||||
pub async fn tail_logs(no_color: bool) -> Result<()> {
|
||||
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
|
||||
let file_path = get_log_path();
|
||||
let file = File::open(&file_path).expect("Cannot open file");
|
||||
let file = File::open(&file_path).with_context(|| {
|
||||
format!(
|
||||
"Unable to open log file {}; file logging may be disabled",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut reader = BufReader::new(file);
|
||||
|
||||
reader
|
||||
@@ -281,18 +329,53 @@ pub(super) fn render_spinner() -> ProgressBar {
|
||||
pb
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CliError<'a> {
|
||||
error: CliErrorDetails<'a>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CliErrorDetails<'a> {
|
||||
message: String,
|
||||
status: Option<u16>,
|
||||
body: Option<&'a Value>,
|
||||
}
|
||||
|
||||
fn format_cli_error(error: &Error) -> String {
|
||||
let details = match error.downcast_ref::<NetworkError>() {
|
||||
Some(network_error) => CliErrorDetails {
|
||||
message: network_error.message.clone(),
|
||||
status: Some(network_error.status),
|
||||
body: network_error.body.as_ref(),
|
||||
},
|
||||
None => CliErrorDetails {
|
||||
message: error.to_string(),
|
||||
status: None,
|
||||
body: None,
|
||||
},
|
||||
};
|
||||
|
||||
serde_json::to_string_pretty(&CliError { error: details })
|
||||
.expect("CLI error details are infallibly serializable")
|
||||
}
|
||||
|
||||
pub(super) async fn start_cli_with_spinner(
|
||||
config: AppConfig,
|
||||
reqwest_client: Client,
|
||||
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| {
|
||||
error!("{error}");
|
||||
eprintln!("{}", format_cli_error(&error));
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
let pb = render_spinner();
|
||||
let app_nw = Arc::clone(&app);
|
||||
@@ -304,7 +387,7 @@ pub(super) async fn start_cli_with_spinner(
|
||||
}
|
||||
Err(e) => {
|
||||
pb.finish();
|
||||
eprintln!("error: {}", e.to_string().red());
|
||||
eprintln!("{}", format_cli_error(&e));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -316,12 +399,17 @@ 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| {
|
||||
error!("{error}");
|
||||
eprintln!("{}", format_cli_error(&error));
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
let app_nw = Arc::clone(&app);
|
||||
let mut network = Network::new(&app_nw, cancellation_token, reqwest_client);
|
||||
@@ -330,7 +418,7 @@ pub(super) async fn start_cli_no_spinner(
|
||||
println!("{output}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e.to_string().red());
|
||||
eprintln!("{}", format_cli_error(&e));
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -340,16 +428,23 @@ 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(_))
|
||||
| (Route::Readarr(..), Command::Readarr(_))
|
||||
)
|
||||
});
|
||||
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 +472,5 @@ pub fn select_cli_configuration(
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +1,56 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::{Error, anyhow};
|
||||
use clap::Parser;
|
||||
use serde_json::json;
|
||||
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},
|
||||
network::NetworkError,
|
||||
utils::{
|
||||
build_logging_config, convert_runtime, format_cli_error, format_size, resolve_log_dir,
|
||||
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() {
|
||||
@@ -25,6 +70,123 @@ mod tests {
|
||||
assert_eq!(minutes, 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_cli_error_network_error_with_json_body() {
|
||||
let error = Error::new(NetworkError {
|
||||
message: "Request failed. Received 404 Not Found response code with body: Not Found"
|
||||
.to_owned(),
|
||||
status: 404,
|
||||
body: Some(json!({ "status": 404, "title": "Not Found" })),
|
||||
});
|
||||
|
||||
let output = format_cli_error(&error);
|
||||
|
||||
assert_str_eq!(
|
||||
output,
|
||||
r#"{
|
||||
"error": {
|
||||
"message": "Request failed. Received 404 Not Found response code with body: Not Found",
|
||||
"status": 404,
|
||||
"body": {
|
||||
"status": 404,
|
||||
"title": "Not Found"
|
||||
}
|
||||
}
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_cli_error_network_error_with_string_body() {
|
||||
let error = Error::new(NetworkError {
|
||||
message:
|
||||
"Request failed. Received 503 Service Unavailable response code with body: Service Unavailable"
|
||||
.to_owned(),
|
||||
status: 503,
|
||||
body: Some(json!("Service Unavailable")),
|
||||
});
|
||||
|
||||
let output = format_cli_error(&error);
|
||||
|
||||
assert_str_eq!(
|
||||
output,
|
||||
r#"{
|
||||
"error": {
|
||||
"message": "Request failed. Received 503 Service Unavailable response code with body: Service Unavailable",
|
||||
"status": 503,
|
||||
"body": "Service Unavailable"
|
||||
}
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_cli_error_network_error_with_null_body() {
|
||||
let error = Error::new(NetworkError {
|
||||
message: "Request failed. Received 404 Not Found response code with body: ".to_owned(),
|
||||
status: 404,
|
||||
body: None,
|
||||
});
|
||||
|
||||
let output = format_cli_error(&error);
|
||||
|
||||
assert_str_eq!(
|
||||
output,
|
||||
r#"{
|
||||
"error": {
|
||||
"message": "Request failed. Received 404 Not Found response code with body: ",
|
||||
"status": 404,
|
||||
"body": null
|
||||
}
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_cli_error_network_error_wrapped_in_context() {
|
||||
let error = Error::new(NetworkError {
|
||||
message: "Request failed. Received 404 Not Found response code with body: Not Found"
|
||||
.to_owned(),
|
||||
status: 404,
|
||||
body: Some(json!({ "status": 404, "title": "Not Found" })),
|
||||
})
|
||||
.context("failed to fetch system status");
|
||||
|
||||
let output = format_cli_error(&error);
|
||||
|
||||
assert_str_eq!(
|
||||
output,
|
||||
r#"{
|
||||
"error": {
|
||||
"message": "Request failed. Received 404 Not Found response code with body: Not Found",
|
||||
"status": 404,
|
||||
"body": {
|
||||
"status": 404,
|
||||
"title": "Not Found"
|
||||
}
|
||||
}
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_cli_error_plain_anyhow_error() {
|
||||
let error = anyhow!("Failed to send request. connection refused ");
|
||||
|
||||
let output = format_cli_error(&error);
|
||||
|
||||
assert_str_eq!(
|
||||
output,
|
||||
r#"{
|
||||
"error": {
|
||||
"message": "Failed to send request. connection refused ",
|
||||
"status": null,
|
||||
"body": null
|
||||
}
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_was_log_rotated_returns_false_when_file_has_not_rotated() {
|
||||
let path = std::env::temp_dir().join("managarr_test_no_rotation.log");
|
||||
@@ -75,4 +237,79 @@ mod tests {
|
||||
|
||||
fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_log_dir_returns_preferred_dir_when_creatable() {
|
||||
let preferred = std::env::temp_dir().join(format!(
|
||||
"managarr_test_preferred_log_dir_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&preferred);
|
||||
|
||||
let resolved = resolve_log_dir(preferred.clone());
|
||||
|
||||
assert_eq!(resolved, preferred);
|
||||
assert!(preferred.is_dir());
|
||||
|
||||
fs::remove_dir_all(&preferred).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_log_dir_keeps_preferred_dir_and_disables_file_logging_when_uncreatable() {
|
||||
let blocker = std::env::temp_dir().join(format!(
|
||||
"managarr_test_log_dir_blocker_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_file(&blocker);
|
||||
fs::write(&blocker, "not a directory").unwrap();
|
||||
let preferred = blocker.join("logs");
|
||||
|
||||
let resolved = resolve_log_dir(preferred.clone());
|
||||
|
||||
assert_eq!(resolved, preferred);
|
||||
assert!(!resolved.exists());
|
||||
|
||||
let config = build_logging_config(resolved.join("managarr.log"));
|
||||
|
||||
assert!(config.appenders().is_empty());
|
||||
assert!(config.root().appenders().is_empty());
|
||||
|
||||
fs::remove_file(&blocker).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_logging_config_uses_logfile_appender_when_log_path_is_writable() {
|
||||
let log_dir = std::env::temp_dir().join(format!(
|
||||
"managarr_test_logging_config_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&log_dir);
|
||||
fs::create_dir_all(&log_dir).unwrap();
|
||||
|
||||
let config = build_logging_config(log_dir.join("managarr.log"));
|
||||
|
||||
assert_eq!(config.appenders().len(), 1);
|
||||
assert_str_eq!(config.appenders()[0].name(), "logfile");
|
||||
assert_eq!(config.root().appenders(), ["logfile"]);
|
||||
|
||||
drop(config);
|
||||
fs::remove_dir_all(&log_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_logging_config_disables_logging_when_appender_is_unbuildable() {
|
||||
let blocker = std::env::temp_dir().join(format!(
|
||||
"managarr_test_appender_blocker_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_file(&blocker);
|
||||
fs::write(&blocker, "not a directory").unwrap();
|
||||
|
||||
let config = build_logging_config(blocker.join("logs").join("managarr.log"));
|
||||
|
||||
assert!(config.appenders().is_empty());
|
||||
assert!(config.root().appenders().is_empty());
|
||||
|
||||
fs::remove_file(&blocker).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process, str,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use assert_cmd::{Command, 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);
|
||||
}
|
||||
}
|
||||
|
||||
fn managarr_command() -> Command {
|
||||
let home = std::env::temp_dir().join(format!("managarr-servarr-name-home-{}", process::id()));
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
let mut command = cargo_bin_cmd!("managarr");
|
||||
command.env("HOME", &home).env("XDG_CACHE_HOME", &home);
|
||||
command
|
||||
}
|
||||
|
||||
#[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 = managarr_command();
|
||||
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 named_readarr_instance_selected_for_readarr_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":"wrong-instance","startTime":"2024-01-01T00:00:00Z"}"#)
|
||||
.expect(0)
|
||||
.create();
|
||||
let mut readarr_server = Server::new();
|
||||
let readarr_mock = readarr_server
|
||||
.mock("GET", "/api/v1/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\nreadarr:\n - name: Servarr\n uri: {}\n api_token: test-token\n",
|
||||
radarr_server.url(),
|
||||
readarr_server.url()
|
||||
));
|
||||
|
||||
let mut command = managarr_command();
|
||||
let assertion = command
|
||||
.arg("--config-file")
|
||||
.arg(config.path())
|
||||
.args([
|
||||
"--disable-spinner",
|
||||
"--servarr-name",
|
||||
"Servarr",
|
||||
"readarr",
|
||||
"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"));
|
||||
readarr_mock.assert();
|
||||
radarr_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 = managarr_command();
|
||||
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 = managarr_command();
|
||||
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 = managarr_command();
|
||||
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 = managarr_command();
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::{fs, path::PathBuf, process, str};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use assert_cmd::Command;
|
||||
use assert_cmd::cargo::cargo_bin_cmd;
|
||||
|
||||
struct ProbeEnv {
|
||||
base: PathBuf,
|
||||
}
|
||||
|
||||
impl ProbeEnv {
|
||||
fn new(test_name: &str) -> Self {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"managarr_unwritable_log_dir_{test_name}_{}",
|
||||
process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
fs::create_dir_all(base.join("home")).unwrap();
|
||||
fs::create_dir_all(base.join("fresh_tmp")).unwrap();
|
||||
fs::write(base.join("blockfile"), "not a directory").unwrap();
|
||||
fs::write(
|
||||
base.join("config.yml"),
|
||||
"radarr:\n - name: Movies\n host: 127.0.0.1\n port: 7878\n api_token: test-token\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Self { base }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn blocked_cache_dir(&self) -> PathBuf {
|
||||
self.base.join("blockfile").join("cache")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn command(&self) -> Command {
|
||||
let mut command = cargo_bin_cmd!("managarr");
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").unwrap())
|
||||
.env("HOME", self.base.join("home"))
|
||||
.env("XDG_CACHE_HOME", self.blocked_cache_dir())
|
||||
.env("TMPDIR", self.base.join("fresh_tmp"))
|
||||
.env("SSL_CERT_FILE", self.base.join("no_certs.crt"))
|
||||
.env("SSL_CERT_DIR", self.base.join("no_certs_dir"))
|
||||
.arg("--config-file")
|
||||
.arg(self.base.join("config.yml"));
|
||||
command
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProbeEnv {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.base);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn unwritable_log_dir_warns_and_cli_command_still_succeeds() {
|
||||
let env = ProbeEnv::new("completions");
|
||||
let expected_log_dir = env.blocked_cache_dir().join("managarr");
|
||||
|
||||
let mut command = env.command();
|
||||
let assertion = command.args(["completions", "bash"]).assert().success();
|
||||
let output = assertion.get_output();
|
||||
let stderr = str::from_utf8(&output.stderr).unwrap();
|
||||
|
||||
assert!(
|
||||
!output.stdout.is_empty(),
|
||||
"completions produced no stdout despite exiting successfully"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!(
|
||||
"Failed to create log directory {}",
|
||||
expected_log_dir.display()
|
||||
)),
|
||||
"stderr did not warn about the uncreatable log directory: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("file logging will be disabled"),
|
||||
"stderr did not announce that file logging is disabled: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"binary panicked instead of degrading gracefully: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
fs::read_dir(env.base.join("fresh_tmp")).unwrap().count() == 0,
|
||||
"log output fell back to a temp directory despite the no-fallback ruling"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn tail_logs_with_unopenable_log_file_returns_error_without_panicking() {
|
||||
let env = ProbeEnv::new("tail_logs");
|
||||
let expected_log_file = env
|
||||
.blocked_cache_dir()
|
||||
.join("managarr")
|
||||
.join("managarr.log");
|
||||
|
||||
let mut command = env.command();
|
||||
let assertion = command
|
||||
.arg("tail-logs")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.assert()
|
||||
.failure();
|
||||
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
|
||||
|
||||
assert!(
|
||||
stderr.contains(&format!(
|
||||
"Unable to open log file {}",
|
||||
expected_log_file.display()
|
||||
)),
|
||||
"tail-logs error did not name the log file: {stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
stderr.matches("Failed to create log directory").count(),
|
||||
1,
|
||||
"the create-dir warning must print exactly once per process: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("file logging may be disabled"),
|
||||
"tail-logs error did not mention that file logging may be disabled: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"tail-logs panicked instead of returning an error: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn writable_cache_dir_creates_log_file_without_warnings() {
|
||||
let env = ProbeEnv::new("happy_path");
|
||||
let cache_dir = env.base.join("home").join("cache");
|
||||
fs::create_dir_all(&cache_dir).unwrap();
|
||||
|
||||
let mut command = env.command();
|
||||
let assertion = command
|
||||
.env("XDG_CACHE_HOME", &cache_dir)
|
||||
.args(["completions", "bash"])
|
||||
.assert()
|
||||
.success();
|
||||
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
|
||||
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"happy path emitted unexpected stderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
cache_dir.join("managarr").join("managarr.log").is_file(),
|
||||
"logfile appender did not create managarr.log in the writable cache dir"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_home_does_not_panic_on_startup() {
|
||||
let env = ProbeEnv::new("unset_home");
|
||||
|
||||
let mut command = cargo_bin_cmd!("managarr");
|
||||
let assertion = command
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var_os("PATH").unwrap())
|
||||
.env("TMPDIR", env.base.join("fresh_tmp"))
|
||||
.env("SSL_CERT_FILE", env.base.join("no_certs.crt"))
|
||||
.env("SSL_CERT_DIR", env.base.join("no_certs_dir"))
|
||||
.current_dir(&env.base)
|
||||
.arg("--config-file")
|
||||
.arg(env.base.join("config.yml"))
|
||||
.args(["completions", "bash"])
|
||||
.assert()
|
||||
.success();
|
||||
let output = assertion.get_output();
|
||||
let stderr = str::from_utf8(&output.stderr).unwrap();
|
||||
|
||||
assert!(
|
||||
!output.stdout.is_empty(),
|
||||
"completions produced no stdout despite exiting successfully"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"binary panicked instead of degrading gracefully: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn config_path_succeeds_without_loadable_tls_certificates() {
|
||||
let env = ProbeEnv::new("config_path");
|
||||
|
||||
let mut command = env.command();
|
||||
let assertion = command.arg("config-path").assert().success();
|
||||
let output = assertion.get_output();
|
||||
let stderr = str::from_utf8(&output.stderr).unwrap();
|
||||
|
||||
let stdout = str::from_utf8(&output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
stdout.trim_end(),
|
||||
env.base.join("config.yml").display().to_string(),
|
||||
"config-path did not print the config file passed via --config-file"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("builder error"),
|
||||
"config-path built the reqwest client despite needing no network: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"binary panicked instead of degrading gracefully: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn servarr_command_without_loadable_tls_certificates_reports_builder_error() {
|
||||
let env = ProbeEnv::new("servarr_builder_error");
|
||||
|
||||
let mut command = env.command();
|
||||
let assertion = command
|
||||
.args(["--disable-spinner", "radarr", "list", "movies"])
|
||||
.assert()
|
||||
.failure();
|
||||
let stderr = str::from_utf8(&assertion.get_output().stderr).unwrap();
|
||||
|
||||
assert!(
|
||||
stderr.contains("error: builder error"),
|
||||
"network-bound command did not surface the client builder failure: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"builder failure panicked instead of exiting cleanly: {stderr}"
|
||||
);
|
||||
}
|
||||