Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot]
c5161f828d chore: bump Cargo.toml to 0.7.0
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 11m0s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 57s
Check / 1.89.0 / check (push) Successful in 1m0s
Test Suite / ubuntu / beta (push) Successful in 1m42s
Test Suite / ubuntu / stable (push) Successful in 1m42s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m38s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-01-21 19:22:11 +00:00
github-actions[bot]
71c64167f0 bump: version 0.6.3 → 0.7.0 [skip ci] 2026-01-21 19:22:03 +00:00
Alex Clarke
4d3e00fd94 Merge pull request #52 from Dark-Alex-17/lidarr
Lidarr Support
2026-01-21 11:57:50 -07:00
9e96e74c87 ci: Fixed the docker-build Justfile recipe
Check / stable / fmt (pull_request) Successful in 10m40s
Check / beta / clippy (pull_request) Successful in 10m59s
Check / stable / clippy (pull_request) Successful in 10m59s
Check / nightly / doc (pull_request) Successful in 58s
Check / 1.89.0 / check (pull_request) Successful in 1m2s
Test Suite / ubuntu / beta (pull_request) Successful in 1m43s
Test Suite / ubuntu / stable (pull_request) Successful in 1m41s
Test Suite / ubuntu / stable / coverage (pull_request) Successful in 13m1s
Test Suite / macos-latest / stable (pull_request) Has been cancelled
Test Suite / windows-latest / stable (pull_request) Has been cancelled
2026-01-21 10:39:51 -07:00
ddb869c341 docs: Reword some Sonarr manual search CLI docs to be more explicit about how the results are filtered 2026-01-20 14:37:42 -07:00
f17f542e8e refactor: Refactored the SonarrEvent enum to not unnecessarily wrap dual series_id and season_number values in a tuple when both values can be passed directly 2026-01-19 16:44:10 -07:00
a2e6400a38 docs: Updated README with information about Lidarr support 2026-01-19 16:29:02 -07:00
89f5ff6bc7 feat: Blocklist support in Lidarr in both the CLI and TUI 2026-01-19 16:13:11 -07:00
76 changed files with 2505 additions and 261 deletions
+77
View File
@@ -5,6 +5,83 @@ 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.7.0 (2026-01-21)
### Feat
- Blocklist support in Lidarr in both the CLI and TUI
- CLI and TUI support for track history and track details in Lidarr
- Lidarr UI support for album details popup
- Implemented TUI handler support for the Album Details popup in Lidarr
- Bulk added CLI support for tracks and album functionalities in Lidarr
- Implemented the manual artist discography search tab in Lidarr's artist details UI
- Lidarr CLI support for downloading a release
- CLI support for searching for discography releases in Lidarr
- Added TUI and CLI support for viewing Artist history in Lidarr
- Full Lidarr system support for both the CLI and TUI
- Full CLI and TUI support for the Lidarr Indexers tab
- Full support for adding a root folder in Lidarr from both the CLI and TUI
- naive lidarr root folder tab implementation. Needs improved add logic
- Downloads tab support in Lidarr
- Created a History tab in the Radarr UI and created a list history command and mark-history-item-as-failed command for Radarr
- Implemented the Lidarr History tab and CLI support
- TUI support for deleting a Lidarr album from the artist details popup
- CLI support for deleting an album from Lidarr
- Completed support for viewing Lidarr artist details
- Full CLI and TUI support for adding an artist to Lidarr
- Include the Lidarr artist disambiguation in the title of the Edit Artist popup
- Initial Lidarr support for searching for new artists
- Lidarr CLI commands to list quality profiles and metadata profiles
- Improved CLI readability by creating a separate Global Options section for global flags
- CLI support for deleting a tag in Lidarr
- Lidarr CLI support for listing and adding tags
- Added CLI and TUI support for editing Lidarr artists
- Support for updating all Lidarr artists in both the CLI and TUI
- Added Lidarr CLI support for fetching the host config and the security config
- Created Lidarr commands: 'get artist-details' and 'get system-status'
- Fetch the artist members as part of the artist details query
- Support for toggling the monitoring of a given artist via the CLI and TUI
- Full support for deleting an artist via CLI and TUI
- TUI support for Lidarr library
- CLI support for listing artists
- Improved UI speed and responsiveness
### Fix
- Sonarr network wasn't checking for the user to be using the sorting block when populating season details
- Sonarr CLI was not properly filtering out episode and season releases when manually searching for releases
- Sonarr manual search TUI and CLI incorrectly displaying the same unfiltered results for both season and episode searches
- Slowed down the automatic text scrolling in tables so the text is readable
- Expanded the history item details size so that it can include all the available information for a given item; was previously being cut off on some screens
- Bug in submitting the update series prompt in the series details UI in Sonarr
- Don't include Lidarr artist disambiguation in Edit popup title when it is empty
- Refactored how quality profiles, language profiles, and metadata profiles are populated for each servarr so they sort using the ID to mimic the web UI better
- Added the correct keybinding context to the Lidarr edit artist popup
- Improved fault tolerance for search result tables and test all indexer results tables
- Prevented additional empty slice errors in indexer tables
- Fixed a bug in all Servarr implementations to not try to get the current selection of a search table when an error is returned from the API
- Fixed an issue with the Managarr table that would incorrectly try to display things before is_loading was ready
- Fixed a bug where the edit collection popup would not display when opening it from collection details
### Refactor
- Refactored the SonarrEvent enum to not unnecessarily wrap dual series_id and season_number values in a tuple when both values can be passed directly
- Improved and simplified the implementation of history details for both Sonarr and Lidarr
- Let serde serialize Add Series and Add Movie enums instead of calling to_string up front
- Use is_multiple_of for the tick counter in the UI module
- Updated all model tests to use purpose-built assertions to improve readability and maintainability
- Updated all handler tests to use purpose built assertions to improve readability and maintainability
- Used is_multiple_of to make life easier and cleaner in the app module
- Refactored all cli tests to use purpose-built assertions
- Improved test assertions in the app module
- Created dedicated proptests and assertions to clean up the handler unit tests
- Migrated the handle_table_events macro into a trait for better IDE support, created a TableEventAdapter wrapper for the KeyEventHandlers to make it so that the trait can be used properly and a simple function to replace the previous call to the handle_table_events macro
- Simplified both the table_handler macro and the stateful_table implementation
- Improved error handling for the tail-logs subcommand to propagate errors up the stack instead of exiting there.
- Added accessor methods to servarr_data structs, replaced for loops with functional iterator chains, eliminated mutable state tracking, and updated network module to use get_or_insert_default() for modal options
- Improved error handling project-wide and cleaned up some regexes with unnecessary escapes (tail_logs and interpolate_env_vars)
- Refactored to use more idiomatic let-else statements where applicable
## v0.6.3 (2025-12-13)
### Fix
Generated
+86 -94
View File
@@ -118,9 +118,9 @@ dependencies = [
[[package]]
name = "assert_cmd"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85"
checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
dependencies = [
"anstyle",
"bstr",
@@ -133,9 +133,9 @@ dependencies = [
[[package]]
name = "assertables"
version = "9.8.3"
version = "9.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbada39b42413d4db3d9460f6e791702490c40f72924378a1b6fc1a4181188fd"
checksum = "4dcd1f7f2f608b9a888a851f234086946c2ca1dfeadf1431c5082fee0942eeb6"
[[package]]
name = "async-trait"
@@ -305,9 +305,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.51"
version = "1.2.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -327,9 +327,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -385,9 +385,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.6"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "colorchoice"
@@ -397,11 +397,11 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "colored"
version = "3.0.0"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -854,9 +854,9 @@ dependencies = [
[[package]]
name = "euclid"
version = "0.22.11"
version = "0.22.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48"
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
dependencies = [
"num-traits",
]
@@ -890,9 +890,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
[[package]]
name = "finl_unicode"
@@ -1029,9 +1029,9 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.2.16"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
@@ -1418,9 +1418,9 @@ dependencies = [
[[package]]
name = "insta"
version = "1.46.0"
version = "1.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5"
checksum = "248b42847813a1550dafd15296fd9748c651d0c32194559dbc05d804d54b21e8"
dependencies = [
"console",
"once_cell",
@@ -1463,15 +1463,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1489,9 +1480,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.83"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -1505,7 +1496,7 @@ checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
dependencies = [
"hashbrown",
"portable-atomic",
"thiserror 2.0.17",
"thiserror 2.0.18",
]
[[package]]
@@ -1522,9 +1513,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.179"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libredox"
@@ -1615,7 +1606,7 @@ dependencies = [
"serde-value",
"serde_json",
"serde_yaml",
"thiserror 2.0.17",
"thiserror 2.0.18",
"thread-id",
"typemap-ors",
"unicode-segmentation",
@@ -1643,7 +1634,7 @@ dependencies = [
[[package]]
name = "managarr"
version = "0.6.3"
version = "0.7.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -1668,7 +1659,7 @@ dependencies = [
"indicatif",
"indoc",
"insta",
"itertools 0.14.0",
"itertools",
"log",
"log4rs",
"managarr-tree-widget",
@@ -2333,7 +2324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core 0.9.3",
"rand_core 0.9.5",
]
[[package]]
@@ -2343,7 +2334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
"rand_core 0.9.5",
]
[[package]]
@@ -2354,9 +2345,9 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.3"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
@@ -2367,7 +2358,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.3",
"rand_core 0.9.5",
]
[[package]]
@@ -2394,11 +2385,11 @@ dependencies = [
"compact_str",
"hashbrown",
"indoc",
"itertools 0.14.0",
"itertools",
"kasuari",
"lru",
"strum 0.27.2",
"thiserror 2.0.17",
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
@@ -2446,7 +2437,7 @@ dependencies = [
"hashbrown",
"indoc",
"instability",
"itertools 0.14.0",
"itertools",
"line-clipping",
"ratatui-core",
"strum 0.27.2",
@@ -2470,7 +2461,7 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
@@ -2558,7 +2549,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -2596,9 +2587,9 @@ dependencies = [
[[package]]
name = "rustc-demangle"
version = "0.1.26"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc_version"
@@ -2650,18 +2641,18 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.13.2"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.8"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3193,11 +3184,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.17",
"thiserror-impl 2.0.18",
]
[[package]]
@@ -3213,9 +3204,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
@@ -3224,34 +3215,34 @@ dependencies = [
[[package]]
name = "thread-id"
version = "5.0.0"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99043e46c5a15af379c06add30d9c93a6c0e8849de00d244c4a2c417da128d80"
checksum = "2010d27add3f3240c1fef7959f46c814487b216baee662af53be645ba7831c07"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "time"
version = "0.3.44"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
[[package]]
name = "tinystr"
@@ -3326,9 +3317,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.10+spec-1.1.0"
version = "0.9.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [
"serde_core",
"serde_spanned",
@@ -3374,9 +3365,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tower"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
@@ -3483,20 +3474,20 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "2.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.13.0",
"itertools",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.2.0"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
@@ -3654,18 +3645,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
"cfg-if",
"once_cell",
@@ -3676,11 +3667,12 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.56"
version = "0.4.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
@@ -3689,9 +3681,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3699,9 +3691,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.106"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -3712,18 +3704,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.106"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.83"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4234,9 +4226,9 @@ dependencies = [
[[package]]
name = "wit-bindgen"
version = "0.46.0"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "writeable"
@@ -4355,6 +4347,6 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.12"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "managarr"
version = "0.6.3"
version = "0.7.0"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
+53 -21
View File
@@ -12,17 +12,16 @@
![Docker pulls](https://img.shields.io/docker/pulls/darkalex17/managarr?label=Docker%20downloads)
[![Matrix](https://img.shields.io/matrix/managarr-room%3Amatrix.org?logo=matrix&server_fqdn=matrix.org&fetchMode=guest&style=social&label=Managarr%20Matrix%20Space&link=https%3A%2F%2Fmatrix.to%2F%23%2F%23managarr%3Amatrix.org)](https://matrix.to/#/#managarr:matrix.org)
Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust!
![library](screenshots/sonarr/sonarr_library.png)
![library](screenshots/lidarr/lidarr_library.png)
## What Servarrs are supported?
- [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr)
- [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr)
- [x] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr)
- [ ] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr)
- [ ] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr)
- [ ] ![prowlarr_logo](logos/prowlarr.png) [Prowlarr](https://wiki.servarr.com/en/prowlarr)
- [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr)
- [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/)
@@ -96,7 +95,7 @@ of Chocolatey packages take quite some time, and thus the package may not be ava
choco install managarr
# Some newer releases may require a version number, so you can specify it like so:
choco install managarr --version=0.5.0
choco install managarr --version=0.7.0
```
To upgrade to the latest and greatest version of Managarr:
@@ -104,7 +103,7 @@ To upgrade to the latest and greatest version of Managarr:
choco upgrade managarr
# To upgrade to a specific version:
choco upgrade managarr --version=0.5.0
choco upgrade managarr --version=0.7.0
```
### Manual
@@ -182,14 +181,30 @@ Key:
| ✅ | ✅ | 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 |
### Readarr
- [ ] Support for Readarr
### Lidarr
- [ ] Support for Lidarr
### Whisparr
- [ ] Support for Whisparr
@@ -231,7 +246,7 @@ To see all available commands, simply run `managarr --help`:
```shell
$ managarr --help
managarr 0.5.1
managarr 0.7.0
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
@@ -241,20 +256,24 @@ Usage: managarr [OPTIONS] [COMMAND]
Commands:
radarr Commands for manging your Radarr instance
sonarr Commands for manging your Sonarr instance
lidarr Commands for manging your Lidarr instance
completions Generate shell completions for the Managarr CLI
tail-logs Tail Managarr logs
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Global Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used.
-h, --help Print help
-V, --version Print version
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used.
```
All subcommands also have detailed help menus to show you how to use them. For example, to see all available commands for Sonarr, you would run:
@@ -283,12 +302,21 @@ Commands:
test-all-indexers Test all Sonarr indexers
toggle-episode-monitoring Toggle monitoring for the specified episode
toggle-season-monitoring Toggle monitoring for the specified season that corresponds to the specified series ID
toggle-series-monitoring Toggle monitoring for the specified series corresponding to the given series ID
help Print this message or the help of the given subcommand(s)
Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config <CONFIG> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
-h, --help Print help
-h, --help Print help
Global Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used.
```
**Pro Tip:** The CLI is even more powerful and useful when used in conjunction with the `jq` CLI tool. This allows you to parse the JSON response from the Managarr CLI and use it in your scripts; For example, to extract the `movieId` of the movie "Ad Astra", you would run:
@@ -428,9 +456,6 @@ 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` |
## Track What I'm Currently Working On
To see what feature(s) I'm currently working on, check out my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr).
## Screenshots
### Radarr
@@ -446,6 +471,13 @@ To see what feature(s) I'm currently working on, check out my [Wekan Board](http
![season_details](screenshots/sonarr/season_details.png)
![manual_episode_search](screenshots/sonarr/manual_episode_search.png)
### Lidarr
![lidarr_library](screenshots/lidarr/lidarr_library.png)
![artist_details](screenshots/lidarr/artist_details.png)
![album_details](screenshots/lidarr/album_details.png)
![artist_discography_search](screenshots/lidarr/artist_discography_search.png)
![manual_album_search](screenshots/lidarr/manual_album_search.png)
### General
![logs](screenshots/radarr/logs.png)
![indexers](screenshots/radarr/indexers.png)
@@ -461,8 +493,8 @@ To see what feature(s) I'm currently working on, check out my [Wekan Board](http
## Servarr Requirements
* [Radarr >= 5.3.6.8612](https://radarr.video/docs/api/)
* [Sonarr >= v4](https://sonarr.tv/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Lidarr v1](https://lidarr.audio/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Whisparr >= v3](https://whisparr.com/docs/api/)
* [Prowlarr v1](https://prowlarr.com/docs/api/)
* [Bazarr v1.1.4](http://localhost:6767/api)
+1 -3
View File
@@ -1,7 +1,5 @@
VERSION := "latest"
IMG_NAME := "darkalex17/managarr"
IMAGE := "{{IMG_NAME}}:{{VERSION}}"
# List all recipes
default:
@@ -88,4 +86,4 @@ build build_type='debug':
# Build the docker image
[group: 'build']
build-docker:
@DOCKER_BUILDKIT=1 docker build --rm -t {{IMAGE}}
@DOCKER_BUILDKIT=1 docker build --rm -t {{IMG_NAME}}:{{VERSION}} .
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

+18
View File
@@ -57,6 +57,24 @@ mod tests {
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_blocklist_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.data.lidarr_data.artists.set_items(vec![artist()]);
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Blocklist)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetBlocklist.into());
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_artist_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
+5
View File
@@ -27,6 +27,11 @@ impl App<'_> {
.dispatch_network_event(LidarrEvent::ListArtists.into())
.await;
}
ActiveLidarrBlock::Blocklist => {
self
.dispatch_network_event(LidarrEvent::GetBlocklist.into())
.await;
}
ActiveLidarrBlock::Downloads => {
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
+4 -6
View File
@@ -58,21 +58,19 @@ impl App<'_> {
}
ActiveSonarrBlock::SeasonHistory => {
if !self.data.sonarr_data.seasons.is_empty() {
let (series_id, season_number) = self.extract_series_id_season_number_tuple().await;
self
.dispatch_network_event(
SonarrEvent::GetSeasonHistory(self.extract_series_id_season_number_tuple().await)
.into(),
)
.dispatch_network_event(SonarrEvent::GetSeasonHistory(series_id, season_number).into())
.await;
}
}
ActiveSonarrBlock::ManualSeasonSearch => {
match self.data.sonarr_data.season_details_modal.as_ref() {
Some(season_details_modal) if season_details_modal.season_releases.is_empty() => {
let (series_id, season_number) = self.extract_series_id_season_number_tuple().await;
self
.dispatch_network_event(
SonarrEvent::GetSeasonReleases(self.extract_series_id_season_number_tuple().await)
.into(),
SonarrEvent::GetSeasonReleases(series_id, season_number).into(),
)
.await;
}
+2 -2
View File
@@ -132,7 +132,7 @@ mod tests {
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeasonHistory((1, 1)).into()
SonarrEvent::GetSeasonHistory(1, 1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
@@ -175,7 +175,7 @@ mod tests {
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeasonReleases((1, 1)).into()
SonarrEvent::GetSeasonReleases(1, 1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
+36 -1
View File
@@ -8,12 +8,18 @@ mod tests {
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::LidarrCommand;
use crate::network::lidarr_network::LidarrEvent;
use crate::{
Cli,
app::App,
cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand},
models::{
Serdeable,
lidarr_models::{
BlocklistItem as LidarrBlocklistItem, BlocklistResponse as LidarrBlocklistResponse,
LidarrSerdeable,
},
radarr_models::{
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable,
@@ -182,5 +188,34 @@ mod tests {
assert_ok!(&result);
}
// TODO: Implement test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler
#[tokio::test]
async fn test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse(
LidarrBlocklistResponse {
records: vec![LidarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = LidarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert_ok!(&result);
}
}
+16
View File
@@ -28,6 +28,15 @@ pub enum LidarrDeleteCommand {
#[arg(long, help = "Add a list exclusion for this album")]
add_list_exclusion: bool,
},
#[command(about = "Delete the specified item from the Lidarr blocklist")]
BlocklistItem {
#[arg(
long,
help = "The ID of the blocklist item to remove from the blocklist",
required = true
)]
blocklist_item_id: i64,
},
#[command(about = "Delete the specified track file from disk")]
TrackFile {
#[arg(long, help = "The ID of the track file to delete", required = true)]
@@ -107,6 +116,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteComm
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::BlocklistItem { blocklist_item_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteBlocklistItem(blocklist_item_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::TrackFile { track_file_id } => {
let resp = self
.network
@@ -86,6 +86,42 @@ mod tests {
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_blocklist_item_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "blocklist-item"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_blocklist_item_success() {
let expected_args = LidarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"blocklist-item",
"--blocklist-item-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_track_file_requires_arguments() {
let result =
@@ -361,6 +397,37 @@ mod tests {
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_blocklist_item_command() {
let expected_blocklist_item_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteBlocklistItem(expected_blocklist_item_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_blocklist_item_command = LidarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = LidarrDeleteCommandHandler::with(
&app_arc,
delete_blocklist_item_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_track_file_command() {
let expected_track_file_id = 1;
+37 -2
View File
@@ -25,7 +25,7 @@ mod tests {
#[rstest]
fn test_commands_that_have_no_arg_requirements(
#[values("test-all-indexers")] subcommand: &str,
#[values("clear-blocklist", "test-all-indexers")] subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", subcommand]);
@@ -284,7 +284,9 @@ mod tests {
use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand;
use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand;
use crate::cli::lidarr::trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand;
use crate::models::lidarr_models::{LidarrReleaseDownloadBody, LidarrTaskName};
use crate::models::lidarr_models::{
BlocklistItem, BlocklistResponse, LidarrReleaseDownloadBody, LidarrTaskName,
};
use crate::models::servarr_models::IndexerSettings;
use crate::{
app::App,
@@ -546,6 +548,39 @@ mod tests {
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_clear_blocklist_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse(
BlocklistResponse {
records: vec![BlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let claer_blocklist_command = LidarrCommand::ClearBlocklist;
let result = LidarrCliHandler::with(&app_arc, claer_blocklist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_download_release_command() {
let expected_release_download_body = LidarrReleaseDownloadBody {
+9
View File
@@ -57,6 +57,8 @@ pub enum LidarrListCommand {
},
#[command(about = "List all artists in your Lidarr library")]
Artists,
#[command(about = "List all items in the Lidarr blocklist")]
Blocklist,
#[command(about = "List all active downloads in Lidarr")]
Downloads {
#[arg(long, help = "How many downloads to fetch", default_value_t = 500)]
@@ -200,6 +202,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Blocklist => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Downloads { count } => {
let resp = self
.network
@@ -27,6 +27,7 @@ mod tests {
fn test_list_commands_have_no_arg_requirements(
#[values(
"artists",
"blocklist",
"indexers",
"metadata-profiles",
"quality-profiles",
@@ -433,6 +434,7 @@ mod tests {
#[rstest]
#[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)]
#[case(LidarrListCommand::Blocklist, LidarrEvent::GetBlocklist)]
#[case(LidarrListCommand::Indexers, LidarrEvent::GetIndexers)]
#[case(LidarrListCommand::MetadataProfiles, LidarrEvent::GetMetadataProfiles)]
#[case(LidarrListCommand::QualityProfiles, LidarrEvent::GetQualityProfiles)]
+13
View File
@@ -74,6 +74,8 @@ pub enum LidarrCommand {
about = "Commands to trigger automatic searches for releases of different resources in your Lidarr instance"
)]
TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand),
#[command(about = "Clear the Lidarr blocklist")]
ClearBlocklist,
#[command(about = "Manually download the given release")]
DownloadRelease {
#[arg(long, help = "The GUID of the release to download", required = true)]
@@ -217,6 +219,17 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, '
.handle()
.await?
}
LidarrCommand::ClearBlocklist => {
self
.network
.handle_network_event(LidarrEvent::GetBlocklist.into())
.await?;
let resp = self
.network
.handle_network_event(LidarrEvent::ClearBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::DownloadRelease { guid, indexer_id } => {
let params = LidarrReleaseDownloadBody { guid, indexer_id };
let resp = self
+1 -1
View File
@@ -249,7 +249,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH
} => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetSeasonHistory((series_id, season_number)).into())
.handle_network_event(SonarrEvent::GetSeasonHistory(series_id, season_number).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
+1 -1
View File
@@ -543,7 +543,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetSeasonHistory((expected_series_id, expected_season_number)).into(),
SonarrEvent::GetSeasonHistory(expected_series_id, expected_season_number).into(),
))
.times(1)
.returning(|_| {
@@ -30,7 +30,7 @@ pub enum SonarrManualSearchCommand {
episode_id: i64,
},
#[command(
about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID"
about = "Trigger a manual search of full-season releases (full_season: true) for the given season corresponding to the series with the given ID"
)]
Season {
#[arg(
@@ -88,7 +88,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrManualSearchCommand>
serde_json::to_string_pretty(&seasons_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
_ => serde_json::to_string_pretty(&json!({"message": "Unexpected response format"}))?,
}
}
SonarrManualSearchCommand::Season {
@@ -98,7 +98,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrManualSearchCommand>
println!("Searching for season releases. This may take a minute...");
match self
.network
.handle_network_event(SonarrEvent::GetSeasonReleases((series_id, season_number)).into())
.handle_network_event(SonarrEvent::GetSeasonReleases(series_id, season_number).into())
.await
{
Ok(Serdeable::Sonarr(SonarrSerdeable::Releases(releases_vec))) => {
@@ -176,7 +176,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetSeasonReleases((expected_series_id, expected_season_number)).into(),
SonarrEvent::GetSeasonReleases(expected_series_id, expected_season_number).into(),
))
.times(1)
.returning(|_| {
+1 -1
View File
@@ -297,7 +297,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, '
let resp = self
.network
.handle_network_event(
SonarrEvent::ToggleSeasonMonitoring((series_id, season_number)).into(),
SonarrEvent::ToggleSeasonMonitoring(series_id, season_number).into(),
)
.await?;
serde_json::to_string_pretty(&resp)?
+1 -1
View File
@@ -755,7 +755,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::ToggleSeasonMonitoring((expected_series_id, expected_season_number)).into(),
SonarrEvent::ToggleSeasonMonitoring(expected_series_id, expected_season_number).into(),
))
.times(1)
.returning(|_| {
@@ -94,7 +94,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrTriggerAutomaticSearchCommand>
let resp = self
.network
.handle_network_event(
SonarrEvent::TriggerAutomaticSeasonSearch((series_id, season_number)).into(),
SonarrEvent::TriggerAutomaticSeasonSearch(series_id, season_number).into(),
)
.await?;
serde_json::to_string_pretty(&resp)?
@@ -197,7 +197,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::TriggerAutomaticSeasonSearch((expected_series_id, expected_season_number))
SonarrEvent::TriggerAutomaticSeasonSearch(expected_series_id, expected_season_number)
.into(),
))
.times(1)
@@ -0,0 +1,615 @@
#[cfg(test)]
mod tests {
use std::cmp::Ordering;
use chrono::DateTime;
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::assert_navigation_pushed;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::handlers::lidarr_handlers::blocklist::{BlocklistHandler, blocklist_sorting_options};
use crate::models::lidarr_models::{Artist, BlocklistItem};
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS};
use crate::models::servarr_models::{Quality, QualityWrapper};
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::artist;
mod test_handle_delete {
use pretty_assertions::assert_eq;
use super::*;
const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key;
#[test]
fn test_delete_blocklist_item_prompt() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
BlocklistHandler::new(DELETE_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::DeleteBlocklistItemPrompt.into());
}
#[test]
fn test_delete_blocklist_item_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
BlocklistHandler::new(DELETE_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into());
}
}
mod test_handle_left_right_action {
use pretty_assertions::assert_eq;
use rstest::rstest;
use super::*;
use crate::assert_navigation_pushed;
#[rstest]
fn test_blocklist_tab_left(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(2);
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.left.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.handle();
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
ActiveLidarrBlock::Downloads.into()
);
assert_navigation_pushed!(app, ActiveLidarrBlock::Downloads.into());
}
#[rstest]
fn test_blocklist_tab_right(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(2);
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.right.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.handle();
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
ActiveLidarrBlock::History.into()
);
assert_navigation_pushed!(app, ActiveLidarrBlock::History.into());
}
#[rstest]
fn test_blocklist_left_right_prompt_toggle(
#[values(
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt
)]
active_lidarr_block: ActiveLidarrBlock,
#[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key,
) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
BlocklistHandler::new(key, &mut app, active_lidarr_block, None).handle();
assert!(app.data.lidarr_data.prompt_confirm);
BlocklistHandler::new(key, &mut app, active_lidarr_block, None).handle();
assert!(!app.data.lidarr_data.prompt_confirm);
}
}
mod test_handle_submit {
use crate::assert_navigation_popped;
use crate::network::lidarr_network::LidarrEvent;
use pretty_assertions::assert_eq;
use rstest::rstest;
use super::*;
const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key;
#[test]
fn test_blocklist_submit() {
let mut app = App::test_default();
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::BlocklistItemDetails.into());
}
#[test]
fn test_blocklist_submit_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into());
}
#[rstest]
#[case(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
LidarrEvent::DeleteBlocklistItem(3)
)]
#[case(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt,
LidarrEvent::ClearBlocklist
)]
fn test_blocklist_prompt_confirm_submit(
#[case] base_route: ActiveLidarrBlock,
#[case] prompt_block: ActiveLidarrBlock,
#[case] expected_action: LidarrEvent,
) {
let mut app = App::test_default();
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.data.lidarr_data.prompt_confirm = true;
app.push_navigation_stack(base_route.into());
app.push_navigation_stack(prompt_block.into());
BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle();
assert!(app.data.lidarr_data.prompt_confirm);
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&expected_action
);
assert_navigation_popped!(app, base_route.into());
}
#[rstest]
fn test_blocklist_prompt_decline_submit(
#[values(
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt
)]
prompt_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.push_navigation_stack(prompt_block.into());
BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle();
assert!(!app.data.lidarr_data.prompt_confirm);
assert_none!(app.data.lidarr_data.prompt_confirm_action);
assert_navigation_popped!(app, ActiveLidarrBlock::Blocklist.into());
}
}
mod test_handle_esc {
use rstest::rstest;
use super::*;
use crate::assert_navigation_popped;
const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key;
#[rstest]
#[case(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::DeleteBlocklistItemPrompt
)]
#[case(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt
)]
fn test_blocklist_prompt_blocks_esc(
#[case] base_block: ActiveLidarrBlock,
#[case] prompt_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(base_block.into());
app.push_navigation_stack(prompt_block.into());
app.data.lidarr_data.prompt_confirm = true;
BlocklistHandler::new(ESC_KEY, &mut app, prompt_block, None).handle();
assert_navigation_popped!(app, base_block.into());
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[test]
fn test_esc_blocklist_item_details() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.push_navigation_stack(ActiveLidarrBlock::BlocklistItemDetails.into());
BlocklistHandler::new(
ESC_KEY,
&mut app,
ActiveLidarrBlock::BlocklistItemDetails,
None,
)
.handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Blocklist.into());
}
#[rstest]
fn test_default_esc(#[values(true, false)] is_ready: bool) {
let mut app = App::test_default();
app.is_loading = is_ready;
app.error = "test error".to_owned().into();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
BlocklistHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::Blocklist, None).handle();
assert_navigation_popped!(app, ActiveLidarrBlock::Blocklist.into());
assert_is_empty!(app.error.text);
}
}
mod test_handle_key_char {
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::network::lidarr_network::LidarrEvent;
use super::*;
use crate::{assert_navigation_popped, assert_navigation_pushed};
#[test]
fn test_refresh_blocklist_key() {
let mut app = App::test_default();
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::Blocklist.into());
assert!(app.should_refresh);
}
#[test]
fn test_refresh_blocklist_key_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into());
assert!(!app.should_refresh);
}
#[test]
fn test_clear_blocklist_key() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.clear.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::BlocklistClearAllItemsPrompt.into());
}
#[test]
fn test_clear_blocklist_key_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.clear.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Blocklist.into());
}
#[rstest]
#[case(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
LidarrEvent::DeleteBlocklistItem(3)
)]
#[case(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt,
LidarrEvent::ClearBlocklist
)]
fn test_blocklist_prompt_confirm(
#[case] base_route: ActiveLidarrBlock,
#[case] prompt_block: ActiveLidarrBlock,
#[case] expected_action: LidarrEvent,
) {
let mut app = App::test_default();
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(base_route.into());
app.push_navigation_stack(prompt_block.into());
BlocklistHandler::new(
DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
prompt_block,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&expected_action
);
assert_navigation_popped!(app, base_route.into());
}
}
#[test]
fn test_blocklist_sorting_options_artist_name() {
let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| {
a.artist
.artist_name
.text
.to_lowercase()
.cmp(&b.artist.artist_name.text.to_lowercase())
};
let mut expected_blocklist_vec = blocklist_vec();
expected_blocklist_vec.sort_by(expected_cmp_fn);
let sort_option = blocklist_sorting_options()[0].clone();
let mut sorted_blocklist_vec = blocklist_vec();
sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_blocklist_vec, expected_blocklist_vec);
assert_str_eq!(sort_option.name, "Artist Name");
}
#[test]
fn test_blocklist_sorting_options_source_title() {
let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| {
a.source_title
.to_lowercase()
.cmp(&b.source_title.to_lowercase())
};
let mut expected_blocklist_vec = blocklist_vec();
expected_blocklist_vec.sort_by(expected_cmp_fn);
let sort_option = blocklist_sorting_options()[1].clone();
let mut sorted_blocklist_vec = blocklist_vec();
sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_blocklist_vec, expected_blocklist_vec);
assert_str_eq!(sort_option.name, "Source Title");
}
#[test]
fn test_blocklist_sorting_options_quality() {
let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| {
a.quality
.quality
.name
.to_lowercase()
.cmp(&b.quality.quality.name.to_lowercase())
};
let mut expected_blocklist_vec = blocklist_vec();
expected_blocklist_vec.sort_by(expected_cmp_fn);
let sort_option = blocklist_sorting_options()[2].clone();
let mut sorted_blocklist_vec = blocklist_vec();
sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_blocklist_vec, expected_blocklist_vec);
assert_str_eq!(sort_option.name, "Quality");
}
#[test]
fn test_blocklist_sorting_options_date() {
let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering =
|a, b| a.date.cmp(&b.date);
let mut expected_blocklist_vec = blocklist_vec();
expected_blocklist_vec.sort_by(expected_cmp_fn);
let sort_option = blocklist_sorting_options()[3].clone();
let mut sorted_blocklist_vec = blocklist_vec();
sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap());
assert_eq!(sorted_blocklist_vec, expected_blocklist_vec);
assert_str_eq!(sort_option.name, "Date");
}
#[test]
fn test_blocklist_handler_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if BLOCKLIST_BLOCKS.contains(&active_lidarr_block) {
assert!(BlocklistHandler::accepts(active_lidarr_block));
} else {
assert!(!BlocklistHandler::accepts(active_lidarr_block));
}
})
}
#[rstest]
fn test_blocklist_handler_ignore_special_keys(
#[values(true, false)] ignore_special_keys_for_textbox_input: bool,
) {
let mut app = App::test_default();
app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input;
let handler = BlocklistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::default(),
None,
);
assert_eq!(
handler.ignore_special_keys(),
ignore_special_keys_for_textbox_input
);
}
#[test]
fn test_extract_blocklist_item_id() {
let mut app = App::test_default();
app.data.lidarr_data.blocklist.set_items(blocklist_vec());
let blocklist_item_id = BlocklistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
)
.extract_blocklist_item_id();
assert_eq!(blocklist_item_id, 3);
}
#[test]
fn test_blocklist_handler_not_ready_when_loading() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.is_loading = true;
let handler = BlocklistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_blocklist_handler_not_ready_when_blocklist_is_empty() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.is_loading = false;
let handler = BlocklistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_blocklist_handler_ready_when_not_loading_and_blocklist_is_not_empty() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
app.is_loading = false;
app
.data
.lidarr_data
.blocklist
.set_items(vec![BlocklistItem::default()]);
let handler = BlocklistHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::Blocklist,
None,
);
assert!(handler.is_ready());
}
fn blocklist_vec() -> Vec<BlocklistItem> {
vec![
BlocklistItem {
id: 3,
source_title: "test 1".to_owned(),
quality: QualityWrapper {
quality: Quality {
name: "Lossless".to_owned(),
},
},
date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()),
artist: Artist {
artist_name: "test 3".into(),
..artist()
},
..BlocklistItem::default()
},
BlocklistItem {
id: 2,
source_title: "test 2".to_owned(),
quality: QualityWrapper {
quality: Quality {
name: "Lossy".to_owned(),
},
},
date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()),
artist: Artist {
artist_name: "test 2".into(),
..artist()
},
..BlocklistItem::default()
},
BlocklistItem {
id: 1,
source_title: "test 3".to_owned(),
quality: QualityWrapper {
quality: Quality {
name: "Lossless".to_owned(),
},
},
date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()),
artist: Artist {
artist_name: "".into(),
..artist()
},
..BlocklistItem::default()
},
]
}
}
@@ -0,0 +1,222 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::lidarr_handlers::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle};
use crate::matches_key;
use crate::models::Route;
use crate::models::lidarr_models::BlocklistItem;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS};
use crate::models::stateful_table::SortOption;
use crate::network::lidarr_network::LidarrEvent;
#[cfg(test)]
#[path = "blocklist_handler_tests.rs"]
mod blocklist_handler_tests;
pub(super) struct BlocklistHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
}
impl BlocklistHandler<'_, '_> {
fn extract_blocklist_item_id(&self) -> i64 {
self.app.data.lidarr_data.blocklist.current_selection().id
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for BlocklistHandler<'a, 'b> {
fn handle(&mut self) {
let blocklist_table_handling_config =
TableHandlingConfig::new(ActiveLidarrBlock::Blocklist.into())
.sorting_block(ActiveLidarrBlock::BlocklistSortPrompt.into())
.sort_options(blocklist_sorting_options());
if !handle_table(
self,
|app| &mut app.data.lidarr_data.blocklist,
blocklist_table_handling_config,
) {
self.handle_key_event();
}
}
fn accepts(active_block: ActiveLidarrBlock) -> bool {
BLOCKLIST_BLOCKS.contains(&active_block)
}
fn new(
key: Key,
app: &'a mut App<'b>,
active_block: ActiveLidarrBlock,
context: Option<ActiveLidarrBlock>,
) -> Self {
BlocklistHandler {
key,
app,
active_lidarr_block: active_block,
_context: context,
}
}
fn get_key(&self) -> Key {
self.key
}
fn ignore_special_keys(&self) -> bool {
self.app.ignore_special_keys_for_textbox_input
}
fn is_ready(&self) -> bool {
!self.app.is_loading && !self.app.data.lidarr_data.blocklist.is_empty()
}
fn handle_scroll_up(&mut self) {}
fn handle_scroll_down(&mut self) {}
fn handle_home(&mut self) {}
fn handle_end(&mut self) {}
fn handle_delete(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::Blocklist {
self
.app
.push_navigation_stack(ActiveLidarrBlock::DeleteBlocklistItemPrompt.into());
}
}
fn handle_left_right_action(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::Blocklist => handle_change_tab_left_right_keys(self.app, self.key),
ActiveLidarrBlock::DeleteBlocklistItemPrompt
| ActiveLidarrBlock::BlocklistClearAllItemsPrompt => handle_prompt_toggle(self.app, self.key),
_ => {}
}
}
fn handle_submit(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::DeleteBlocklistItemPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DeleteBlocklistItem(
self.extract_blocklist_item_id(),
));
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::BlocklistClearAllItemsPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::ClearBlocklist);
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::Blocklist => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::BlocklistItemDetails.into());
}
_ => (),
}
}
fn handle_esc(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::DeleteBlocklistItemPrompt
| ActiveLidarrBlock::BlocklistClearAllItemsPrompt => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.prompt_confirm = false;
}
ActiveLidarrBlock::BlocklistItemDetails | ActiveLidarrBlock::BlocklistSortPrompt => {
self.app.pop_navigation_stack();
}
_ => handle_clear_errors(self.app),
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
match self.active_lidarr_block {
ActiveLidarrBlock::Blocklist => match self.key {
_ if matches_key!(refresh, key) => {
self.app.should_refresh = true;
}
_ if matches_key!(clear, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::BlocklistClearAllItemsPrompt.into());
}
_ => (),
},
ActiveLidarrBlock::DeleteBlocklistItemPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DeleteBlocklistItem(
self.extract_blocklist_item_id(),
));
self.app.pop_navigation_stack();
}
}
ActiveLidarrBlock::BlocklistClearAllItemsPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::ClearBlocklist);
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
fn app_mut(&mut self) -> &mut App<'b> {
self.app
}
fn current_route(&self) -> Route {
self.app.get_current_route()
}
}
fn blocklist_sorting_options() -> Vec<SortOption<BlocklistItem>> {
vec![
SortOption {
name: "Artist Name",
cmp_fn: Some(|a, b| {
a.artist
.artist_name
.text
.to_lowercase()
.cmp(&b.artist.artist_name.text.to_lowercase())
}),
},
SortOption {
name: "Source Title",
cmp_fn: Some(|a, b| {
a.source_title
.to_lowercase()
.cmp(&b.source_title.to_lowercase())
}),
},
SortOption {
name: "Quality",
cmp_fn: Some(|a, b| {
a.quality
.quality
.name
.to_lowercase()
.cmp(&b.quality.quality.name.to_lowercase())
}),
},
SortOption {
name: "Date",
cmp_fn: Some(|a, b| a.date.cmp(&b.date)),
},
]
}
@@ -99,9 +99,9 @@ mod tests {
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
ActiveLidarrBlock::History.into()
ActiveLidarrBlock::Blocklist.into()
);
assert_navigation_pushed!(app, ActiveLidarrBlock::History.into());
assert_navigation_pushed!(app, ActiveLidarrBlock::Blocklist.into());
}
#[rstest]
@@ -29,7 +29,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(2);
app.data.lidarr_data.main_tabs.set_index(3);
HistoryHandler::new(
DEFAULT_KEYBINDINGS.left.key,
@@ -41,9 +41,9 @@ mod tests {
assert_eq!(
app.data.lidarr_data.main_tabs.get_active_route(),
ActiveLidarrBlock::Downloads.into()
ActiveLidarrBlock::Blocklist.into()
);
assert_navigation_pushed!(app, ActiveLidarrBlock::Downloads.into());
assert_navigation_pushed!(app, ActiveLidarrBlock::Blocklist.into());
}
#[rstest]
@@ -51,7 +51,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::History.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(2);
app.data.lidarr_data.main_tabs.set_index(3);
HistoryHandler::new(
DEFAULT_KEYBINDINGS.right.key,
@@ -67,7 +67,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Indexers.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(4);
app.data.lidarr_data.main_tabs.set_index(5);
IndexersHandler::new(
DEFAULT_KEYBINDINGS.left.key,
@@ -89,7 +89,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Indexers.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(4);
app.data.lidarr_data.main_tabs.set_index(5);
IndexersHandler::new(
DEFAULT_KEYBINDINGS.right.key,
@@ -53,11 +53,12 @@ mod tests {
#[rstest]
#[case(0, ActiveLidarrBlock::System, ActiveLidarrBlock::Downloads)]
#[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::History)]
#[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::RootFolders)]
#[case(3, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)]
#[case(4, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)]
#[case(5, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)]
#[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Blocklist)]
#[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::History)]
#[case(3, ActiveLidarrBlock::Blocklist, ActiveLidarrBlock::RootFolders)]
#[case(4, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)]
#[case(5, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)]
#[case(6, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)]
fn test_lidarr_handler_change_tab_left_right_keys(
#[case] index: usize,
#[case] left_block: ActiveLidarrBlock,
@@ -87,11 +88,12 @@ mod tests {
#[rstest]
#[case(0, ActiveLidarrBlock::System, ActiveLidarrBlock::Downloads)]
#[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::History)]
#[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::RootFolders)]
#[case(3, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)]
#[case(4, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)]
#[case(5, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)]
#[case(1, ActiveLidarrBlock::Artists, ActiveLidarrBlock::Blocklist)]
#[case(2, ActiveLidarrBlock::Downloads, ActiveLidarrBlock::History)]
#[case(3, ActiveLidarrBlock::Blocklist, ActiveLidarrBlock::RootFolders)]
#[case(4, ActiveLidarrBlock::History, ActiveLidarrBlock::Indexers)]
#[case(5, ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::System)]
#[case(6, ActiveLidarrBlock::Indexers, ActiveLidarrBlock::Artists)]
fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation(
#[case] index: usize,
#[case] left_block: ActiveLidarrBlock,
@@ -122,10 +124,11 @@ mod tests {
#[rstest]
#[case(0, ActiveLidarrBlock::Artists)]
#[case(1, ActiveLidarrBlock::Downloads)]
#[case(2, ActiveLidarrBlock::History)]
#[case(3, ActiveLidarrBlock::RootFolders)]
#[case(4, ActiveLidarrBlock::Indexers)]
#[case(5, ActiveLidarrBlock::System)]
#[case(2, ActiveLidarrBlock::Blocklist)]
#[case(3, ActiveLidarrBlock::History)]
#[case(4, ActiveLidarrBlock::RootFolders)]
#[case(5, ActiveLidarrBlock::Indexers)]
#[case(6, ActiveLidarrBlock::System)]
fn test_lidarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key(
#[case] index: usize,
#[case] block: ActiveLidarrBlock,
@@ -197,6 +200,24 @@ mod tests {
);
}
#[rstest]
fn test_delegates_blocklist_blocks_to_blocklist_handler(
#[values(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::BlocklistItemDetails,
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt,
ActiveLidarrBlock::BlocklistSortPrompt
)]
active_lidarr_block: ActiveLidarrBlock,
) {
test_handler_delegation!(
LidarrHandler,
ActiveLidarrBlock::Blocklist,
active_lidarr_block
);
}
#[rstest]
fn test_delegates_history_blocks_to_history_handler(
#[values(
+5
View File
@@ -3,6 +3,7 @@ use indexers::IndexersHandler;
use library::LibraryHandler;
use super::KeyEventHandler;
use crate::handlers::lidarr_handlers::blocklist::BlocklistHandler;
use crate::handlers::lidarr_handlers::downloads::DownloadsHandler;
use crate::handlers::lidarr_handlers::root_folders::RootFoldersHandler;
use crate::handlers::lidarr_handlers::system::SystemHandler;
@@ -11,6 +12,7 @@ use crate::{
app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
};
mod blocklist;
mod downloads;
mod history;
mod indexers;
@@ -38,6 +40,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b
_ if DownloadsHandler::accepts(self.active_lidarr_block) => {
DownloadsHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
_ if BlocklistHandler::accepts(self.active_lidarr_block) => {
BlocklistHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
_ if HistoryHandler::accepts(self.active_lidarr_block) => {
HistoryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle();
}
@@ -71,7 +71,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::RootFolders.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(3);
app.data.lidarr_data.main_tabs.set_index(4);
RootFoldersHandler::new(
DEFAULT_KEYBINDINGS.left.key,
@@ -93,7 +93,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::RootFolders.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(3);
app.data.lidarr_data.main_tabs.set_index(4);
RootFoldersHandler::new(
DEFAULT_KEYBINDINGS.right.key,
@@ -27,7 +27,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::System.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(5);
app.data.lidarr_data.main_tabs.set_index(6);
SystemHandler::new(
DEFAULT_KEYBINDINGS.left.key,
@@ -49,7 +49,7 @@ mod tests {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::System.into());
app.is_loading = is_ready;
app.data.lidarr_data.main_tabs.set_index(5);
app.data.lidarr_data.main_tabs.set_index(6);
SystemHandler::new(
DEFAULT_KEYBINDINGS.right.key,
@@ -279,8 +279,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler
}
ActiveSonarrBlock::AutomaticallySearchSeasonPrompt => {
if self.app.data.sonarr_data.prompt_confirm {
let (series_id, season_number) = self.extract_series_id_season_number_tuple();
self.app.data.sonarr_data.prompt_confirm_action = Some(
SonarrEvent::TriggerAutomaticSeasonSearch(self.extract_series_id_season_number_tuple()),
SonarrEvent::TriggerAutomaticSeasonSearch(series_id, season_number),
);
}
@@ -404,8 +405,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler
},
ActiveSonarrBlock::AutomaticallySearchSeasonPrompt if matches_key!(confirm, key) => {
self.app.data.sonarr_data.prompt_confirm = true;
let (series_id, season_number) = self.extract_series_id_season_number_tuple();
self.app.data.sonarr_data.prompt_confirm_action = Some(
SonarrEvent::TriggerAutomaticSeasonSearch(self.extract_series_id_season_number_tuple()),
SonarrEvent::TriggerAutomaticSeasonSearch(series_id, season_number),
);
self.app.pop_navigation_stack();
@@ -268,7 +268,7 @@ mod tests {
#[rstest]
#[case(
ActiveSonarrBlock::AutomaticallySearchSeasonPrompt,
SonarrEvent::TriggerAutomaticSeasonSearch((0, 0))
SonarrEvent::TriggerAutomaticSeasonSearch(0, 0)
)]
#[case(
ActiveSonarrBlock::DeleteEpisodeFilePrompt,
@@ -694,7 +694,7 @@ mod tests {
#[rstest]
#[case(
ActiveSonarrBlock::AutomaticallySearchSeasonPrompt,
SonarrEvent::TriggerAutomaticSeasonSearch((0, 0))
SonarrEvent::TriggerAutomaticSeasonSearch(0, 0)
)]
#[case(
ActiveSonarrBlock::DeleteEpisodeFilePrompt,
@@ -278,8 +278,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler
}
_ if matches_key!(toggle_monitoring, key) => {
self.app.data.sonarr_data.prompt_confirm = true;
let (series_id, season_number) = self.extract_series_id_season_number_tuple();
self.app.data.sonarr_data.prompt_confirm_action = Some(
SonarrEvent::ToggleSeasonMonitoring(self.extract_series_id_season_number_tuple()),
SonarrEvent::ToggleSeasonMonitoring(series_id, season_number),
);
self
@@ -378,7 +378,7 @@ mod tests {
assert!(app.is_routing);
assert_some_eq_x!(
&app.data.sonarr_data.prompt_confirm_action,
&SonarrEvent::ToggleSeasonMonitoring((0, 0))
&SonarrEvent::ToggleSeasonMonitoring(0, 0)
);
}
+23
View File
@@ -499,6 +499,28 @@ pub struct LidarrReleaseDownloadBody {
pub indexer_id: i64,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BlocklistItem {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub artist_id: i64,
pub album_ids: Option<Vec<Number>>,
pub source_title: String,
pub quality: QualityWrapper,
pub date: DateTime<Utc>,
pub protocol: String,
pub indexer: String,
pub message: String,
pub artist: Artist,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct BlocklistResponse {
pub records: Vec<BlocklistItem>,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TrackFile {
@@ -574,6 +596,7 @@ serde_enum_from!(
Album(Album),
Artist(Artist),
Artists(Vec<Artist>),
BlocklistResponse(BlocklistResponse),
DiskSpaces(Vec<DiskSpace>),
DownloadsResponse(DownloadsResponse),
LidarrHistoryWrapper(LidarrHistoryWrapper),
+21 -4
View File
@@ -5,10 +5,10 @@ mod tests {
use serde_json::json;
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse,
LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask,
MediaInfo, Member, MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus, Track,
TrackFile,
AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord,
DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MetadataProfile,
MonitorType, NewItemMonitorType, SystemStatus, Track, TrackFile,
};
use crate::models::servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse,
@@ -276,6 +276,23 @@ mod tests {
assert_eq!(lidarr_serdeable, LidarrSerdeable::Artist(artist));
}
#[test]
fn test_lidarr_serdeable_from_blocklist_response() {
let blocklist_response = BlocklistResponse {
records: vec![BlocklistItem {
id: 1,
..BlocklistItem::default()
}],
};
let lidarr_serdeable: LidarrSerdeable = blocklist_response.clone().into();
assert_eq!(
lidarr_serdeable,
LidarrSerdeable::BlocklistResponse(blocklist_response)
);
}
#[test]
fn test_lidarr_serdeable_from_disk_spaces() {
let disk_spaces = vec![DiskSpace {
+30 -4
View File
@@ -2,14 +2,14 @@ use serde_json::Number;
use super::modals::{AddArtistModal, AddRootFolderModal, AlbumDetailsModal, EditArtistModal};
use crate::app::context_clues::{
DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
};
use crate::app::lidarr::lidarr_context_clues::{
ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
MANUAL_ARTIST_SEARCH_CONTEXT_CLUES,
};
use crate::models::lidarr_models::{LidarrRelease, LidarrTask};
use crate::models::lidarr_models::{BlocklistItem, LidarrRelease, LidarrTask};
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_models::{IndexerSettings, QueueEvent};
use crate::models::stateful_list::StatefulList;
@@ -30,6 +30,7 @@ use {
super::modals::TrackDetailsModal,
crate::models::lidarr_models::{MonitorType, NewItemMonitorType},
crate::models::stateful_table::SortOption,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::blocklist_item,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::indexer_settings,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
@@ -64,6 +65,7 @@ pub struct LidarrData<'a> {
pub artist_history: StatefulTable<LidarrHistoryItem>,
pub artist_info_tabs: TabState,
pub artists: StatefulTable<Artist>,
pub blocklist: StatefulTable<BlocklistItem>,
pub delete_files: bool,
pub discography_releases: StatefulTable<LidarrRelease>,
pub disk_space_vec: Vec<DiskSpace>,
@@ -149,6 +151,7 @@ impl<'a> Default for LidarrData<'a> {
album_details_modal: None,
artist_history: StatefulTable::default(),
artists: StatefulTable::default(),
blocklist: StatefulTable::default(),
delete_files: false,
discography_releases: StatefulTable::default(),
disk_space_vec: Vec::new(),
@@ -187,6 +190,12 @@ impl<'a> Default for LidarrData<'a> {
contextual_help: Some(&DOWNLOADS_CONTEXT_CLUES),
config: None,
},
TabRoute {
title: "Blocklist".to_string(),
route: ActiveLidarrBlock::Blocklist.into(),
contextual_help: Some(&BLOCKLIST_CONTEXT_CLUES),
config: None,
},
TabRoute {
title: "History".to_string(),
route: ActiveLidarrBlock::History.into(),
@@ -293,8 +302,10 @@ impl LidarrData<'_> {
.metadata_profile_list
.set_items(vec![metadata_profile().name]);
let mut track_details_modal = TrackDetailsModal::default();
track_details_modal.track_details = ScrollableText::with_string("Some details".to_owned());
let mut track_details_modal = TrackDetailsModal {
track_details: ScrollableText::with_string("Some details".to_owned()),
..TrackDetailsModal::default()
};
track_details_modal
.track_history
.set_items(vec![lidarr_history_item()]);
@@ -377,6 +388,8 @@ impl LidarrData<'_> {
}]);
lidarr_data.artists.search = Some("artist search".into());
lidarr_data.artists.filter = Some("artist filter".into());
lidarr_data.blocklist.set_items(vec![blocklist_item()]);
lidarr_data.blocklist.sorting(vec![sort_option!(id)]);
lidarr_data.downloads.set_items(vec![download_record()]);
lidarr_data.history.set_items(vec![lidarr_history_item()]);
lidarr_data.history.sorting(vec![SortOption {
@@ -444,6 +457,11 @@ pub enum ActiveLidarrBlock {
AllIndexerSettingsPrompt,
AutomaticallySearchAlbumPrompt,
AutomaticallySearchArtistPrompt,
Blocklist,
BlocklistItemDetails,
DeleteBlocklistItemPrompt,
BlocklistClearAllItemsPrompt,
BlocklistSortPrompt,
DeleteAlbumPrompt,
DeleteAlbumConfirmPrompt,
DeleteAlbumToggleDeleteFile,
@@ -579,6 +597,14 @@ pub static ALBUM_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [
ActiveLidarrBlock::DeleteTrackFilePrompt,
];
pub static BLOCKLIST_BLOCKS: [ActiveLidarrBlock; 5] = [
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::BlocklistItemDetails,
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt,
ActiveLidarrBlock::BlocklistSortPrompt,
];
pub static DOWNLOADS_BLOCKS: [ActiveLidarrBlock; 3] = [
ActiveLidarrBlock::Downloads,
ActiveLidarrBlock::DeleteDownloadPrompt,
@@ -1,8 +1,8 @@
#[cfg(test)]
mod tests {
use crate::app::context_clues::{
DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
};
use crate::app::lidarr::lidarr_context_clues::{
ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
@@ -11,7 +11,7 @@ mod tests {
use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease};
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS,
ARTIST_DETAILS_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS,
ARTIST_DETAILS_BLOCKS, BLOCKLIST_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS,
DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS,
EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS,
EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS,
@@ -149,6 +149,7 @@ mod tests {
assert_none!(lidarr_data.album_details_modal);
assert_is_empty!(lidarr_data.artists);
assert_is_empty!(lidarr_data.artist_history);
assert_is_empty!(lidarr_data.blocklist);
assert!(!lidarr_data.delete_files);
assert_is_empty!(lidarr_data.disk_space_vec);
assert_is_empty!(lidarr_data.downloads);
@@ -171,7 +172,7 @@ mod tests {
assert_is_empty!(lidarr_data.updates);
assert_is_empty!(lidarr_data.version);
assert_eq!(lidarr_data.main_tabs.tabs.len(), 6);
assert_eq!(lidarr_data.main_tabs.tabs.len(), 7);
assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library");
assert_eq!(
@@ -195,50 +196,61 @@ mod tests {
);
assert_none!(lidarr_data.main_tabs.tabs[1].config);
assert_str_eq!(lidarr_data.main_tabs.tabs[2].title, "History");
assert_str_eq!(lidarr_data.main_tabs.tabs[2].title, "Blocklist");
assert_eq!(
lidarr_data.main_tabs.tabs[2].route,
ActiveLidarrBlock::History.into()
ActiveLidarrBlock::Blocklist.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[2].contextual_help,
&HISTORY_CONTEXT_CLUES
&BLOCKLIST_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[2].config);
assert_str_eq!(lidarr_data.main_tabs.tabs[3].title, "Root Folders");
assert_str_eq!(lidarr_data.main_tabs.tabs[3].title, "History");
assert_eq!(
lidarr_data.main_tabs.tabs[3].route,
ActiveLidarrBlock::RootFolders.into()
ActiveLidarrBlock::History.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[3].contextual_help,
&ROOT_FOLDERS_CONTEXT_CLUES
&HISTORY_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[3].config);
assert_str_eq!(lidarr_data.main_tabs.tabs[4].title, "Indexers");
assert_str_eq!(lidarr_data.main_tabs.tabs[4].title, "Root Folders");
assert_eq!(
lidarr_data.main_tabs.tabs[4].route,
ActiveLidarrBlock::Indexers.into()
ActiveLidarrBlock::RootFolders.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[4].contextual_help,
&INDEXERS_CONTEXT_CLUES
&ROOT_FOLDERS_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[4].config);
assert_str_eq!(lidarr_data.main_tabs.tabs[5].title, "System");
assert_str_eq!(lidarr_data.main_tabs.tabs[5].title, "Indexers");
assert_eq!(
lidarr_data.main_tabs.tabs[5].route,
ActiveLidarrBlock::System.into()
ActiveLidarrBlock::Indexers.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[5].contextual_help,
&SYSTEM_CONTEXT_CLUES
&INDEXERS_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[5].config);
assert_str_eq!(lidarr_data.main_tabs.tabs[6].title, "System");
assert_eq!(
lidarr_data.main_tabs.tabs[6].route,
ActiveLidarrBlock::System.into()
);
assert_some_eq_x!(
&lidarr_data.main_tabs.tabs[6].contextual_help,
&SYSTEM_CONTEXT_CLUES
);
assert_none!(lidarr_data.main_tabs.tabs[6].config);
assert_eq!(lidarr_data.artist_info_tabs.tabs.len(), 3);
assert_str_eq!(lidarr_data.artist_info_tabs.tabs[0].title, "Albums");
assert_eq!(
@@ -326,6 +338,16 @@ mod tests {
assert!(ALBUM_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::DeleteTrackFilePrompt));
}
#[test]
fn test_blocklist_blocks_contents() {
assert_eq!(BLOCKLIST_BLOCKS.len(), 5);
assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::Blocklist));
assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::BlocklistItemDetails));
assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteBlocklistItemPrompt));
assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::BlocklistClearAllItemsPrompt));
assert!(BLOCKLIST_BLOCKS.contains(&ActiveLidarrBlock::BlocklistSortPrompt));
}
#[test]
fn test_downloads_blocks_contains_expected_blocks() {
assert_eq!(DOWNLOADS_BLOCKS.len(), 3);
@@ -0,0 +1,353 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{Artist, BlocklistItem, BlocklistResponse, LidarrSerdeable};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::SortOption;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
artist, blocklist_item,
};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use pretty_assertions::assert_eq;
use rstest::rstest;
use serde_json::{Number, json};
#[tokio::test]
async fn test_handle_clear_lidarr_blocklist_event() {
let blocklist_items = vec![
BlocklistItem {
id: 1,
..blocklist_item()
},
BlocklistItem {
id: 2,
..blocklist_item()
},
BlocklistItem {
id: 3,
..blocklist_item()
},
];
let expected_request_json = json!({ "ids": [1, 2, 3]});
let (mock, app, _server) = MockServarrApi::delete()
.with_request_body(expected_request_json)
.build_for(LidarrEvent::ClearBlocklist)
.await;
app
.lock()
.await
.data
.lidarr_data
.blocklist
.set_items(blocklist_items);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::ClearBlocklist)
.await
.is_ok()
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_delete_lidarr_blocklist_item_event() {
let (mock, app, _server) = MockServarrApi::delete()
.path("/1")
.build_for(LidarrEvent::DeleteBlocklistItem(1))
.await;
app
.lock()
.await
.data
.lidarr_data
.blocklist
.set_items(vec![blocklist_item()]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::DeleteBlocklistItem(1))
.await
.is_ok()
);
mock.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_handle_get_lidarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) {
let blocklist_json = json!({"records": [{
"artistId": 1007,
"albumIds": [42020],
"sourceTitle": "z artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 123,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
}
},
{
"artistId": 2001,
"artistTitle": "Test Artist",
"albumIds": [42018],
"sourceTitle": "A Artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 456,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
}
}]});
let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap();
let mut expected_blocklist = vec![
BlocklistItem {
id: 123,
artist_id: 1007,
source_title: "z artist".into(),
album_ids: Some(vec![Number::from(42020)]),
..blocklist_item()
},
BlocklistItem {
id: 456,
artist_id: 2001,
source_title: "A Artist".into(),
album_ids: Some(vec![Number::from(42018)]),
..blocklist_item()
},
];
let (mock, app, _server) = MockServarrApi::get()
.returns(blocklist_json)
.build_for(LidarrEvent::GetBlocklist)
.await;
app
.lock()
.await
.data
.lidarr_data
.artists
.set_items(vec![Artist {
id: 1007,
artist_name: "Z Artist".into(),
..artist()
}]);
app.lock().await.data.lidarr_data.blocklist.sort_asc = true;
if use_custom_sorting {
let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| {
a.source_title
.to_lowercase()
.cmp(&b.source_title.to_lowercase())
};
expected_blocklist.sort_by(cmp_fn);
let blocklist_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
app
.lock()
.await
.data
.lidarr_data
.blocklist
.sorting(vec![blocklist_sort_option]);
}
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::BlocklistResponse(blocklist) = network
.handle_lidarr_event(LidarrEvent::GetBlocklist)
.await
.unwrap()
else {
panic!("Expected BlocklistResponse")
};
mock.assert_async().await;
assert_eq!(
app.lock().await.data.lidarr_data.blocklist.items,
expected_blocklist
);
assert!(app.lock().await.data.lidarr_data.blocklist.sort_asc);
assert_eq!(blocklist, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_blocklist_event_no_op_when_user_is_selecting_sort_options() {
let blocklist_json = json!({"records": [{
"artistId": 1007,
"albumIds": [42020],
"sourceTitle": "z artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 123,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
}
},
{
"artistId": 2001,
"albumIds": [42018],
"sourceTitle": "A Artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 456,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
}
}]});
let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(blocklist_json)
.build_for(LidarrEvent::GetBlocklist)
.await;
app.lock().await.data.lidarr_data.blocklist.sort_asc = true;
app
.lock()
.await
.push_navigation_stack(ActiveLidarrBlock::BlocklistSortPrompt.into());
let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| {
a.source_title
.to_lowercase()
.cmp(&b.source_title.to_lowercase())
};
let blocklist_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
app
.lock()
.await
.data
.lidarr_data
.blocklist
.sorting(vec![blocklist_sort_option]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::BlocklistResponse(blocklist) = network
.handle_lidarr_event(LidarrEvent::GetBlocklist)
.await
.unwrap()
else {
panic!("Expected BlocklistResponse")
};
mock.assert_async().await;
assert_is_empty!(app.lock().await.data.lidarr_data.blocklist);
assert!(app.lock().await.data.lidarr_data.blocklist.sort_asc);
assert_eq!(blocklist, response);
}
}
@@ -0,0 +1,92 @@
use crate::models::Route;
use crate::models::lidarr_models::{BlocklistItem, BlocklistResponse};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use anyhow::Result;
use log::info;
use serde_json::{Value, json};
#[cfg(test)]
#[path = "lidarr_blocklist_network_tests.rs"]
mod lidarr_blocklist_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn clear_lidarr_blocklist(&mut self) -> Result<()> {
info!("Clearing Lidarr blocklist");
let event = LidarrEvent::ClearBlocklist;
let ids = self
.app
.lock()
.await
.data
.lidarr_data
.blocklist
.items
.iter()
.map(|item| item.id)
.collect::<Vec<i64>>();
let request_props = self
.request_props_from(
event,
RequestMethod::Delete,
Some(json!({"ids": ids})),
None,
None,
)
.await;
self
.handle_request::<Value, ()>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn delete_lidarr_blocklist_item(
&mut self,
blocklist_item_id: i64,
) -> Result<()> {
let event = LidarrEvent::DeleteBlocklistItem(blocklist_item_id);
info!("Deleting Lidarr blocklist item for item with id: {blocklist_item_id}");
let request_props = self
.request_props_from(
event,
RequestMethod::Delete,
None::<()>,
Some(format!("/{blocklist_item_id}")),
None,
)
.await;
self
.handle_request::<(), ()>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_blocklist(
&mut self,
) -> Result<BlocklistResponse> {
info!("Fetching Lidarr blocklist");
let event = LidarrEvent::GetBlocklist;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| {
if !matches!(
app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::BlocklistSortPrompt, _)
) {
let mut blocklist_vec: Vec<BlocklistItem> = blocklist_resp.records;
blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id));
app.data.lidarr_data.blocklist.set_items(blocklist_vec);
app.data.lidarr_data.blocklist.apply_sorting_toggle(false);
}
})
.await
}
}
@@ -3,10 +3,10 @@
pub mod test_utils {
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus,
AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams,
LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper,
LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MetadataProfile,
NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile,
AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse,
EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member,
MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile,
};
use crate::models::servarr_models::IndexerSettings;
use crate::models::servarr_models::{
@@ -477,4 +477,25 @@ pub mod test_utils {
track_file: Some(track_file()),
}
}
pub fn blocklist_item() -> BlocklistItem {
BlocklistItem {
id: 1,
artist_id: 1,
album_ids: Some(vec![1.into()]),
source_title: "Alex - Something".to_string(),
quality: quality_wrapper(),
date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()),
protocol: "usenet".to_string(),
indexer: "NZBgeek (Prowlarr)".to_string(),
message: "test message".to_string(),
artist: artist(),
}
}
pub fn blocklist_response() -> BlocklistResponse {
BlocklistResponse {
records: vec![blocklist_item()],
}
}
}
@@ -159,6 +159,9 @@ mod tests {
}
#[rstest]
#[case(LidarrEvent::ClearBlocklist, "/blocklist/bulk")]
#[case(LidarrEvent::DeleteBlocklistItem(0), "/blocklist")]
#[case(LidarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")]
#[case(LidarrEvent::GetDiskSpace, "/diskspace")]
#[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")]
#[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")]
+16
View File
@@ -9,6 +9,7 @@ use crate::models::lidarr_models::{
use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag};
use crate::network::{Network, RequestMethod};
mod blocklist;
mod downloads;
mod history;
mod indexers;
@@ -29,8 +30,10 @@ pub enum LidarrEvent {
AddArtist(AddArtistBody),
AddRootFolder(AddLidarrRootFolderBody),
AddTag(String),
ClearBlocklist,
DeleteAlbum(DeleteParams),
DeleteArtist(DeleteParams),
DeleteBlocklistItem(i64),
DeleteDownload(i64),
DeleteIndexer(i64),
DeleteRootFolder(i64),
@@ -47,6 +50,7 @@ pub enum LidarrEvent {
GetArtistHistory(i64),
GetAllIndexerSettings,
GetArtistDetails(i64),
GetBlocklist,
GetDiscographyReleases(i64),
GetDiskSpace,
GetDownloads(u64),
@@ -87,7 +91,9 @@ impl NetworkResource for LidarrEvent {
fn resource(&self) -> &'static str {
match &self {
LidarrEvent::AddTag(_) | LidarrEvent::DeleteTag(_) | LidarrEvent::GetTags => "/tag",
LidarrEvent::ClearBlocklist => "/blocklist/bulk",
LidarrEvent::DeleteTrackFile(_) | LidarrEvent::GetTrackFiles(_) => "/trackfile",
LidarrEvent::DeleteBlocklistItem(_) => "/blocklist",
LidarrEvent::GetAllIndexerSettings | LidarrEvent::EditAllIndexerSettings(_) => {
"/config/indexer"
}
@@ -104,6 +110,7 @@ impl NetworkResource for LidarrEvent {
LidarrEvent::GetArtistHistory(_)
| LidarrEvent::GetAlbumHistory(_, _)
| LidarrEvent::GetTrackHistory(_, _, _) => "/history/artist",
LidarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000",
LidarrEvent::GetLogs(_) => "/log",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) | LidarrEvent::DeleteDownload(_) => "/queue",
@@ -157,12 +164,20 @@ impl Network<'_, '_> {
.add_lidarr_root_folder(path)
.await
.map(LidarrSerdeable::from),
LidarrEvent::ClearBlocklist => self
.clear_lidarr_blocklist()
.await
.map(LidarrSerdeable::from),
LidarrEvent::DeleteAlbum(params) => {
self.delete_album(params).await.map(LidarrSerdeable::from)
}
LidarrEvent::DeleteArtist(params) => {
self.delete_artist(params).await.map(LidarrSerdeable::from)
}
LidarrEvent::DeleteBlocklistItem(blocklist_item_id) => self
.delete_lidarr_blocklist_item(blocklist_item_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::DeleteDownload(download_id) => self
.delete_lidarr_download(download_id)
.await
@@ -218,6 +233,7 @@ impl Network<'_, '_> {
.get_album_releases(artist_id, album_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetBlocklist => self.get_lidarr_blocklist().await.map(LidarrSerdeable::from),
LidarrEvent::GetDiscographyReleases(artist_id) => self
.get_artist_discography_releases(artist_id)
.await
@@ -14,10 +14,10 @@ mod sonarr_seasons_network_tests;
impl Network<'_, '_> {
pub(in crate::network::sonarr_network) async fn toggle_sonarr_season_monitoring(
&mut self,
series_id_season_number_tuple: (i64, i64),
series_id: i64,
season_number: i64,
) -> Result<()> {
let event = SonarrEvent::ToggleSeasonMonitoring(series_id_season_number_tuple);
let (series_id, season_number) = series_id_season_number_tuple;
let event = SonarrEvent::ToggleSeasonMonitoring(series_id, season_number);
let detail_event = SonarrEvent::GetSeriesDetails(series_id);
info!("Toggling season monitoring for season {season_number} in series with ID: {series_id}");
@@ -94,10 +94,10 @@ impl Network<'_, '_> {
pub(in crate::network::sonarr_network) async fn get_season_releases(
&mut self,
series_season_id_tuple: (i64, i64),
series_id: i64,
season_number: i64,
) -> Result<Vec<SonarrRelease>> {
let event = SonarrEvent::GetSeasonReleases(series_season_id_tuple);
let (series_id, season_number) = series_season_id_tuple;
let event = SonarrEvent::GetSeasonReleases(series_id, season_number);
info!("Fetching releases for series with ID: {series_id} and season number: {season_number}");
let request_props = self
@@ -132,10 +132,10 @@ impl Network<'_, '_> {
pub(in crate::network::sonarr_network) async fn get_sonarr_season_history(
&mut self,
series_season_id_tuple: (i64, i64),
series_id: i64,
season_number: i64,
) -> Result<Vec<SonarrHistoryItem>> {
let event = SonarrEvent::GetSeasonHistory(series_season_id_tuple);
let (series_id, season_number) = series_season_id_tuple;
let event = SonarrEvent::GetSeasonHistory(series_id, season_number);
info!("Fetching history for series with ID: {series_id} and season number: {season_number}");
let params = format!("seriesId={series_id}&seasonNumber={season_number}",);
@@ -170,10 +170,10 @@ impl Network<'_, '_> {
pub(in crate::network::sonarr_network) async fn trigger_automatic_season_search(
&mut self,
series_season_id_tuple: (i64, i64),
series_id: i64,
season_number: i64,
) -> Result<Value> {
let event = SonarrEvent::TriggerAutomaticSeasonSearch(series_season_id_tuple);
let (series_id, season_number) = series_season_id_tuple;
let event = SonarrEvent::TriggerAutomaticSeasonSearch(series_id, season_number);
info!("Searching indexers for series with ID: {series_id} and season number: {season_number}");
let body = SonarrCommandBody {
@@ -37,7 +37,7 @@ mod tests {
"PUT",
format!(
"/api/v3{}/1",
SonarrEvent::ToggleSeasonMonitoring((1, 1)).resource()
SonarrEvent::ToggleSeasonMonitoring(1, 1).resource()
)
.as_str(),
)
@@ -56,7 +56,7 @@ mod tests {
assert!(
network
.handle_sonarr_event(SonarrEvent::ToggleSeasonMonitoring((1, 1)))
.handle_sonarr_event(SonarrEvent::ToggleSeasonMonitoring(1, 1))
.await
.is_ok()
);
@@ -117,7 +117,7 @@ mod tests {
let (mock, app, _server) = MockServarrApi::get()
.returns(release_json)
.query("seriesId=1&seasonNumber=1")
.build_for(SonarrEvent::GetSeasonReleases((1, 1)))
.build_for(SonarrEvent::GetSeasonReleases(1, 1))
.await;
app
.lock()
@@ -138,7 +138,7 @@ mod tests {
let mut network = test_network(&app);
let SonarrSerdeable::Releases(releases_vec) = network
.handle_sonarr_event(SonarrEvent::GetSeasonReleases((1, 1)))
.handle_sonarr_event(SonarrEvent::GetSeasonReleases(1, 1))
.await
.unwrap()
else {
@@ -203,7 +203,7 @@ mod tests {
let (mock, app, _server) = MockServarrApi::get()
.returns(release_json)
.query("seriesId=1&seasonNumber=1")
.build_for(SonarrEvent::GetSeasonReleases((1, 1)))
.build_for(SonarrEvent::GetSeasonReleases(1, 1))
.await;
app
.lock()
@@ -224,7 +224,7 @@ mod tests {
assert!(
network
.handle_sonarr_event(SonarrEvent::GetSeasonReleases((1, 1)))
.handle_sonarr_event(SonarrEvent::GetSeasonReleases(1, 1))
.await
.is_ok()
);
@@ -291,7 +291,7 @@ mod tests {
let (mock, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("seriesId=1&seasonNumber=1")
.build_for(SonarrEvent::GetSeasonHistory((1, 1)))
.build_for(SonarrEvent::GetSeasonHistory(1, 1))
.await;
app.lock().await.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default());
app
@@ -322,7 +322,7 @@ mod tests {
let mut network = test_network(&app);
let SonarrSerdeable::SonarrHistoryItems(history) = network
.handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1)))
.handle_sonarr_event(SonarrEvent::GetSeasonHistory(1, 1))
.await
.unwrap()
else {
@@ -403,7 +403,7 @@ mod tests {
let (mock, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("seriesId=1&seasonNumber=1")
.build_for(SonarrEvent::GetSeasonHistory((1, 1)))
.build_for(SonarrEvent::GetSeasonHistory(1, 1))
.await;
app
.lock()
@@ -423,7 +423,7 @@ mod tests {
let mut network = test_network(&app);
let SonarrSerdeable::SonarrHistoryItems(history) = network
.handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1)))
.handle_sonarr_event(SonarrEvent::GetSeasonHistory(1, 1))
.await
.unwrap()
else {
@@ -499,7 +499,7 @@ mod tests {
let (mock, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("seriesId=1&seasonNumber=1")
.build_for(SonarrEvent::GetSeasonHistory((1, 1)))
.build_for(SonarrEvent::GetSeasonHistory(1, 1))
.await;
app.lock().await.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default());
app
@@ -520,7 +520,7 @@ mod tests {
let mut network = test_network(&app);
let SonarrSerdeable::SonarrHistoryItems(history) = network
.handle_sonarr_event(SonarrEvent::GetSeasonHistory((1, 1)))
.handle_sonarr_event(SonarrEvent::GetSeasonHistory(1, 1))
.await
.unwrap()
else {
@@ -563,14 +563,14 @@ mod tests {
"seasonNumber": 1
}))
.returns(json!({}))
.build_for(SonarrEvent::TriggerAutomaticSeasonSearch((1, 1)))
.build_for(SonarrEvent::TriggerAutomaticSeasonSearch(1, 1))
.await;
app.lock().await.server_tabs.next();
let mut network = test_network(&app);
assert!(
network
.handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch((1, 1)))
.handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(1, 1))
.await
.is_ok()
);
+16 -16
View File
@@ -65,8 +65,8 @@ pub enum SonarrEvent {
GetQueuedEvents,
GetRootFolders,
GetEpisodeReleases(i64),
GetSeasonHistory((i64, i64)),
GetSeasonReleases((i64, i64)),
GetSeasonHistory(i64, i64),
GetSeasonReleases(i64, i64),
GetSecurityConfig,
GetSeriesDetails(i64),
GetSeriesHistory(i64),
@@ -81,11 +81,11 @@ pub enum SonarrEvent {
StartTask(SonarrTaskName),
TestIndexer(i64),
TestAllIndexers,
ToggleSeasonMonitoring((i64, i64)),
ToggleSeasonMonitoring(i64, i64),
ToggleSeriesMonitoring(i64),
ToggleEpisodeMonitoring(i64),
TriggerAutomaticEpisodeSearch(i64),
TriggerAutomaticSeasonSearch((i64, i64)),
TriggerAutomaticSeasonSearch(i64, i64),
TriggerAutomaticSeriesSearch(i64),
UpdateAllSeries,
UpdateAndScanSeries(i64),
@@ -118,7 +118,7 @@ impl NetworkResource for SonarrEvent {
SonarrEvent::GetQueuedEvents
| SonarrEvent::StartTask(_)
| SonarrEvent::TriggerAutomaticSeriesSearch(_)
| SonarrEvent::TriggerAutomaticSeasonSearch(_)
| SonarrEvent::TriggerAutomaticSeasonSearch(_, _)
| SonarrEvent::TriggerAutomaticEpisodeSearch(_)
| SonarrEvent::UpdateAllSeries
| SonarrEvent::UpdateAndScanSeries(_)
@@ -126,8 +126,8 @@ impl NetworkResource for SonarrEvent {
SonarrEvent::GetRootFolders
| SonarrEvent::DeleteRootFolder(_)
| SonarrEvent::AddRootFolder(_) => "/rootfolder",
SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release",
SonarrEvent::GetSeriesHistory(_) | SonarrEvent::GetSeasonHistory(_) => "/history/series",
SonarrEvent::GetSeasonReleases(_, _) | SonarrEvent::GetEpisodeReleases(_) => "/release",
SonarrEvent::GetSeriesHistory(_) | SonarrEvent::GetSeasonHistory(_, _) => "/history/series",
SonarrEvent::GetStatus => "/system/status",
SonarrEvent::GetTasks => "/system/task",
SonarrEvent::GetUpdates => "/update",
@@ -137,7 +137,7 @@ impl NetworkResource for SonarrEvent {
| SonarrEvent::GetSeriesDetails(_)
| SonarrEvent::DeleteSeries(_)
| SonarrEvent::EditSeries(_)
| SonarrEvent::ToggleSeasonMonitoring(_)
| SonarrEvent::ToggleSeasonMonitoring(_, _)
| SonarrEvent::ToggleSeriesMonitoring(_) => "/series",
SonarrEvent::SearchNewSeries(_) => "/series/lookup",
SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed",
@@ -275,12 +275,12 @@ impl Network<'_, '_> {
.get_episode_releases(params)
.await
.map(SonarrSerdeable::from),
SonarrEvent::GetSeasonHistory(params) => self
.get_sonarr_season_history(params)
SonarrEvent::GetSeasonHistory(series_id, season_number) => self
.get_sonarr_season_history(series_id, season_number)
.await
.map(SonarrSerdeable::from),
SonarrEvent::GetSeasonReleases(params) => self
.get_season_releases(params)
SonarrEvent::GetSeasonReleases(series_id, season_number) => self
.get_season_releases(series_id, season_number)
.await
.map(SonarrSerdeable::from),
SonarrEvent::GetSecurityConfig => self
@@ -328,16 +328,16 @@ impl Network<'_, '_> {
.toggle_sonarr_episode_monitoring(episode_id)
.await
.map(SonarrSerdeable::from),
SonarrEvent::ToggleSeasonMonitoring(params) => self
.toggle_sonarr_season_monitoring(params)
SonarrEvent::ToggleSeasonMonitoring(series_id, season_number) => self
.toggle_sonarr_season_monitoring(series_id, season_number)
.await
.map(SonarrSerdeable::from),
SonarrEvent::ToggleSeriesMonitoring(series_id) => self
.toggle_sonarr_series_monitoring(series_id)
.await
.map(SonarrSerdeable::from),
SonarrEvent::TriggerAutomaticSeasonSearch(params) => self
.trigger_automatic_season_search(params)
SonarrEvent::TriggerAutomaticSeasonSearch(series_id, season_number) => self
.trigger_automatic_season_search(series_id, season_number)
.await
.map(SonarrSerdeable::from),
SonarrEvent::TriggerAutomaticSeriesSearch(series_id) => self
@@ -43,8 +43,8 @@ mod test {
SonarrEvent::GetSeriesDetails(0),
SonarrEvent::DeleteSeries(DeleteSeriesParams::default()),
SonarrEvent::EditSeries(EditSeriesParams::default()),
SonarrEvent::ToggleSeasonMonitoring((0, 0)),
SonarrEvent::ToggleSeriesMonitoring(0),
SonarrEvent::ToggleSeasonMonitoring(0, 0),
SonarrEvent::ToggleSeriesMonitoring(0)
)]
event: SonarrEvent,
) {
@@ -76,7 +76,7 @@ mod test {
SonarrEvent::GetQueuedEvents,
SonarrEvent::StartTask(SonarrTaskName::default()),
SonarrEvent::TriggerAutomaticEpisodeSearch(0),
SonarrEvent::TriggerAutomaticSeasonSearch((0, 0)),
SonarrEvent::TriggerAutomaticSeasonSearch(0, 0),
SonarrEvent::TriggerAutomaticSeriesSearch(0),
SonarrEvent::UpdateAllSeries,
SonarrEvent::UpdateAndScanSeries(0),
@@ -108,10 +108,7 @@ mod test {
#[rstest]
fn test_resource_series_history(
#[values(
SonarrEvent::GetSeriesHistory(0),
SonarrEvent::GetSeasonHistory((0, 0))
)]
#[values(SonarrEvent::GetSeriesHistory(0), SonarrEvent::GetSeasonHistory(0, 0))]
event: SonarrEvent,
) {
assert_str_eq!(event.resource(), "/history/series");
@@ -139,7 +136,7 @@ mod test {
#[rstest]
fn test_resource_release(
#[values(
SonarrEvent::GetSeasonReleases((0, 0)),
SonarrEvent::GetSeasonReleases(0, 0),
SonarrEvent::GetEpisodeReleases(0)
)]
event: SonarrEvent,
@@ -0,0 +1,74 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::blocklist::BlocklistUi;
use crate::ui::ui_test_utils::test_utils::render_to_string_with_app;
#[test]
fn test_blocklist_ui_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if BLOCKLIST_BLOCKS.contains(&active_lidarr_block) {
assert!(BlocklistUi::accepts(active_lidarr_block.into()));
} else {
assert!(!BlocklistUi::accepts(active_lidarr_block.into()));
}
});
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
use super::*;
#[test]
fn test_blocklist_ui_renders_loading() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
BlocklistUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_blocklist_ui_renders_empty() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Blocklist.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
BlocklistUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[rstest]
fn test_blocklist_ui_renders(
#[values(
ActiveLidarrBlock::Blocklist,
ActiveLidarrBlock::BlocklistItemDetails,
ActiveLidarrBlock::DeleteBlocklistItemPrompt,
ActiveLidarrBlock::BlocklistClearAllItemsPrompt,
ActiveLidarrBlock::BlocklistSortPrompt
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
BlocklistUi::draw(f, app, f.area());
});
insta::assert_snapshot!(format!("blocklist_tab_{active_lidarr_block}"), output);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
use crate::app::App;
use crate::models::Route;
use crate::models::lidarr_models::BlocklistItem;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, BLOCKLIST_BLOCKS};
use crate::ui::DrawUi;
use crate::ui::styles::{ManagarrStyle, secondary_style};
use crate::ui::utils::layout_block_top_border;
use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt;
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::message::Message;
use crate::ui::widgets::popup::{Popup, Size};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Rect};
use ratatui::style::Stylize;
use ratatui::text::{Line, Text};
use ratatui::widgets::{Cell, Row};
#[cfg(test)]
#[path = "blocklist_ui_tests.rs"]
mod blocklist_ui_tests;
pub(super) struct BlocklistUi;
impl DrawUi for BlocklistUi {
fn accepts(route: Route) -> bool {
if let Route::Lidarr(active_lidarr_block, _) = route {
return BLOCKLIST_BLOCKS.contains(&active_lidarr_block);
}
false
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() {
draw_blocklist_table(f, app, area);
match active_lidarr_block {
ActiveLidarrBlock::BlocklistItemDetails => {
draw_blocklist_item_details_popup(f, app);
}
ActiveLidarrBlock::DeleteBlocklistItemPrompt => {
let prompt = format!(
"Do you want to remove this item from your blocklist: \n{}?",
app
.data
.lidarr_data
.blocklist
.current_selection()
.source_title
);
let confirmation_prompt = ConfirmationPrompt::new()
.title("Remove Item from Blocklist")
.prompt(&prompt)
.yes_no_value(app.data.lidarr_data.prompt_confirm);
f.render_widget(
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
f.area(),
);
}
ActiveLidarrBlock::BlocklistClearAllItemsPrompt => {
let confirmation_prompt = ConfirmationPrompt::new()
.title("Clear Blocklist")
.prompt("Do you want to clear your blocklist?")
.yes_no_value(app.data.lidarr_data.prompt_confirm);
f.render_widget(
Popup::new(confirmation_prompt).size(Size::SmallPrompt),
f.area(),
);
}
_ => (),
}
}
}
}
fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() {
let blocklist_row_mapping = |blocklist_item: &BlocklistItem| {
let BlocklistItem {
source_title,
artist,
quality,
date,
..
} = blocklist_item;
let title = artist.artist_name.text.to_owned();
Row::new(vec![
Cell::from(title),
Cell::from(source_title.to_owned()),
Cell::from(quality.quality.name.to_owned()),
Cell::from(date.to_string()),
])
.primary()
};
let blocklist_table = ManagarrTable::new(
Some(&mut app.data.lidarr_data.blocklist),
blocklist_row_mapping,
)
.block(layout_block_top_border())
.loading(app.is_loading)
.sorting(active_lidarr_block == ActiveLidarrBlock::BlocklistSortPrompt)
.headers(["Artist Name", "Source Title", "Quality", "Date"])
.constraints([
Constraint::Percentage(27),
Constraint::Percentage(43),
Constraint::Percentage(13),
Constraint::Percentage(17),
]);
f.render_widget(blocklist_table, area);
}
}
fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) {
let current_selection = if app.data.lidarr_data.blocklist.items.is_empty() {
BlocklistItem::default()
} else {
app.data.lidarr_data.blocklist.current_selection().clone()
};
let BlocklistItem {
source_title,
protocol,
indexer,
message,
..
} = current_selection;
let text = Text::from(vec![
Line::from(vec![
"Name: ".bold().secondary(),
source_title.to_owned().secondary(),
]),
Line::from(vec![
"Protocol: ".bold().secondary(),
protocol.to_owned().secondary(),
]),
Line::from(vec![
"Indexer: ".bold().secondary(),
indexer.to_owned().secondary(),
]),
Line::from(vec![
"Message: ".bold().secondary(),
message.to_owned().secondary(),
]),
]);
let message = Message::new(text)
.title("Details")
.style(secondary_style())
.alignment(Alignment::Left);
f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area());
}
@@ -0,0 +1,7 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Artist Name ▼ Source Title Quality Date
=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC
@@ -0,0 +1,34 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Artist Name ▼ Source Title Quality Date
=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC
╭────── Clear Blocklist ──────╮
│ Do you want to clear your │
│ blocklist? │
│ │
│ │
│ │
│╭──────────────╮╭─────────────╮│
││ Yes ││ No ││
│╰──────────────╯╰─────────────╯│
╰───────────────────────────────╯
@@ -0,0 +1,34 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Artist Name ▼ Source Title Quality Date
=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC
╭─────────────────────────────────── Details ───────────────────────────────────╮
│Name: Alex - Something │
│Protocol: usenet │
│Indexer: NZBgeek (Prowlarr) │
│Message: test message │
│ │
│ │
│ │
│ │
╰─────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,42 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Artist Name Source Title Quality Date
=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC
╭───────────────────────────────╮
│Something │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────╯
@@ -0,0 +1,38 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Artist Name ▼ Source Title Quality Date
=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC
╭────────────── Remove Item from Blocklist ───────────────╮
│ Do you want to remove this item from your blocklist: │
│ Alex - Something? │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│╭────────────────────────────╮╭───────────────────────────╮│
││ Yes ││ No ││
│╰────────────────────────────╯╰───────────────────────────╯│
╰───────────────────────────────────────────────────────────╯
@@ -0,0 +1,5 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,8 @@
---
source: src/ui/lidarr_ui/blocklist/blocklist_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Loading ...
+5 -3
View File
@@ -23,9 +23,11 @@ mod tests {
#[rstest]
#[case(ActiveLidarrBlock::Artists, 0)]
#[case(ActiveLidarrBlock::Downloads, 1)]
#[case(ActiveLidarrBlock::History, 2)]
#[case(ActiveLidarrBlock::RootFolders, 3)]
#[case(ActiveLidarrBlock::Indexers, 4)]
#[case(ActiveLidarrBlock::Blocklist, 2)]
#[case(ActiveLidarrBlock::History, 3)]
#[case(ActiveLidarrBlock::RootFolders, 4)]
#[case(ActiveLidarrBlock::Indexers, 5)]
#[case(ActiveLidarrBlock::System, 6)]
fn test_lidarr_ui_renders_lidarr_tabs(
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] index: usize,
+3
View File
@@ -23,6 +23,7 @@ use super::{
},
widgets::loading_block::LoadingBlock,
};
use crate::ui::lidarr_ui::blocklist::BlocklistUi;
use crate::ui::lidarr_ui::downloads::DownloadsUi;
use crate::ui::lidarr_ui::indexers::IndexersUi;
use crate::ui::lidarr_ui::root_folders::RootFoldersUi;
@@ -39,6 +40,7 @@ use crate::{
utils::convert_to_gb,
};
mod blocklist;
mod downloads;
mod history;
mod indexers;
@@ -65,6 +67,7 @@ impl DrawUi for LidarrUi {
match route {
_ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area),
_ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area),
_ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area),
_ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area),
_ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area),
_ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area),
@@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │
│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │
@@ -0,0 +1,54 @@
---
source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Artist Name ▼ Source Title Quality Date │
│=> Alex Alex - Something Lossless 2023-05-20 21:29:16 UTC │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Title Percent Complete Size Output Path Indexer Download Client │
│=> Test download title 50% 3.30 GB /nfs/music/alex/album kickass torrents transmission │
@@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Source Title ▼ Event Type Quality Date │
│=> Test source title grabbed Lossless 2023-01-01 00:00:00 UTC │
@@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Indexer RSS Automatic Search Interactive Search Priority Tags │
│=> Test Indexer Enabled Enabled Enabled 25 alex │
@@ -3,7 +3,7 @@ source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Path Free Space Unmapped Folders │
│=> /nfs 204800.00 GB 0 │
@@ -0,0 +1,54 @@
---
source: src/ui/lidarr_ui/lidarr_ui_tests.rs
expression: output
---
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│╭ Tasks ───────────────────────────────────────────────────────────────────────╮╭ Queued Events ──────────────────────────────────────────────────────────────╮│
││Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration ││
││Backup 1 hour now 59 minutes ││manual completed Refresh Monitored 4 minutes ago 4 minutes a 00:03:03 ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
││ ││ ││
│╰────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────╯│
│╭ Logs ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -16,7 +16,7 @@ expression: output
│ ││ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │
╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │
│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │
@@ -16,7 +16,7 @@ expression: output
│ │ s search │ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │
╰───────────────────────────────────│ f filter │─────────────────╯╰──────────────────╯
╭ Artists ────────────────────────│ ctrl-r refresh │─────────────────────────────────────╮
│ Library │ Downloads │ HistoryRo│ u update all │ │
│ Library │ Downloads │ Blocklist │ │ u update all │ │
│───────────────────────────────────│ enter details │─────────────────────────────────────│
│ Name ▼ Typ│ esc cancel filter │e Monitored Tags │
│=> Alex Per│ ↑ k scroll up │0 GB 🏷 alex │
@@ -19,7 +19,7 @@ expression: output
│ ││ ││ ⠀⠀⠀⠉⠻⠿⢿⡆⡾⠿⠟⠉⠀⠀⠀ │
╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯
╭ Artists ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Library │ Downloads │ History │ Root Folders │ Indexers │ System
│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │
│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │