Compare commits

...
22 Commits
Author SHA1 Message Date
Dark-Alex-17 21749a62ea fix: config-path correctly displays the config file used even if --config-file is passed
Check / stable / fmt (push) Failing after 27s
Check / beta / clippy (push) Failing after 1m11s
Check / stable / clippy (push) Failing after 1m11s
Check / nightly / doc (push) Successful in 1m1s
Check / 1.95.0 / check (push) Successful in 1m8s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m17s
Test Suite / ubuntu / stable (push) Successful in 2m16s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m57s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-10-14 12:56:31 -06:00
Dark-Alex-17 947b3c2eda fix: only instantiate the reqwest client on paths that actually need it 2026-10-14 12:49:23 -06:00
github-actions[bot] 9992a2bff0 chore: bump Cargo.toml to 0.8.1
Check / stable / fmt (push) Failing after 24s
Check / beta / clippy (push) Failing after 1m10s
Check / stable / clippy (push) Failing after 1m10s
Check / nightly / doc (push) Successful in 1m1s
Check / 1.95.0 / check (push) Successful in 1m8s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m15s
Test Suite / ubuntu / stable (push) Successful in 2m16s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m55s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-10-12 17:31:42 +00:00
github-actions[bot] c5ac78a310 bump: version 0.8.0 → 0.8.1 [skip ci] 2026-10-12 17:31:40 +00:00
Dark-Alex-17 0f230a1a4f fix: never panic when the log directory is unwritable; disable file logging instead [#67]
Check / stable / fmt (push) Failing after 26s
Check / beta / clippy (push) Failing after 1m10s
Check / stable / clippy (push) Failing after 1m10s
Check / nightly / doc (push) Successful in 1m0s
Check / 1.95.0 / check (push) Successful in 1m7s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m15s
Test Suite / ubuntu / stable (push) Successful in 2m14s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m57s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-10-12 11:16:11 -06:00
Dark-Alex-17 f6071f73b5 Merge branch 'main' of github.com:Dark-Alex-17/managarr
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 1m15s
Check / stable / clippy (push) Failing after 1m11s
Check / nightly / doc (push) Successful in 1m3s
Check / 1.95.0 / check (push) Successful in 1m10s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m16s
Test Suite / ubuntu / stable (push) Successful in 2m17s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m0s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-10-01 14:45:21 -06:00
Dark-Alex-17 11524bb232 feat: all CLI-based errors now come out in the same JSON format to further improve scripting and LLM integration
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 1m15s
Check / stable / clippy (push) Failing after 1m12s
Check / nightly / doc (push) Successful in 1m2s
Check / 1.95.0 / check (push) Successful in 1m8s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m21s
Test Suite / ubuntu / stable (push) Successful in 2m20s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m59s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-09-30 22:02:46 -06:00
github-actions[bot] 72ace0f2d5 chore: bump Cargo.toml to 0.8.0 2026-09-30 17:57:08 +00:00
github-actions[bot] 9375e04ebf bump: version 0.7.3 → 0.8.0 [skip ci] 2026-09-30 17:57:06 +00:00
Dark-Alex-17 7a79e1c05e ci: Added a dockerfile check to ensure the dockerfile MSRV matches Cargo.toml
Check / stable / fmt (push) Failing after 32s
Check / beta / clippy (push) Failing after 1m20s
Check / stable / clippy (push) Failing after 1m19s
Check / nightly / doc (push) Successful in 1m4s
Check / 1.95.0 / check (push) Successful in 1m8s
Check / dockerfile / msrv (push) Successful in 5s
Test Suite / ubuntu / beta (push) Successful in 2m24s
Test Suite / ubuntu / stable (push) Successful in 2m28s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m22s
Test Suite / macos-latest / stable (push) Canceled after 0s
Test Suite / windows-latest / stable (push) Canceled after 0s
2026-09-30 11:40:46 -06:00
Alex Clarke 06e504732d Merge pull request #65 from Dark-Alex-17/readarr-support
Readarr support
2026-09-30 10:41:44 -06:00
Dark-Alex-17 003f80098a docs: Updated the pull request template 2026-09-30 00:11:56 -06:00
Dark-Alex-17 ad8a6743ab docs: Added documentation on how to configure Chaptarr in Managarr 2026-09-30 00:03:29 -06:00
Dark-Alex-17 c61404dc71 docs: Updated the contribution guidelines and PR template to sound more like AI is allowed but only when disclosed 2026-09-29 23:46:40 -06:00
Dark-Alex-17 a5b95c165a fix(readarr): send an empty images list when adding an author 2026-09-29 23:36:11 -06:00
Dark-Alex-17 8ec4ec018b fix(readarr): accept release searches wrapped in a filter summary envelope 2026-09-29 23:30:30 -06:00
Dark-Alex-17 642a5c0a87 fix(readarr): tolerate book payloads that omit the grabbed flag 2026-09-29 23:25:19 -06:00
Dark-Alex-17 9128e68d0a test(readarr): cover the CLI handler dispatch to each subcommand handler 2026-09-29 16:52:53 -06:00
Dark-Alex-17 ddc18ae21a docs: Updated the README to mention that Readarr has been sunset but Managarr is compatible with any APIs that are compatible with Readarr v1 2026-09-29 15:54:47 -06:00
Dark-Alex-17 4b8b9cde57 docs: Updated README with screenshots of Readarr 2026-09-29 14:24:00 -06:00
Dark-Alex-17 238348f6ec fix(radarr): render the movie overview one size down so the collection details stay visible behind it 2026-09-29 13:49:28 -06:00
Dark-Alex-17 37f697d1b4 docs: reformatted tables in README 2026-09-29 13:47:04 -06:00
40 changed files with 1841 additions and 378 deletions

No files matched your search

@@ -1,11 +0,0 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented below
+18
View File
@@ -0,0 +1,18 @@
### Description
<!-- What problem is this PR solving? -->
### Alternatives Considered (Optional)
<!-- What alternatives did you consider? Why did you choose this approach? -->
### AI assistance (if any)
<!-- Required if AI assistance was used to write or modify code in this PR. -->
<!-- - List tools here -->
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself. If I didn't, I gave proper attribution to AI assistance above.
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented above
+27
View File
@@ -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"
+151
View File
@@ -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
+2 -2
View File
@@ -93,8 +93,8 @@ act -W .github/workflows/release.yml --input_type bump=minor
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is permitted only when explicitly disclosed and approved. For example: disclosing the use of AI-generated code in a PR description.
Submissions must certify that the contributor understands and can maintain the code they submit.
Generated
+165 -112
View File
@@ -158,7 +158,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -184,9 +184,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
version = "1.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -194,9 +194,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
dependencies = [
"cc",
"cmake",
@@ -252,11 +252,11 @@ dependencies = [
[[package]]
name = "bit-set"
version = "0.8.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
dependencies = [
"bit-vec 0.8.0",
"bit-vec 0.10.1",
]
[[package]]
@@ -267,9 +267,13 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit-vec"
version = "0.8.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
dependencies = [
"borsh",
"serde",
]
[[package]]
name = "bitflags"
@@ -301,6 +305,30 @@ dependencies = [
"objc2",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "bstr"
version = "1.13.1"
@@ -353,9 +381,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.5.1"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -441,7 +469,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -742,7 +770,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -775,7 +803,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -911,7 +939,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -937,9 +965,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.18.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "encode_unicode"
@@ -1147,7 +1175,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -1244,9 +1272,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "h2"
version = "0.4.19"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
@@ -1373,9 +1401,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
[[package]]
name = "hyper"
version = "1.11.1"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
@@ -1593,18 +1621,18 @@ dependencies = [
[[package]]
name = "indoc"
version = "2.0.7"
version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
dependencies = [
"rustversion",
]
[[package]]
name = "insta"
version = "1.48.0"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
checksum = "b67d3d2e287e4b86c10b3f3b641033d1f89b74bdb39d05f34952e2b9a6fe21cd"
dependencies = [
"console 0.16.6",
"once_cell",
@@ -1622,7 +1650,7 @@ dependencies = [
"indoc",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -1741,15 +1769,15 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.189"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libm"
@@ -1795,9 +1823,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]]
name = "lock_api"
@@ -1869,17 +1897,17 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "mac_address"
version = "1.1.8"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
dependencies = [
"nix 0.29.0",
"winapi",
"nix 0.31.3",
"windows-sys 0.61.2",
]
[[package]]
name = "managarr"
version = "0.7.3"
version = "0.8.1"
dependencies = [
"anyhow",
"assert_cmd",
@@ -1984,9 +2012,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.3"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
@@ -2067,7 +2095,6 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
@@ -2080,6 +2107,7 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
@@ -2144,9 +2172,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "objc2"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -2332,19 +2360,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.9.2"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
checksum = "b568374ba38b33a6c627141f891faf16902b08d2db26b8ede1bcb0a15b1919fa"
dependencies = [
"memchr",
"psm",
"stacker",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.9.2"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
checksum = "b66e184b924cebaaff20ab2256ca52f12332d528a39aa76553b5d96f92aacf7f"
dependencies = [
"pest",
"pest_generator",
@@ -2352,22 +2382,22 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.9.2"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
checksum = "a87478d267e4de54a626af9754f2f0f58e927aac6ed0575fe89bc05ad6851694"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
name = "pest_meta"
version = "2.9.2"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
checksum = "4f986f248b4241ac359b831f6139aaa34e03b08a37b6caf7e201a33f95c869e1"
dependencies = [
"pest",
]
@@ -2453,9 +2483,9 @@ dependencies = [
[[package]]
name = "powerfmt"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
@@ -2523,16 +2553,17 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bit-set 0.11.1",
"bit-vec 0.10.1",
"bitflags 2.13.2",
"chacha20",
"core_detect",
"num-traits",
"rand 0.9.5",
"rand_chacha",
"rand 0.10.3",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -2540,6 +2571,15 @@ dependencies = [
"unarray",
]
[[package]]
name = "psm"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
dependencies = [
"cc",
]
[[package]]
name = "quick-error"
version = "1.2.3"
@@ -2568,9 +2608,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.18"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -2591,9 +2631,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
@@ -2696,11 +2736,11 @@ dependencies = [
[[package]]
name = "rand_xorshift"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
"rand_core 0.9.5",
"rand_core 0.10.1",
]
[[package]]
@@ -3179,14 +3219,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -3197,9 +3237,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.1.1"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
@@ -3338,9 +3378,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "socket2"
@@ -3358,6 +3398,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -3443,9 +3496,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.6"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -3469,7 +3522,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -3649,7 +3702,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -3701,9 +3754,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
@@ -3724,7 +3777,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -3739,9 +3792,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.19"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
@@ -3753,9 +3806,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"serde_core",
"serde_spanned",
@@ -3765,18 +3818,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
version = "0.25.17+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"toml_datetime",
@@ -3786,18 +3839,18 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tower"
@@ -3898,9 +3951,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.26"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-segmentation"
@@ -3984,9 +4037,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"atomic",
"getrandom 0.4.3",
@@ -4076,9 +4129,9 @@ dependencies = [
[[package]]
name = "want"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
@@ -4141,7 +4194,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
"wasm-bindgen-shared",
]
@@ -4538,30 +4591,30 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.59"
version = "0.8.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
version = "0.8.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
dependencies = [
"proc-macro2",
"quote",
@@ -4585,15 +4638,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
@@ -4625,7 +4678,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
+1 -1
View File
@@ -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"]
+121 -64
View File
@@ -26,6 +26,11 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built
- [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/)
- [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/)
> [!NOTE]
> The official Readarr repository has been archived, but Managarr's Readarr support is compatible with any Readarr forks
> that maintain compatibility with the official Readarr v1 API. See [Tested Readarr Forks](#tested-readarr-forks) below
> for forks that have been explicitly tested with Managarr, and any oddities in configuring them to work with Managarr.
## Try Out the Demo
To try out Managarr before linking it to your HTPC, you can use the purpose built [managarr-demo](https://github.com/Dark-Alex-17/managarr-demo) repository.
Simply run the following command to start a demo:
@@ -134,8 +139,8 @@ Key:
| Symbol | Status |
|--------|-----------|
| ✅ | Supported |
| ❌ | Missing |
| ✅ | Supported |
| ❌ | Missing |
| 🕒 | Planned |
| 🚫 | Won't Add |
@@ -143,82 +148,82 @@ Key:
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Sonarr
| TUI | CLI | Feature |
|-----|-----|--------------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Lidarr
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, tracks |
| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add artists to your library |
| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files |
| ✅ | ✅ | Trigger automatic searches for artists or albums |
| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads |
| ✅ | ✅ | Manually search for full artist discographies or albums |
| ✅ | ✅ | Edit your artists and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, blocklist, tracks |
| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add artists to your library |
| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files |
| ✅ | ✅ | Trigger automatic searches for artists or albums |
| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads |
| ✅ | ✅ | Manually search for full artist discographies or albums |
| ✅ | ✅ | Edit your artists and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Readarr
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, editions |
| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add authors to your library |
| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files |
| ✅ | ✅ | Trigger automatic searches for authors or books |
| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads |
| ✅ | ✅ | Manually search for author releases or books |
| ✅ | ✅ | Edit your authors and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
| ✅ | ✅ | View your library, downloads, blocklist, editions |
| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add authors to your library |
| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files |
| ✅ | ✅ | Trigger automatic searches for authors or books |
| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads |
| ✅ | ✅ | Manually search for author releases or books |
| ✅ | ✅ | Edit your authors and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Whisparr
@@ -261,7 +266,7 @@ To see all available commands, simply run `managarr --help`:
```shell
$ managarr --help
managarr 0.7.0
managarr 0.8.0
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
@@ -459,6 +464,11 @@ lidarr:
custom_headers: # Example of adding custom headers to all requests to the Servarr instance
traefik-auth-bypass-key: someBypassKey1234567890
SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE}
readarr:
- name: Chaptarr (Ebooks) # Example of how to use the Readarr support with a Readarr fork like Chaptarr
uri: http://192.168.0.87:8789/readarr/gr/ebook
api_token: someApiToken1234567890
```
### SSL Configuration
@@ -568,6 +578,46 @@ Managarr supports using environment variables on startup so you don't have to al
| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` |
| `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` |
## Tested Readarr Forks
The following Readarr forks have been explicitly tested with Managarr:
* [Bookshelf](https://github.com/pennydreadful/bookshelf)
* [Readarr-Resurrected](https://github.com/ricetim/readarr-rresurrected)
* [Librarr](https://github.com/Rorqualx/Librarr)
* [Chaptarr](https://github.com/Chaptarr/Chaptarr)
> [!IMPORTANT]
> Chaptarr houses a dedicated Readarr facade middleware that activates on a path prefix, meaning a bare config doesn't
> work. Instead, users need to configure both Audiobooks and Ebooks separately, and based on their desired metadata
> fetcher (i.e. Goodreads and Hardcover IDs). Because of this, Chaptarr-based configurations must use the `uri` key
> rather than the `host` and `port` keys. The below configuration file demonstrates all the supported variations for
> connecting Managarr to Chaptarr.
### Chaptarr Example Configuration
```yaml
readarr:
# Because Chaptarr supports both eBooks and audiobooks, the same instance must be provisioned twice if you're using
# both so Managarr can differentiate between the two. Thus, for Chaptarr, you need an eBook server configured and an
# audiobook server configured like below.
- name: eBooks (Goodreads) # Readarr originally used Goodreads as its metadata provider
uri: http://192.168.0.87:8789/readarr/gr/ebook # Note the `gr` in the path prefix indicating "GoodReads"
api_token: someApiToken1234567890
- name: Chaptarr Audiobooks (Goodreads)
uri: http://192.168.0.105:8789/readarr/gr/audiobook
api_token: someApiToken1234567890
- name: Chaptarr eBooks (Hardcover)
uri: http://192.168.0.105:8789/readarr/hc/ebook # Note the `hc` in the path prefix indicating "HardCover"
api_token: someApiToken1234567890
- name: Chaptarr Audiobooks (Hardcover)
uri: http://192.168.0.105:8789/readarr/hc/audiobook
api_token: someApiToken1234567890
```
## Screenshots
### Radarr
@@ -590,6 +640,13 @@ Managarr supports using environment variables on startup so you don't have to al
![artist_discography_search](screenshots/lidarr/artist_discography_search.png)
![manual_album_search](screenshots/lidarr/manual_album_search.png)
### Readarr
![readarr_library](screenshots/readarr/readarr_library.png)
![author_details](screenshots/readarr/author_details.png)
![book_details](screenshots/readarr/book_details.png)
![manual_author_search](screenshots/readarr/manual_author_search.png)
![manual_album_search](screenshots/readarr/manual_book_search.png)
### General
![logs](screenshots/radarr/logs.png)
![indexers](screenshots/radarr/indexers.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+44
View File
@@ -9,7 +9,9 @@ mod tests {
use tokio::sync::Mutex;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::readarr::ReadarrCommand;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::readarr_network::ReadarrEvent;
use crate::{
Cli,
app::App,
@@ -24,6 +26,10 @@ mod tests {
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable,
},
readarr_models::{
BlocklistItem as ReadarrBlocklistItem, BlocklistResponse as ReadarrBlocklistResponse,
ReadarrSerdeable,
},
sonarr_models::{
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable,
@@ -68,6 +74,13 @@ mod tests {
assert_ok!(&result);
}
#[test]
fn test_readarr_subcommand_delegates_to_readarr() {
let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "authors"]);
assert_ok!(&result);
}
#[test]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -218,4 +231,35 @@ mod tests {
assert_ok!(&result);
}
#[tokio::test]
async fn test_cli_handler_delegates_readarr_commands_to_the_readarr_cli_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse(
ReadarrBlocklistResponse {
records: vec![ReadarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = ReadarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert_ok!(&result);
}
}
+2 -2
View File
@@ -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,
}
+1
View File
@@ -171,6 +171,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH
quality_profile_id,
metadata_profile_id,
tags,
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor,
@@ -447,6 +447,7 @@ mod tests {
quality_profile_id: 1,
metadata_profile_id: 1,
tags: vec![1, 2],
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor: MonitorType::All,
+244 -3
View File
@@ -1,5 +1,20 @@
#[cfg(test)]
mod tests {
use crate::cli::{
Command,
readarr::{ReadarrCommand, list_command_handler::ReadarrListCommand},
};
use pretty_assertions::assert_eq;
#[test]
fn test_readarr_command_from() {
let command = ReadarrCommand::List(ReadarrListCommand::Authors);
let result = Command::from(command.clone());
assert_eq!(result, Command::Readarr(command));
}
mod cli {
use clap::CommandFactory;
use clap::error::ErrorKind;
@@ -243,20 +258,246 @@ mod tests {
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::readarr::add_command_handler::ReadarrAddCommand;
use crate::cli::readarr::edit_command_handler::ReadarrEditCommand;
use crate::cli::readarr::get_command_handler::ReadarrGetCommand;
use crate::cli::readarr::refresh_command_handler::ReadarrRefreshCommand;
use crate::cli::readarr::trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand;
use crate::models::readarr_models::{
BlocklistItem, BlocklistResponse, ReadarrSerdeable, ReadarrTaskName,
Author, BlocklistItem, BlocklistResponse, DeleteParams, ReadarrSerdeable, ReadarrTaskName,
};
use crate::models::servarr_models::ReleaseDownloadBody;
use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody};
use crate::{
app::App,
cli::{
CliCommandHandler,
readarr::{ReadarrCliHandler, ReadarrCommand},
readarr::{
ReadarrCliHandler, ReadarrCommand, delete_command_handler::ReadarrDeleteCommand,
list_command_handler::ReadarrListCommand,
},
},
models::Serdeable,
network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent},
};
#[tokio::test]
async fn test_readarr_cli_handler_delegates_add_commands_to_the_add_command_handler() {
let expected_tag_name = "test".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = ReadarrCommand::Add(ReadarrAddCommand::Tag {
name: expected_tag_name,
});
let result = ReadarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_get_commands_to_the_get_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = ReadarrCommand::Get(ReadarrGetCommand::SystemStatus);
let result = ReadarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_delete_author_params = DeleteParams {
id: 7,
delete_files: true,
add_import_list_exclusion: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::DeleteAuthor(expected_delete_author_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_author_command = ReadarrCommand::Delete(ReadarrDeleteCommand::Author {
author_id: 7,
delete_files_from_disk: true,
add_list_exclusion: false,
});
let result = ReadarrCliHandler::with(&app_arc, delete_author_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() {
let expected_edit_all_indexer_settings = IndexerSettings {
id: 1,
maximum_size: 26500,
minimum_age: 17,
retention: 43,
rss_sync_interval: 35,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings(
IndexerSettings {
id: 9,
maximum_size: 31200,
minimum_age: 22,
retention: 58,
rss_sync_interval: 90,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command =
ReadarrCommand::Edit(ReadarrEditCommand::AllIndexerSettings {
maximum_size: Some(26500),
minimum_age: Some(17),
retention: Some(43),
rss_sync_interval: Some(35),
});
let result = ReadarrCliHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_list_commands_to_the_list_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::ListAuthors.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Authors(vec![
Author::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_authors_command = ReadarrCommand::List(ReadarrListCommand::Authors);
let result = ReadarrCliHandler::with(&app_arc, list_authors_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::UpdateAllAuthors.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_all_authors_command = ReadarrCommand::Refresh(ReadarrRefreshCommand::AllAuthors);
let result =
ReadarrCliHandler::with(&app_arc, refresh_all_authors_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler()
{
let expected_author_id = 7;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::TriggerAutomaticAuthorSearch(expected_author_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
ReadarrCommand::TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand::Author {
author_id: expected_author_id,
});
let result = ReadarrCliHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_mark_history_item_as_failed_command() {
let expected_history_item_id = 1234i64;
@@ -88,6 +88,7 @@ impl AddAuthorHandler<'_, '_> {
quality_profile_id,
metadata_profile_id,
tags: Vec::new(),
images: Vec::new(),
tag_input_string: Some(tags),
add_options: AddAuthorOptions {
monitor,
@@ -1237,6 +1237,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -1628,6 +1629,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -1795,6 +1797,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
+6 -2
View File
@@ -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();
@@ -185,6 +187,7 @@ async fn main() -> Result<()> {
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,
@@ -219,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)
+26
View File
@@ -222,6 +222,8 @@ pub struct AddAuthorBody {
pub quality_profile_id: i64,
pub metadata_profile_id: i64,
pub tags: Vec<i64>,
// Readarr-Resurrected persists the add body directly and rejects it when the images list is absent
pub images: Vec<Value>,
#[serde(skip_serializing, skip_deserializing)]
pub tag_input_string: Option<String>,
pub add_options: AddAuthorOptions,
@@ -280,6 +282,7 @@ pub struct Book {
pub ratings: Option<Ratings>,
pub release_date: Option<DateTime<Utc>>,
pub statistics: Option<BookStatistics>,
#[serde(default)]
pub grabbed: bool,
}
@@ -454,6 +457,29 @@ pub struct ReadarrRelease {
pub quality: QualityWrapper,
}
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(untagged)]
pub enum ReadarrReleaseResponse {
Releases(Vec<ReadarrRelease>),
// Chaptarr wraps the release list in a filter summary envelope, even behind its Readarr facade
Envelope { releases: Vec<ReadarrRelease> },
}
impl Default for ReadarrReleaseResponse {
fn default() -> Self {
ReadarrReleaseResponse::Releases(Vec::new())
}
}
impl ReadarrReleaseResponse {
pub fn into_releases(self) -> Vec<ReadarrRelease> {
match self {
ReadarrReleaseResponse::Releases(releases)
| ReadarrReleaseResponse::Envelope { releases } => releases,
}
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BlocklistItem {
+40 -1
View File
@@ -9,7 +9,7 @@ mod tests {
AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord,
DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType,
ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrRelease,
ReadarrTask, ReadarrTaskName,
ReadarrReleaseResponse, ReadarrTask, ReadarrTaskName,
};
use crate::models::servarr_models::{
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log,
@@ -497,6 +497,29 @@ mod tests {
assert_eq!(readarr_serdeable, ReadarrSerdeable::Releases(releases));
}
#[test]
fn test_readarr_release_response_default_is_an_empty_release_list() {
assert_eq!(
ReadarrReleaseResponse::default(),
ReadarrReleaseResponse::Releases(Vec::new())
);
}
#[test]
fn test_readarr_release_response_into_releases() {
let releases = vec![ReadarrRelease {
guid: "test".to_owned(),
..ReadarrRelease::default()
}];
let list = ReadarrReleaseResponse::Releases(releases.clone());
let envelope = ReadarrReleaseResponse::Envelope {
releases: releases.clone(),
};
assert_eq!(list.into_releases(), releases);
assert_eq!(envelope.into_releases(), releases);
}
#[test]
fn test_readarr_serdeable_from_security_config() {
let security_config = SecurityConfig {
@@ -939,6 +962,22 @@ mod tests {
assert_none!(&book.statistics);
}
#[test]
fn test_book_deserialization_defaults_grabbed_when_absent() {
let book_json = json!({
"id": 1,
"title": "The Name of the Wind",
"authorId": 3,
"foreignBookId": "test-foreign-book-id",
"monitored": true
});
let book: Book = serde_json::from_value(book_json).unwrap();
assert!(!book.grabbed);
assert!(book.monitored);
}
#[test]
fn test_edition_deserialization() {
let edition_json = json!({
+33 -3
View File
@@ -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,
+102 -2
View File
@@ -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 │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ │
+106 -26
View File
@@ -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,13 +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::models::Route;
use crate::network::Network;
use crate::network::{Network, NetworkError};
use crate::ui::theme::ThemeDefinitionsWrapper;
#[cfg(test)]
@@ -32,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 {
@@ -100,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
@@ -282,6 +329,36 @@ 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,
@@ -295,7 +372,8 @@ pub(super) async fn start_cli_with_spinner(
let mut app = app.lock().await;
app.cli_mode = true;
select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| {
log_and_print_error(error.to_string());
error!("{error}");
eprintln!("{}", format_cli_error(&error));
process::exit(1);
});
}
@@ -309,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);
}
}
@@ -328,7 +406,8 @@ pub(super) async fn start_cli_no_spinner(
let mut app = app.lock().await;
app.cli_mode = true;
select_cli_configuration(&mut app, &config, &command, servarr_name).unwrap_or_else(|error| {
log_and_print_error(error.to_string());
error!("{error}");
eprintln!("{}", format_cli_error(&error));
process::exit(1);
});
}
@@ -339,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);
}
}
@@ -360,6 +439,7 @@ pub fn select_cli_configuration(
(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(|| {
+199 -1
View File
@@ -1,6 +1,8 @@
#[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};
@@ -11,7 +13,11 @@ mod tests {
use crate::{
Cli,
app::{App, AppConfig, ServarrConfig},
utils::{convert_runtime, format_size, select_cli_configuration, was_log_rotated},
network::NetworkError,
utils::{
build_logging_config, convert_runtime, format_cli_error, format_size, resolve_log_dir,
select_cli_configuration, was_log_rotated,
},
};
#[test]
@@ -64,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");
@@ -114,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();
}
}
+65 -6
View File
@@ -5,7 +5,7 @@ use std::{
sync::atomic::{AtomicU64, Ordering},
};
use assert_cmd::cargo::cargo_bin_cmd;
use assert_cmd::{Command, cargo::cargo_bin_cmd};
use mockito::Server;
static NEXT_CONFIG_ID: AtomicU64 = AtomicU64::new(0);
@@ -36,6 +36,14 @@ impl Drop for TemporaryConfig {
}
}
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();
@@ -62,7 +70,7 @@ fn named_instance_of_different_servarr_type_rejects_command() {
sonarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -86,6 +94,57 @@ fn named_instance_of_different_servarr_type_rejects_command() {
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();
@@ -112,7 +171,7 @@ fn same_named_servarrs_select_command_matching_instance() {
sonarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -153,7 +212,7 @@ fn unknown_servarr_name_fails() {
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n",
radarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -192,7 +251,7 @@ fn whitespace_servarr_name_trimmed() {
"radarr:\n - name: Movies\n uri: {}\n api_token: test-token\n",
radarr_server.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -242,7 +301,7 @@ fn no_servarr_name_uses_first_configured_instance() {
radarr_server2.url()
));
let mut command = cargo_bin_cmd!("managarr");
let mut command = managarr_command();
let assertion = command
.arg("--config-file")
.arg(config.path())
@@ -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}"
);
}