Compare commits

...

687 Commits

Author SHA1 Message Date
Alex Clarke
ec90e2dca7 Merge pull request #56 from Dark-Alex-17/develop
Check / stable / fmt (push) Successful in 9m53s
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
Hotfix and feature adds for some new issues
2026-02-04 10:38:58 -07:00
5a4e6c9623 style: Addressed CR comments about adding an explicit Command::ConfigPath to the main command match statement 2026-02-04 10:24:59 -07:00
9a6a06ee20 build: Updated all dependencies to resolve dependabot security issues 2026-02-04 09:51:57 -07:00
5556e48fc0 fix: Improved the system notification feature so it can persist between modals 2026-02-04 08:18:04 -07:00
af573cac2a feat: Added support for a system-wide notification popup mechanism that works across Servarrs 2026-02-03 17:03:12 -07:00
447cf6a2b4 style: Applied formatting to the artist_details_ui 2026-02-03 08:10:02 -07:00
203bf9cb66 fix: Sonarr API updated to somtimes allow either seeders or leechers to be null 2026-02-03 08:00:31 -07:00
4f9bc34d23 docs: Updated the README so that the example configuration only includes references to Servarrs that are actually supported [#55] 2026-01-30 15:45:22 -07:00
a2aa9507a9 fix: Improved the first-time run behavior so that it outputs the default configuration file it tries to load to help users locate the file on first-runs 2026-01-30 15:36:26 -07:00
c791b985f0 docs: Updated README to tell users to use 'managarr config-path' as the default method to discover the location of the default configuration file 2026-01-29 12:56:59 -07:00
5c517a748c fix: 'managarr config-path' should work without a pre-existing config already in place [#54] 2026-01-29 12:54:45 -07:00
892c687077 docs: Updated README with config-path as another way to find the default config file for a given system 2026-01-29 10:26:12 -07:00
c6d5b98e86 feat: Implemented a 'config-path' command to print out the default Managarr configuration file path to help address #54 2026-01-29 10:23:05 -07:00
67e5114ec2 build: Removed #[allow(dead_code)] from the LIDARR_LOGO since it is now being utilized 2026-01-26 11:56:05 -07:00
fdc331865e feat: Full support for filtering disks and aggregating root folders in the UI's 'Stats' block 2026-01-26 11:10:59 -07:00
f388dccc08 feat: proper collapsing of root folder paths in the stats layer of the UI 2026-01-22 14:44:48 -07:00
64fad3b9bc refactor: Removed the filtering of monitored_storage_paths from the networking module and migrated all of it to the UI 2026-01-22 13:12:51 -07:00
3be7b09da8 feat: Added config option to filter for specific disk space paths to display in the UI (CLI is unaffected) 2026-01-22 10:49:30 -07:00
5f3123cd79 test: Updated snapshot tests to assert the paths are updated in the UI
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 10m59s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 59s
Check / 1.89.0 / check (push) Successful in 1m7s
Test Suite / ubuntu / beta (push) Successful in 1m48s
Test Suite / ubuntu / stable (push) Successful in 1m43s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m55s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-01-22 09:39:44 -07:00
d8f7febfe1 feat: Improved disk-space UI and CLI that shows the actual path being monitored instead of just a disk number
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-01-22 09:36:58 -07:00
0bfbb44e3e feat: Implemented the forgotten lidarr list disk-space command
Check / stable / fmt (push) Successful in 9m59s
Check / beta / clippy (push) Successful in 10m58s
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-01-22 09:06:38 -07:00
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
eff1a901eb feat: CLI and TUI support for track history and track details in Lidarr 2026-01-19 14:50:20 -07:00
7add62b245 fix: Sonarr network wasn't checking for the user to be using the sorting block when populating season details 2026-01-19 14:50:01 -07:00
5fa9b08347 fix: Sonarr CLI was not properly filtering out episode and season releases when manually searching for releases 2026-01-19 14:49:17 -07:00
7bb5f83a56 feat: Lidarr UI support for album details popup 2026-01-16 23:21:25 -07:00
caf4ad1e64 feat: Implemented TUI handler support for the Album Details popup in Lidarr 2026-01-16 17:16:44 -07:00
bc6ecc39f4 feat: Bulk added CLI support for tracks and album functionalities in Lidarr 2026-01-16 14:38:08 -07:00
5e70d70758 test: Wrote tests for the Lidarr manual artist search key handler 2026-01-16 11:00:38 -07:00
1329589bd6 feat: Implemented the manual artist discography search tab in Lidarr's artist details UI 2026-01-15 14:36:09 -07:00
c6dc8f6090 feat: Lidarr CLI support for downloading a release 2026-01-15 12:57:54 -07:00
0ee275d58f fix: Sonarr manual search TUI and CLI incorrectly displaying the same unfiltered results for both season and episode searches 2026-01-15 12:43:16 -07:00
8dfa664a06 feat: CLI support for searching for discography releases in Lidarr 2026-01-15 11:39:34 -07:00
d7f0dd5950 feat: Added TUI and CLI support for viewing Artist history in Lidarr 2026-01-14 16:09:37 -07:00
8b9467bd39 feat: Full Lidarr system support for both the CLI and TUI 2026-01-14 14:50:33 -07:00
c74d5936d2 feat: Full CLI and TUI support for the Lidarr Indexers tab 2026-01-14 13:30:51 -07:00
8abcf44866 feat: Full support for adding a root folder in Lidarr from both the CLI and TUI 2026-01-14 09:06:27 -07:00
d2217509f2 feat: naive lidarr root folder tab implementation. Needs improved add logic 2026-01-13 14:33:12 -07:00
c68cd75015 feat: Downloads tab support in Lidarr 2026-01-13 13:40:18 -07:00
e1a25bfaf2 refactor: Improved and simplified the implementation of history details for both Sonarr and Lidarr 2026-01-13 12:36:21 -07:00
ad9e2b3671 feat: Created a History tab in the Radarr UI and created a list history command and mark-history-item-as-failed command for Radarr 2026-01-13 12:35:54 -07:00
0172253d20 fix: Slowed down the automatic text scrolling in tables so the text is readable 2026-01-12 15:28:41 -07:00
47fdee190a fix: 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 2026-01-12 15:28:03 -07:00
68b08d1cd7 feat: Implemented the Lidarr History tab and CLI support 2026-01-12 14:43:05 -07:00
f31810e48a feat: TUI support for deleting a Lidarr album from the artist details popup 2026-01-09 17:21:10 -07:00
09bee7473f feat: CLI support for deleting an album from Lidarr 2026-01-09 16:33:32 -07:00
b2814371f0 feat: Completed support for viewing Lidarr artist details 2026-01-09 16:22:03 -07:00
269057867f fix: Bug in submitting the update series prompt in the series details UI in Sonarr 2026-01-09 14:01:46 -07:00
450fdd7106 test: Added tests for the Lidarr context clues for the add artist popup 2026-01-08 15:20:17 -07:00
c624d1b9e4 feat: Full CLI and TUI support for adding an artist to Lidarr 2026-01-08 15:16:01 -07:00
e94f78dc7b refactor: Let serde serialize Add Series and Add Movie enums instead of calling to_string up front 2026-01-08 10:52:30 -07:00
b1a6db21f1 fix: Don't include Lidarr artist disambiguation in Edit popup title when it is empty 2026-01-08 10:09:15 -07:00
ca208ff5e4 fix: 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 2026-01-08 10:05:15 -07:00
1a43d1ec7c feat: Include the Lidarr artist disambiguation in the title of the Edit Artist popup 2026-01-08 09:28:56 -07:00
4abf705cb5 fix: Added the correct keybinding context to the Lidarr edit artist popup 2026-01-08 09:28:12 -07:00
cf98b10d77 style: Modified the LineGauge style so its more readable in the UI 2026-01-07 17:19:32 -07:00
f0ed71b436 build: Upgraded to Ratatui v0.30.0 and fixed a new security vulnerability [#13] 2026-01-07 17:15:54 -07:00
243de47cae feat: Initial Lidarr support for searching for new artists 2026-01-07 15:53:18 -07:00
d3947d9e15 fix: Improved fault tolerance for search result tables and test all indexer results tables 2026-01-07 14:58:32 -07:00
64d8c65831 fix: Prevented additional empty slice errors in indexer tables 2026-01-07 14:09:12 -07:00
60c4cf1098 fix: 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 2026-01-07 14:00:02 -07:00
9cc3ccb419 feat: Lidarr CLI commands to list quality profiles and metadata profiles 2026-01-07 13:15:50 -07:00
45c61369c8 feat: Improved CLI readability by creating a separate Global Options section for global flags 2026-01-07 13:08:23 -07:00
a8609e08c5 feat: CLI support for deleting a tag in Lidarr 2026-01-07 12:50:23 -07:00
a18b047f4f feat: Lidarr CLI support for listing and adding tags 2026-01-07 12:20:39 -07:00
b1afdaf541 feat: Added CLI and TUI support for editing Lidarr artists 2026-01-07 12:01:03 -07:00
3c1634d1e3 testing 2026-01-07 10:45:49 -07:00
9b4eda6a9d feat: Support for updating all Lidarr artists in both the CLI and TUI 2026-01-06 12:47:10 -07:00
96308afeee feat: Added Lidarr CLI support for fetching the host config and the security config 2026-01-06 11:00:19 -07:00
4e13d5d34d style: Applied formatting for the lidarr_command_tests 2026-01-06 10:25:05 -07:00
b4a99d1665 feat: Created Lidarr commands: 'get artist-details' and 'get system-status' 2026-01-06 10:24:51 -07:00
a012f6ecd5 feat: Fetch the artist members as part of the artist details query 2026-01-06 10:10:28 -07:00
5afee1998b feat: Support for toggling the monitoring of a given artist via the CLI and TUI 2026-01-06 09:40:16 -07:00
059fa48bd9 style: Applied uniform formatting across all new Lidarr files 2026-01-05 15:46:16 -07:00
6771a0ab38 feat: Full support for deleting an artist via CLI and TUI 2026-01-05 15:44:51 -07:00
bc3aeefa6e feat: TUI support for Lidarr library 2026-01-05 13:10:30 -07:00
e61537942b test: Implemented tests for the Lidarr list artists command 2026-01-05 11:28:35 -07:00
5d09b2402c feat: CLI support for listing artists 2026-01-05 10:58:48 -07:00
368f7505ff feat: Improved UI speed and responsiveness
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2025-12-19 13:41:14 -07:00
6a9fd0999c build: Added additional compile-time optimizations for speed improvements and a smaller binary 2025-12-17 16:45:51 -07:00
Alex Clarke
d8ac94d067 Merge pull request #51 from Dark-Alex-17/test-refactoring
Refactored all tests and wrote snapshot tests to finally cover the UI module
2025-12-16 16:41:04 -07:00
0532d59746 test: Wrote snapshot tests for all Sonarr UI 2025-12-16 14:12:10 -07:00
e0fcbc71e1 ci: updated Cargo Husky hooks to not use the Makefile 2025-12-15 20:21:14 -07:00
c072c57bbb style: Applied uniform formatting across all new UI test files 2025-12-15 20:18:28 -07:00
aadd6c8abf fix: Fixed an issue with the Managarr table that would incorrectly try to display things before is_loading was ready 2025-12-15 20:17:28 -07:00
316ed64315 test: Created snapshot tests for all Radarr UI modules 2025-12-15 20:17:09 -07:00
7084ca1be2 ci: Added more aliases and commands to the Justfile for snapshot tests 2025-12-15 20:16:10 -07:00
317daddb8e fix: Fixed a bug where the edit collection popup would not display when opening it from collection details 2025-12-15 20:15:52 -07:00
8ef291efd8 ci: Fixed some typos in the justfile 2025-12-15 13:22:00 -07:00
92be9c50bf docs: Updated contributing docs to mention how to use Just 2025-12-15 13:18:54 -07:00
f0e5ecd5de ci: Migrated to using Just instead of using Makefiles and included a helix config 2025-12-15 13:16:16 -07:00
e2c44583e8 style: Applied formatting to recently merged in files 2025-12-15 08:37:54 -07:00
5da741f3a9 ci: Updated Cargo.lock after main merge 2025-12-15 08:37:28 -07:00
35c5eb65cb Merge remote-tracking branch 'origin/main' into test-refactoring
# Conflicts:
#	Cargo.lock
#	src/cli/mod.rs
#	src/cli/radarr/add_command_handler.rs
#	src/cli/radarr/get_command_handler.rs
#	src/cli/radarr/list_command_handler.rs
2025-12-15 07:55:14 -07:00
7e53a26e5f Merge remote-tracking branch 'origin/main' into test-refactoring 2025-12-15 07:52:47 -07:00
github-actions[bot]
436b3f85d0 bump: version 0.6.2 → 0.6.3 [skip ci] 2025-12-13 20:23:15 +00:00
9c1a9cc3c5 fix: Wrapped all Sonarr use of Language with Option to fix the 'null' array issue in the new Sonarr API 2025-12-13 13:06:33 -07:00
82f30f126d test: Implemented UI snapshot tests 2025-12-12 15:44:11 -07:00
github-actions[bot]
9599ac28ca chore: bump Cargo.toml to 0.6.2 2025-12-12 17:02:43 +00:00
github-actions[bot]
e71a699ed8 bump: version 0.6.1 → 0.6.2 [skip ci] 2025-12-12 17:02:30 +00:00
ff4eb8ca98 ci: Specify commitizen version [skip ci] 2025-12-12 10:00:11 -07:00
b69973b9af ci: Fix the commitizen version issue [skip ci] 2025-12-12 09:54:15 -07:00
3e133fa147 refactor: Replaced all modulo usages of tick_until_poll with is_multiple_of 2025-12-12 09:20:05 -07:00
ae506789ab fix: Fixed breaking Sonarr Episode file API calls after recent Sonarr API update 2025-12-12 09:17:36 -07:00
c3fa689617 refactor: Use is_multiple_of for the tick counter in the UI module 2025-12-09 14:30:17 -07:00
b51e42b4b2 refactor: Updated all model tests to use purpose-built assertions to improve readability and maintainability 2025-12-09 14:29:35 -07:00
d4bea91186 refactor: Updated all handler tests to use purpose built assertions to improve readability and maintainability 2025-12-09 14:28:47 -07:00
d47dadeb88 refactor: Used is_multiple_of to make life easier and cleaner in the app module 2025-12-09 14:28:09 -07:00
b807904c6c refactor: Refactored all cli tests to use purpose-built assertions 2025-12-08 17:07:31 -07:00
ee1bee22eb refactor: Improved test assertions in the app module 2025-12-08 16:47:40 -07:00
f6c4c1623f style: Removed unused imports after massive handler testing refactor 2025-12-04 23:03:48 -07:00
49fd086b92 refactor: Created dedicated proptests and assertions to clean up the handler unit tests 2025-12-04 22:57:06 -07:00
35dce0bf01 refactor: 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 2025-12-04 16:03:58 -07:00
71240373c0 refactor: Simplified both the table_handler macro and the stateful_table implementation 2025-12-04 11:34:45 -07:00
659023d561 refactor: Improved error handling for the tail-logs subcommand to propagate errors up the stack instead of exiting there. 2025-12-04 10:10:19 -07:00
a0073b65ad refactor: 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 2025-12-04 10:02:32 -07:00
cba53e0841 refactor: Improved error handling project-wide and cleaned up some regexes with unnecessary escapes (tail_logs and interpolate_env_vars) 2025-12-04 09:03:58 -07:00
e50fb88bfc refactor: Refactored to use more idiomatic let-else statements where applicable 2025-12-03 15:23:07 -07:00
ad58912baf test: Updated Rust edition to 2024 and refactored network module tests to be more idiomatic 2025-12-03 14:49:27 -07:00
c4e8d64710 ci: Fixed changelog generation for releases 2025-09-14 18:46:28 -06:00
ca4319001c docs: Updated the Homebrew tap description [skip-ci] 2025-09-12 11:25:20 -06:00
ebc58b831d ci: Modified the CI/CD pipeline to bump the procedural macros as well 2025-09-02 17:27:12 -06:00
github-actions[bot]
b1572c903c chore: bump Cargo.toml to 0.6.1 2025-09-02 22:58:28 +00:00
github-actions[bot]
5b73924e2a bump: version 0.6.0 → 0.6.1 [skip ci] 2025-09-02 22:58:25 +00:00
97ce258a8d fix: Fixed UI bugs introduced as part of the hotkey refactor 2025-09-02 15:49:36 -06:00
fedec79a95 refactor: Updated crate to publish properly with the procedural macros 2025-08-29 17:31:14 -06:00
8e74709b9c chore(release): Add metadata to enum_display_style_derive 2025-08-29 17:22:15 -06:00
github-actions[bot]
be09e1d932 bump: version 0.5.1 → 0.6.0 [skip ci] 2025-08-29 22:33:29 +00:00
Alex Clarke
d95417d075 Merge pull request #49 from Dark-Alex-17/develop
chore: Pre-release merge develop into main
2025-08-29 16:14:22 -06:00
2e3e511e3b ci: Improved the release workflow to be more compatible with Act local testing 2025-08-29 15:41:39 -06:00
8555052cc4 build: Modified the Dockerfile to be able to build the procedural macros as well 2025-08-29 15:09:22 -06:00
80bc6793c7 ci: Updated the release flow to also update the Cargo.toml version automatically 2025-08-29 13:45:14 -06:00
049a0c5d49 docs: Updated CONTRIBUTING.md to explain how to use act 2025-08-29 13:27:36 -06:00
ae9cb77e6d ci: Added step to release flow to update the Cargo.toml version 2025-08-29 11:13:32 -06:00
126ed5ed72 build: Updated dependencies 2025-08-29 10:59:29 -06:00
63ae64cebd ci: Modified CI/CD workflows so they can be tested locally with Act 2025-08-29 10:58:39 -06:00
0b29351366 feat: Support for custom headers to be added to every request to each server to support alternative authentication mechanisms [#47] 2025-08-29 10:23:17 -06:00
4ea39f74fe Updated the Rust version to 1.89 in the dockerfile to match the crate definition 2025-08-26 16:43:15 -06:00
76fcf5e67f docs: Fixed a typo in the README 2025-08-15 14:39:47 -06:00
11457736e6 refactor: Network module is now broken out into similar directory structures for each servarr to mimic the rest of the project to make it easier to develop and maintain 2025-08-14 13:14:23 -06:00
e2a6af1cbd refactor: Refactored the IndexerTestResut model into the general Servarr models 2025-08-12 17:05:17 -06:00
20ea15009d build: Updated dependencies and upgraded to Rust 1.89.0 2025-08-12 16:56:45 -06:00
00ab0f27f7 feat: Refactor all keybinding tips into a dynamically changing menu that can be invoked via '?' [#32] 2025-08-12 16:27:34 -06:00
1f4870d082 feat: Display total disk usage for series in the Library view to mirror Radarr functionality [#44] 2025-08-11 10:24:00 -06:00
e96af7410e feat: Pagination support for jumping 20 items at a time in all table views [#45] 2025-08-08 17:04:28 -06:00
345bb8ce03 refactor: Renamed 'ctrl-*' keyboard shortcuts to 'C-*' to simplify and shrink the on-screen help 2025-08-08 15:09:39 -06:00
dbcfc77ad4 feat: Support toggling Movie monitoring directly from the library view [#43] 2025-08-08 14:54:28 -06:00
e653532212 feat: Support toggling Movie monitoring from the CLI 2025-08-08 14:49:15 -06:00
8e7e31f64d feat: Support toggling Series monitoring directly from the Sonarr library view [#43] 2025-08-08 14:48:27 -06:00
bff3795cc6 feat: Support toggling Series monitoring from the CLI 2025-08-08 14:46:35 -06:00
8782f1353d docs: Formatted and cleaned up README a bit 2025-08-08 14:10:33 -06:00
f08f255a46 fix: Marked Radarr studios as nullable to prevent crashes 2025-08-07 20:05:05 -06:00
02870043ec style: Addressed updated linter complaints 2025-08-07 19:49:11 -06:00
154e491922 fix: Fixed a bug where the Sonarr API was returning empty values for seeders when searching for season releases 2025-08-07 16:00:10 -06:00
ef5e702255 feat: Fixed the Radarr downloads tab to display more than 10 downloads at a time and added a new --count flag to the CLI for specifying the number of downloads to return 2025-07-13 15:04:39 -06:00
cb4cd93bcd feat: Fetch more than 10 downloads when listing Sonarr downloads, and add a --count flag to the CLI to specify how many downloads to fetch 2025-07-13 14:48:15 -06:00
Alex Clarke
ee034c9caf Update README.md to remove the cheeky "try before you buy" heading since some users reported it as misleading 2025-05-29 23:48:16 -06:00
c133a4ecd2 fix: Improve fault tolerance for tag associations in Radarr and Sonarr 2025-05-18 13:49:52 -06:00
f09a2efa5e Updated README to have labels for the theme examples for better readability 2025-04-08 17:31:29 -06:00
49983f4173 fix: Upgraded to the most recent version of Tokio to mitigate CWE-664 2025-04-08 11:05:32 -06:00
0f9894e1be fix: Updated all dependencies and updated openssl to the most recent version to mitigate CWE-416 2025-04-07 15:22:56 -06:00
Alex Clarke
8c0dffec31 Merge pull request #41 from cwelsys/fix-key-event-handling
fix: Ensure key events are only processed on key press to avoid dupli…
2025-04-07 12:01:14 -06:00
Alex Clarke
0085d944ea Merge branch 'develop' into fix-key-event-handling 2025-04-07 11:58:06 -06:00
Connor Welsh
89cf0e66a6 fix: Ensure key events are only processed on key press to avoid duplicates 2025-04-06 17:29:06 -04:00
Alex Clarke
baef436f78 Merge pull request #39 from Dark-Alex-17/alternative-keymappings
Alternative keymappings
2025-04-01 10:11:22 -06:00
cf00d7992e fix: Updated the name of the should_ignore_quit_key to ignore_special_keys_for_textbox_input to give a better idea of what the flag is used for; also added alt keybinding for backspace 2025-03-27 15:21:44 -06:00
f25829f3c1 Merge remote-tracking branch 'refs/remotes/origin/develop' 2025-03-21 11:56:12 -06:00
8c783bc405 fix: Marked videoCodecs as Option to resolve #38 2025-03-21 11:55:54 -06:00
0048d71b74 feat: Support alternative keymappings for all keys, featuring hjkl movements 2025-03-17 22:02:15 -06:00
c633347ecc Merge remote-tracking branch 'refs/remotes/origin/develop' 2025-03-17 20:49:52 -06:00
Alex Clarke
ecd6a0ec32 Merge pull request #37 from Dark-Alex-17/custom-themes
Support Themes
2025-03-17 14:23:18 -06:00
30507d9d01 docs: Updated the README to include the new flags 2025-03-17 13:26:46 -06:00
6245a794d5 docs: Update all screenshots to not have any auto-generated usernames in the tags columns 2025-03-10 16:22:24 -06:00
5c822e4890 Merge branch 'develop' 2025-03-10 16:13:48 -06:00
cab06fe43f fix: Marked the Season.statistics field as Option so that a panic does not happen for outdated Sonarr data. This resolves #35 2025-03-10 16:13:04 -06:00
b4ff5f3351 feat: Added the Eldritch theme and updated documentation 2025-03-10 15:49:40 -06:00
0834802481 fix: When adding a film from the Collection Details modal, the render order was wrong: Radarr Library -> Collection Table -> Add Movie Prompt (missing the Collection details prompt too). Correct order is: Collection Table -> Collection Details Modal -> Add Movie Modal 2025-03-10 15:08:02 -06:00
3afd74dcbf fix: Fixed a bug that was rendering encompassing blocks after other widgets were rendered, thus overwriting the custom styles on each previously rendered widget 2025-03-10 15:01:58 -06:00
b1a0bdfbb6 Merge branch 'develop' 2025-03-07 12:02:47 -07:00
6d38bc5e1d Merge branch 'main' 2025-03-07 12:02:19 -07:00
5ba1ba15c9 ci: Update to the most recent Rust version 2025-03-07 11:55:32 -07:00
db05d2abfb Merge branch 'develop' into custom-themes 2025-03-07 10:37:48 -07:00
1840c4e39a Merge branch 'main' into develop
# Conflicts:
#	proc_macros/enum_display_style_derive/src/lib.rs
2025-03-07 10:37:23 -07:00
c5a3f424d6 refactor: Reformatted code to make the format checks pass 2025-03-07 10:36:40 -07:00
04aa6b81b5 Merge branch 'develop' into custom-themes 2025-03-07 10:35:07 -07:00
5ff3b9b996 Merge branch 'main' into develop 2025-03-07 10:34:16 -07:00
228e4a61a4 fix: Updated ring dependency to mitigate CWE-770 2025-03-07 10:33:57 -07:00
df38ea5413 feat: Write built in themes to the themes file on first run so users can define custom themes 2025-03-06 17:44:52 -07:00
709f6ca6ca test: Added integration tests for the ValidateTheme macro 2025-03-06 16:00:50 -07:00
b012fc29e4 Merge branch 'develop' into custom-themes
# Conflicts:
#	Cargo.toml
2025-03-06 15:35:05 -07:00
bdad723aef refactor: Formatted files using rustfmt 2025-03-06 15:32:59 -07:00
f97d46cec3 refactor: Created a derive macro for defining the display style of Enum models and removed the use of the EnumDisplayStyle trait 2025-03-06 15:29:30 -07:00
7381eaef57 refactor: Expanded the serde_enum_from macro to further reduce code duplication 2025-03-05 15:09:51 -07:00
72c922b311 feat: Created a theme validation macro to verify theme configurations before allowing the TUI to start 2025-03-05 14:37:34 -07:00
Alex Clarke
fd14a8152c fix: change the name of the theme configuration file to 'themes' 2025-03-04 18:29:21 -07:00
5cb60c317d feat: Initial support for custom user-defined themes 2025-03-04 18:09:09 -07:00
847de75713 fix: Modified the Sonarr DownloadRecord so that the episode_id is optional to prevent crashes for weird downloads 2025-03-01 14:50:20 -07:00
58723cf3e8 ci: Ensure the docker release is fully up-to-date 2025-02-28 21:45:05 -07:00
c613168bfb docs: Updated the CHANGELOG accordingly 2025-02-28 21:26:13 -07:00
github-actions[bot]
6f83de77f2 chore: Bump the version in Cargo.lock 2025-03-01 03:30:05 +00:00
github-actions[bot]
3f6ef3beb4 bump: version 0.5.0 → 0.5.1 [skip ci] 2025-03-01 03:30:04 +00:00
14e50c1465 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-02-28 20:29:29 -07:00
0aa9fdca14 ci: Overwrite previous artifact uploads for proper releases 2025-02-28 20:29:15 -07:00
github-actions[bot]
dc50820abc chore: Bump the version in Cargo.lock 2025-03-01 03:08:01 +00:00
github-actions[bot]
81afce78ad bump: version 0.4.2 → 0.5.0 [skip ci] 2025-03-01 03:07:57 +00:00
8a0b912601 ci: Updated the release flow to use the newer upload/download artifact actions 2025-02-28 20:06:41 -07:00
85105a953e docs: Pre-Release update of versions and added link to the Matrix Space. 2025-02-28 16:45:16 -07:00
40f3452d08 ci: Removed the minimal-versions check 2025-02-27 20:51:50 -07:00
a287a5c903 docs: Updated the README to also include details on the new CLI flags 2025-02-27 20:51:00 -07:00
f30e5270d8 refactor: Updated dependencies 2025-02-27 20:45:32 -07:00
104bcd7bb2 refactor: Addressed Cargo fmt complaints 2025-02-27 20:42:32 -07:00
fd6fcfc98f feat: CLI Support for multiple Servarr instances 2025-02-27 20:37:03 -07:00
f87e02cd7c test: Added in unit tests for TUI support for multiple custom named Servarrs 2025-02-27 19:30:17 -07:00
9b63b10118 feat: Support for multiple servarr definitions - no tests [skip ci] 2025-02-27 18:00:28 -07:00
111485e7c4 feat: Support for loading Servarr API tokens from a file 2025-02-27 16:53:29 -07:00
Alex Clarke
0167753cfe Merge pull request #30 from tangowithfoxtrot/var-interpolation
feat: environment variable interpolation in the managarr config file
2025-02-19 17:52:23 -07:00
Alex Clarke
73131cc518 Merge branch 'main' into var-interpolation 2025-02-19 17:40:16 -07:00
25576757bb ci: Updated codecov config to consider patches as well to hopefully fix PR issues [skip ci] 2025-02-19 17:40:03 -07:00
105c8f3a82 test: Hopefully the final environment variable name fix to correct all race conditions with parallel tests 2025-02-19 17:27:56 -07:00
5164d81492 test: Fix a potential race condition happening with parallel tests 2025-02-19 15:59:31 -07:00
319e5f1ac2 test: Added remaining unit tests for the deserialize_optional_env_var deserialization functions 2025-02-19 15:44:55 -07:00
Alex Clarke
b24c2fbeb1 Merge branch 'main' into var-interpolation 2025-02-19 15:14:41 -07:00
bc5053c39c fix: Updated openssl to 0.10.70 to mitigate CVE-2025-24898 2025-02-03 16:06:47 -07:00
tangowithfoxtrot
f06a031c93 Merge branch 'main' into var-interpolation 2025-01-26 14:40:14 -08:00
tangowithfoxtrot
8d450dea5a Merge branch 'main' into var-interpolation 2025-01-26 14:36:45 -08:00
c4ace8c53f feat: Tweaked the implementation for environment variables in the config a bit 2025-01-26 14:59:09 -07:00
78f104f558 refactor: Added a debug line for logging to output the config used when starting Managarr 2025-01-26 14:56:37 -07:00
e8a6f740b9 refactor: Updated the 2018 idiom lint to the 2021_compatibility lint 2025-01-26 14:47:40 -07:00
tangowithfoxtrot
6f3c6ec840 feat: var interpolation 2025-01-26 09:28:47 -08:00
Alex Clarke
47a3ef1d8b Merge pull request #29 from Dark-Alex-17/license-update
Updated license and attribution requirements
2025-01-21 15:47:00 -07:00
Alex Clarke
367e9bf33b Added attribution guidelines to the CONTRIBUTING
Added attribution guidelines to the CONTRIBUTING file so the license is easier to understand
2025-01-21 15:10:01 -07:00
Alex Clarke
f122b02424 Do not require attributions for forks
Update the LICENSE to not require an attribution for forks that merge back into the main/aren't distributed as separate projects
2025-01-21 14:58:35 -07:00
Alex Clarke
3a09c17f0a Update LICENSE
Made the attribution wording more flexible and less legal in nature.
2025-01-21 14:53:30 -07:00
Alex Clarke
6773abb04e Update LICENSE due to scammer abuse
Updated the LICENSE to incorporate an attribution clause, and prohibit commercial use. 

This was done in response to a recently discovered abuse of some scammers using both my GitHub and this project in job applications to scam other companies and job applicants, offering to "adapt the project to the needs of their internal tools". 

Previously, this would have been fine. However, with scams spiking recently and myself struggling to find a job, this kind of abuse is, regrettably, something I must limit.
2025-01-21 13:14:15 -07:00
b757d66d7a fix: Addressed rustfmt complaints 2025-01-18 15:33:56 -07:00
81cb7a750c refactor: Removed unnecessary clones in the networking module to speed up network request handling 2025-01-18 15:23:03 -07:00
3be59108a9 refactor: Corrected some clone instead of copy behaviors in the command line handlers 2025-01-18 14:54:25 -07:00
fac9c45aee refactor: Removed unnecessary clone from stateful table 2025-01-18 14:24:23 -07:00
184bd2b510 refactor: Removed unnecessary clone call from extract_and_add_tag_ids_vec method 2025-01-18 14:15:52 -07:00
fda69178b9 refactor: Reduced the number of clones necessary when building modal structs 2025-01-18 13:56:18 -07:00
652bbcd5d4 refactor: Refactored a handful of Option calls to use take instead 2025-01-18 13:00:21 -07:00
fd35106df8 refactor: Renamed KeyEventHandler::with to KeyEventHandler::new to keep with Rust best practices and conventions 2025-01-18 12:43:25 -07:00
Alex Clarke
5ead5bc3d6 docs: removed the Unraid section of the README now that the issue has been corrected and fixed. 2024-12-31 16:55:39 -06:00
3ce0003315 docs: Added installation instructions for Nix and a note for Unraid users until the template is corrected by the maintainer 2024-12-30 11:25:15 -07:00
Alex Clarke
ee94059a15 fix: Corrected typo in the managarr.nuspec.template 2024-12-21 21:26:38 -07:00
844742053d ci: Finalized corrected release workflow [skip ci] 2024-12-21 16:52:29 -07:00
github-actions[bot]
7ed9cfa018 chore: Bump the version in Cargo.lock 2024-12-21 23:48:07 +00:00
github-actions[bot]
71791afca0 bump: version 0.4.1 → 0.4.2 [skip ci] 2024-12-21 23:48:04 +00:00
a52eddfdac ci: Reverted failed release [skip ci] 2024-12-21 16:47:31 -07:00
0474978ac0 ci: Fixed final typo I hope [skip ci] 2024-12-21 16:46:16 -07:00
4b94a0ce2a ci: Fixed typo in release flow [skip ci] 2024-12-21 16:44:48 -07:00
0d1eac7610 ci: Final test of corrected release flow for GitHub [skip ci] 2024-12-21 16:43:04 -07:00
b129e5d5a4 Merge branch 'main' of github.com:Dark-Alex-17/managarr 2024-12-21 14:59:58 -07:00
9f5c22890d ci: Configure release workflow to only release docker now [skip ci] 2024-12-21 14:58:54 -07:00
github-actions[bot]
74f4a19003 chore: Bump the version in Cargo.lock 2024-12-21 21:57:10 +00:00
github-actions[bot]
717d9872dc bump: version 0.4.1 → 0.4.2 [skip ci] 2024-12-21 21:57:08 +00:00
439270fe2d ci: Revert failed release and add fix to (hopefully) finally fix the GitHub release [skip ci] 2024-12-21 14:37:18 -07:00
github-actions[bot]
601fd55435 chore: Bump the version in Cargo.lock 2024-12-21 21:34:35 +00:00
github-actions[bot]
341c5254f1 bump: version 0.4.1 → 0.4.2 [skip ci] 2024-12-21 21:34:33 +00:00
cb2701ada4 ci: Fixed a typo in the Managarr docker release. Still in release testing mode [skip ci] 2024-12-21 14:04:54 -07:00
28fcccce98 Reverted failed release once again... 2024-12-21 13:51:01 -07:00
2ba3e56772 docs: Removed unnecessary revert commit mention in the CHANGELOG 2024-12-19 21:51:02 -07:00
github-actions[bot]
f3fa3401f1 chore: Bump the version in Cargo.lock 2024-12-20 04:19:08 +00:00
github-actions[bot]
820f339982 bump: version 0.4.1 → 0.4.2 [skip ci] 2024-12-20 04:19:04 +00:00
afd9dd34a7 fix: Revert failed release [skip ci] 2024-12-19 20:37:32 -07:00
4a4e5d2cf4 Merge remote-tracking branch 'refs/remotes/origin/main' 2024-12-19 20:34:54 -07:00
029b00532e ci: Fix a typo in the download-artifacts version [skip ci] 2024-12-19 20:34:41 -07:00
github-actions[bot]
0b052684c2 chore: Bump the version in Cargo.lock 2024-12-20 03:33:06 +00:00
github-actions[bot]
3dda80b50f bump: version 0.4.1 → 0.4.2 [skip ci] 2024-12-20 03:33:03 +00:00
9dede1e45d ci: Removed the now defunct Scoop testing workflow [skip ci] 2024-12-19 20:13:04 -07:00
6dc64c6edf docs: Updated README with Mac and Linux installation steps with Homebrew [skip ci] 2024-12-19 19:56:27 -07:00
c21748df1e ci: Deleted the test homebrew release workflow and updated the main release workflow to have the homebrew release [skip ci] 2024-12-19 19:52:38 -07:00
27d024cca2 ci: Set username and email globally [skip ci] 2024-12-19 19:48:19 -07:00
b0dd2575d9 ci: Typo in clone URL [skip ci] 2024-12-19 19:47:30 -07:00
7a004c2cfc ci: Test using the token directly in the clone [skip ci] 2024-12-19 19:46:32 -07:00
53ab164379 ci: Changed username [skip ci] 2024-12-19 19:45:00 -07:00
bf4fb3a55d ci: Fixed a typo in the homebrew test [skip ci] 2024-12-19 19:43:39 -07:00
2235f1d0d9 ci: Created a token for all Managarr repos [skip ci] 2024-12-19 19:42:13 -07:00
17dcb2b10b ci: Removed the use of the deploy key to force git to use SSH [skip ci] 2024-12-19 19:26:13 -07:00
4c7c62eb0b ci: Changed to cloning the homebrew repo with SSH [skip ci] 2024-12-19 19:25:09 -07:00
81dcf4b003 ci: Updated the username and email to be that of my personal user [skip ci] 2024-12-19 19:22:45 -07:00
3fffdd20a8 ci: Set username and email globally [skip ci] 2024-12-19 19:20:37 -07:00
078d75473b ci: Update the test homebrew release [skip ci] 2024-12-19 19:18:35 -07:00
8b7ff58c7d ci: Created a test homebrew release workflow [skip ci] 2024-12-19 19:15:59 -07:00
ff67eebcea ci: Removed scoop workflow [skip ci] 2024-12-19 18:25:05 -07:00
github-actions[bot]
e585eb46ea chore: Update Scoop bucket for version 0.4.1 [skip ci] 2024-12-20 01:10:06 +00:00
01507f88da ci: Corrected the use of the SSH key for pushing to the repo [skip ci] 2024-12-19 18:08:43 -07:00
6344b6dba2 ci: Fixing the push step [skip ci] 2024-12-19 18:04:07 -07:00
d3042cf724 ci: Corrected the packager script path [skip ci] 2024-12-19 17:58:00 -07:00
fd4b9a4f15 ci: Updated a typo in the scoop test workflow config [skip ci] 2024-12-19 17:55:58 -07:00
2879a2aa67 ci: Corrected the scoop workflow to publish changes to repo first, and then to publish them to Scoop [skip ci] 2024-12-19 17:53:16 -07:00
5cd8cb66f1 ci: Fix erroring out when removing the scoop bucket if files don't exist [skip ci] 2024-12-19 17:40:11 -07:00
47c206ca1d ci: Created the test scoop deployment workflow [skip ci] 2024-12-19 17:34:47 -07:00
8f22b64a0a ci: Moved the package.py script into the main deployment directory for reuse, and also updated the Chocolatey workflow to push the generated Chocolatey files to the repo for posterity [skip ci] 2024-12-19 17:23:27 -07:00
7941b3d16c docs: Updated the README to include steps on how to install Managarr in Windows using Chocolatey [skip ci] 2024-12-19 16:55:06 -07:00
e9ded4bde4 ci: Delete the test chocolatey deploy and add the official chocolatey deploy to the release workflow [skip ci] 2024-12-19 16:50:58 -07:00
7b02472f67 ci: Fix the test workflow to use the first release that actually has a sha256 for Windows attached to it [skip ci] 2024-12-19 16:46:16 -07:00
a370d67121 ci: Attempting to explicitly set the output encoding to UTF-8 for Windows [skip ci] 2024-12-19 16:41:56 -07:00
4f6e64083a ci: Explicitly set the encoding as UTF-8 in the python script since that is not the windows default encoding [skip ci] 2024-12-19 16:36:56 -07:00
276a672df4 ci: Try refreshing the environment after installing managarr choco from local [skip ci] 2024-12-19 16:31:44 -07:00
e55a157977 ci: Fixed a typo in the chocolatey deployment PowerShell template [skip ci] 2024-12-19 16:26:28 -07:00
e1e91d1add ci: Testing fixes in the test chocolatey deployment workflow [skip ci] 2024-12-19 16:25:04 -07:00
8102b5933f ci: Mock the artifact creation for the test workflow [skip ci] 2024-12-19 16:19:43 -07:00
bc60128679 ci: Fixed a typo in the test chocolatey deployment workflow [skip ci] 2024-12-19 16:11:41 -07:00
fc3a7ab789 ci: Created test GHA workflow to rest releasing Managarr to Chocolatey [skip ci] 2024-12-19 16:10:38 -07:00
93d3e6cec7 docs: Fixed working in the README about the new managarr-demo.alexjclarke.com site 2024-12-18 18:22:42 -07:00
95779f1ac2 docs: Updated the README to point to the new managarr-demo.alexjclarke.com site for testing out the TUI as an alternative to running the command 2024-12-18 18:20:36 -07:00
7bc57d7696 ci: Remove the test multi-platform docker test job [skip ci] 2024-12-18 18:12:38 -07:00
7e7b75f378 ci: Correct another typo in the test multi-platform job [skip ci] 2024-12-18 17:57:31 -07:00
a958350b2d ci: Fix typo in multi-platform release test [skip ci] 2024-12-18 17:56:41 -07:00
c502b3d08f ci: Attempting multi-platform builds for docker [skip ci] 2024-12-18 17:54:37 -07:00
Alex Clarke
e602b66188 Merge pull request #27 from Dark-Alex-17/race-condition-refactor
Race condition refactor
2024-12-18 17:38:13 -07:00
12fba15bcf style: Clean up all remaining unused test helper functions 2024-12-18 01:44:27 -07:00
7e36ad4e8a fix(sonarr): Pass the series ID alongside all UpdateAndScan events when publishing to the networking channel 2024-12-18 01:40:47 -07:00
33249f509f fix(sonarr): pass the series ID alongside all TriggerAutomaticSeriesSearch events when publishing to the networking channel 2024-12-18 01:38:05 -07:00
ed645dd0d5 fix(sonarr): Pass the series ID and season number alongside all TriggerAutomaticSeasonSearch events when publishing to the networking channel 2024-12-18 01:34:45 -07:00
b12c635c27 fix(sonarr): Pass the episode ID alongside all TriggerAutomaticEpisodeSearch events when publishing to the networking channel 2024-12-18 01:29:30 -07:00
c16ecfb188 fix(sonarr): Pass the episode ID alongside all ToggleEpisodeMonitoring events when publishing to the networking channel 2024-12-18 01:22:28 -07:00
18a8b81631 fix(sonarr): Pass the series ID and season number alongside all toggle season monitoring events when publishing to the networking channel 2024-12-18 01:12:32 -07:00
1d404d4d2c fix(sonarr): Pass the indexer ID directly alongside all TestIndexer events when publishing to the networking channel 2024-12-18 01:01:01 -07:00
42479ced21 fix(sonarr): Provide the task name directly alongside all StartTask events when publishing to the networking channel 2024-12-18 00:56:21 -07:00
1193b8c848 fix(sonarr): Pass the search query directly to the networking channel when searching for a new series 2024-12-18 00:49:36 -07:00
ec8d748991 fix(sonarr): Pass the series ID alongside all GetSeriesHistory events when publishing to the networking channel 2024-12-18 00:39:50 -07:00
bafaf7ca7a fix(sonarr): Pass the series ID alongside all GetSeriesDetails events when publishing to the networking channel 2024-12-18 00:37:06 -07:00
f7315a3bec fix(sonarr): Pass series ID and season number alongside all ManualSeasonSearch events when publishing to the networking channel 2024-12-18 00:32:36 -07:00
f655ca989d fix(sonarr): Provide the series ID and season number alongside all GetSeasonHistory events when publishing to the networking channel 2024-12-18 00:22:24 -07:00
fcb87a6779 fix(sonarr): Pass the episode ID alongside all ManualEpisodeSearch events when publishing to the networking channel 2024-12-18 00:12:18 -07:00
924f8d5eff fix(sonarr): Pass events alongside all GetLogs events when publishing to the networking channel 2024-12-18 00:07:59 -07:00
64ecc38073 fix(sonarr): Pass the episode ID alongside all GetEpisodeHistory events when publishing to the networking channel 2024-12-18 00:05:22 -07:00
5f94dbcabe fix(sonarr): Pass series ID alongside all GetEpisodeFiles events when publishing to the networking channel 2024-12-17 23:59:49 -07:00
2ecc591966 fix(sonarr): Pass series ID alognside all GetEpisodes events when publishing to the networking channel 2024-12-17 23:57:13 -07:00
30ba1f3317 fix(sonarr): Pass the episode ID alongside all GetEpisodeDetails events when publishing to the networking channel 2024-12-17 23:52:18 -07:00
4fdf9b3df1 fix(sonarr): Pass history events alongside all GetHistory events when publishing to the networking channel 2024-12-17 23:40:23 -07:00
22fe1a8f73 fix(sonarr): Construct and pass edit series parameters alongside all EditSeries events when publishing to the networking channel 2024-12-17 23:37:18 -07:00
38c0ad29dd fix(sonarr): Construct and pass edit indexer parameters alongside all EditIndexer events when publishing to the networking channel 2024-12-17 23:22:56 -07:00
89d106c03e fix(sonarr): Construct and pass edit all indexer settings alongside all EditAllIndexerSettings events when publishing to the networking channel 2024-12-17 23:05:29 -07:00
3e36bcf307 fix(sonarr): Construct and pass delete series params alongside all DeleteSeries events when publishing to the networking channel 2024-12-17 22:56:14 -07:00
acf983c07c fix(sonarr): Corrected a bug that would cause a crash if a user spams the ESC key while searching for a new series and the search results are still loading 2024-12-17 22:45:34 -07:00
fedb78fb88 fix(sonarr): Pass the root folder ID alongside all DeleteRootFolder events when publishing to the networking channel 2024-12-17 22:42:37 -07:00
db64a0968b fix(sonarr): Pass the indexer ID alongside all DeleteIndexer events when publishing to the networking channel 2024-12-17 22:37:50 -07:00
aece20af47 fix(sonarr): Pass the episode file ID alongside all DeleteEpisodeFile events when publishing to the networking channel 2024-12-17 22:33:10 -07:00
6c5a73f78f fix(sonarr): Pass the download ID alongside all DeleteDownload events published to the networking channel 2024-12-17 22:27:08 -07:00
906e093152 fix(sonarr): Pass the blocklist item ID alongside the DeleteBlocklistItem event when publishing to the networking channel 2024-12-17 22:22:32 -07:00
478b4ae3c0 fix(sonarr): Construct and pass the add series body alongside AddSeries events when publishing to the networking channel 2024-12-17 22:16:43 -07:00
23971cbb76 fix(sonarr): Construct and pass the AddRootFolderBody alongside all AddRootFolder events when publishing to the networking channel 2024-12-17 21:48:52 -07:00
43410fac60 fix(radarr): Pass the movie ID alongside all UpdateAndScan events published to the networking channel 2024-12-17 21:34:14 -07:00
cb8035a2ce fix(radarr): Provide the movie ID alongside all TriggerAutomaticMovieSearch events when publishing to the networking channel 2024-12-17 21:26:34 -07:00
8d071c7674 fix(radarr): Pass in the indexer id with all TestIndexer events when publishing to the networking channel 2024-12-17 21:21:23 -07:00
965c488468 fix(radarr): Pass in the task name alongside the StartTask event when publishing to the networking channel 2024-12-17 21:13:47 -07:00
ede7f64c4b fix(radarr): Pass in the search query for the SearchNewMovie event when publishing to the networking channel 2024-12-17 21:06:07 -07:00
ba38dcdc15 fix(radarr): Pass in the movie ID alongside the GetReleases event when publishing to the networking channel 2024-12-17 20:52:31 -07:00
92d9222b05 fix(radarr): Pass in the movie ID alongside the GetMovieHistory event when publishing to the networking channel 2024-12-17 20:50:00 -07:00
4c396c3442 fix(radarr): Pass the movie ID in alongside the GetMovieDetaisl event when publishing to the networking channel 2024-12-17 20:47:29 -07:00
e1d5139e36 fix(radarr): Provide the movie id alongside the GetMovieCredits event when publishing to the networking channel 2024-12-17 20:42:52 -07:00
1ad35652f8 fix(radarr): Pass the number of log events to fetch in with the GetLogs event when publishing to the networking channel 2024-12-17 20:33:39 -07:00
9a9b13d604 fix(radarr): Construct and pass the edit movie parameters alongside the EditMovie event when publishing to the networking channel 2024-12-17 17:50:07 -07:00
77b8b61079 fix(radarr): Construct and pass params when publishing the EditIndexer event to the networking channel 2024-12-17 17:29:21 -07:00
bdf48d1bf4 fix(radarr): Construct and pass edit collection parameters alongside the EditCollection event when publishing to the networking channel 2024-12-17 16:32:35 -07:00
f8792ea012 fix(radarr): Build and pass the edit indexer settings body with the EditAllIndexerSettings event when publishing to the networking channel 2024-12-17 16:10:11 -07:00
4afde8b750 fix(radarr): Send the parameters alongside the DownloadRelease event when publishing to the networking channel 2024-12-17 15:56:58 -07:00
f5614995c7 fix(radarr): Pass the root folder ID in with the DeleteRootFolder event when publishing to the networking channel 2024-12-17 15:41:28 -07:00
9ea6dbec20 fix: Pass the delete movie params in with the DeleteMovie event when publishing to the networking channel 2024-12-17 15:35:29 -07:00
d73dfb9fc7 fix: Pass the indexer ID in with the DeleteIndexer event when sending to the networking channel 2024-12-17 15:21:34 -07:00
a7da73300c fix: Pass the download ID directly in the DeleteDownload event when publishing into the networking channel 2024-12-17 15:14:17 -07:00
a308b8fe95 fix: Blocklist Item ID passed in the DeleteBlocklistItem event when sent to the networking channel 2024-12-17 15:03:06 -07:00
1d1e42aeb1 fix: AddRootFolderBody now constructed prior to AddRootFolder event being sent down the network channel 2024-12-17 14:53:40 -07:00
1f81061152 Merge remote-tracking branch 'origin/main' into race-condition-refactor 2024-12-17 14:37:46 -07:00
368d5d3db7 fix: Cancel all requests when switching Servarr tabs to both improve performance and fix issue #15 2024-12-17 14:36:49 -07:00
0612a02d68 fix(add_movie_handler_tests): Added in a forgotten test for the build_add_movie_body function 2024-12-17 14:19:12 -07:00
a0d470087b Merge remote-tracking branch 'origin/main' into race-condition-refactor 2024-12-17 14:11:12 -07:00
3ecaf04eb4 fix: Missing tagged version of docker builds in release flow 2024-12-17 12:40:13 -07:00
df0811985d Fixed a bug in the release flow that published the docker image before the version bump 2024-12-17 12:15:36 -07:00
057ff0fef1 Fixed a bug in the release pipeline that created a conflict between the tag and the actual code 2024-12-16 21:09:24 -07:00
14c46f88ab fix: AddMovie Radarr event is now populated in the dispatch thread before being sent to the network thread 2024-12-16 15:31:26 -07:00
e38e430c77 fix: dynamically load servarrs in UI based on what configs are provided 2024-12-16 14:16:01 -07:00
Alex Clarke
93cd235aef docs: Update README.md to reference the Wekan board and not mention tracking the Beta release since it's live now 2024-12-14 02:26:38 -07:00
github-actions[bot]
df9bba32cb chore: Bump the version in Cargo.lock 2024-12-14 08:07:55 +00:00
github-actions[bot]
28a8f9b2fa bump: version 0.4.0 → 0.4.1 [skip ci] 2024-12-14 08:07:52 +00:00
e49b366d77 docs: Removed the docker version from the README [skip ci] 2024-12-14 00:46:35 -07:00
9a0963ca2c Merge remote-tracking branch 'origin/main' 2024-12-14 00:41:45 -07:00
17737a06a4 ci: Add the tar.gz files to the artifacts [skip ci] 2024-12-14 00:41:26 -07:00
github-actions[bot]
464779cc17 chore: Bump the version in Cargo.lock 2024-12-14 07:35:49 +00:00
github-actions[bot]
f25c0889a3 bump: version 0.3.7 → 0.4.0 [skip ci] 2024-12-14 07:35:46 +00:00
90170cb3d5 ci: Fixed a typo in the github-release job [skip ci] 2024-12-14 00:14:52 -07:00
4dcb141f3a ci: Fixed a typo in the docker release [skip ci] 2024-12-13 23:41:01 -07:00
133721917f ci: Attempting a different artifact job version to see if it corrects the error [skip ci] 2024-12-13 23:19:07 -07:00
766e23d265 ci: Use the same version of upload/download-artifact action [skip ci] 2024-12-13 23:06:36 -07:00
77d8e84e14 ci: Correct the artifact paths for the release [skip ci] 2024-12-13 22:42:42 -07:00
00c1cca412 ci: Fix the artifacts directory creation for Windows binaries [skip ci] 2024-12-13 22:24:03 -07:00
4968833d05 ci: Attempting a different way of creating the artifacts directory [skip ci] 2024-12-13 22:05:22 -07:00
d172fa17f6 ci: Attempting to fix the artifacts directory [skip ci] 2024-12-13 21:58:30 -07:00
3c99b38db7 ci: Support for arm64 docker builds 2024-12-13 21:40:53 -07:00
Alex Clarke
1128937cac Merge pull request #21 from Dark-Alex-17/sonarr-tui-support
Sonarr TUI support
2024-12-13 21:09:53 -07:00
6fc1228173 docs: Updated the README to include OS-specific steps for running Managarr on different platforms 2024-12-13 21:04:10 -07:00
b48a2efb7d fix(blocklist_handler): Fixed a breaking change between Sonarr v3 and v4 2024-12-13 20:48:10 -07:00
412cb2408e fix(style): Addressed linter complaints on formatting 2024-12-13 19:48:22 -07:00
682bc91855 fix: Implemented a handful of fixes that are breaking changes between Sonarr v3 and v4 2024-12-13 19:44:10 -07:00
f03120e5a1 ci: Updated CI to cross compile for a handful of additional architectures to increase availability 2024-12-13 17:58:59 -07:00
8dd63b30e8 feat(docs): Updated the README with new screeshots for the Sonarr release 2024-12-13 16:28:42 -07:00
54006c378f feat(handler): Support for toggling the monitoring status of a specified episode in the Sonarr UI 2024-12-13 16:18:02 -07:00
9269b66aa8 feat(handlers): Support for toggling the monitoring status of a season in the Sonarr UI 2024-12-13 16:10:06 -07:00
cfac433861 feat(keybindings): Added a new keybinding for toggling the monitoring of a highlighted table item 2024-12-13 14:53:39 -07:00
a28f8c3dd2 feat(cli): Support for toggling monitoring on a specific episode in Sonarr 2024-12-13 14:49:00 -07:00
4001dee1bd refactor(network): Changed the toggle episode monitoring handler to simply return empty since the response is always empty from Sonarr 2024-12-13 14:45:06 -07:00
d1ffd0d77f feat(network): Support for toggling the monitoring status of an episode in Sonarr 2024-12-13 14:40:11 -07:00
91ad50350d feat(cli): Support for toggling monitoring for a specific season in Sonarr 2024-12-13 14:09:11 -07:00
a88d43807e feat(network): Support for toggling monitoring/unmonitoring a season 2024-12-13 13:59:02 -07:00
98619664cf refactor(ui): Tweaked some of the color schemes in the series table 2024-12-13 13:10:57 -07:00
39f8ad2106 refactor: Fixed a couple of typos in some test function names 2024-12-13 11:51:23 -07:00
82ce38d7b5 feat(handlers): Support for the episode details popup 2024-12-12 18:52:27 -07:00
12eb453fc7 feat(ui): Support for the episode details UI 2024-12-12 16:25:02 -07:00
a84324d3bc feat(handler): Full handler support for the Season details UI in Sonarr 2024-12-11 23:18:37 -07:00
ed2211586e refactor(handlers): Refactored the handlers to all use the handle_table_events macro when appropriate and created tests for the macro so tests don't have to be duplicated across each handler 2024-12-11 17:03:52 -07:00
c09950d0af refactor(ui): Simplified the popup delegation so all future UI is easier to implement 2024-12-11 15:08:52 -07:00
e9a30382a3 feat(ui): Sonarr support for viewing season details 2024-12-10 18:23:09 -07:00
7bf3311102 feat(cli): Sonarr support for fetching a list of all episode files for a given series ID 2024-12-10 16:32:35 -07:00
cbad40245f feat(app): Dispatch support for Season Details to fetch both the current downloads as well as the episode files to match qualities to them 2024-12-10 16:23:30 -07:00
75c4fcbb9e feat(network): Support for fetching all episode files for a given series 2024-12-10 16:22:02 -07:00
f3b7f155b7 feat(app): Model and modal support for the season and episode details popups 2024-12-09 15:15:09 -07:00
6427a80bd1 feat(cli): Sonarr support for fetching season history events 2024-12-09 14:30:07 -07:00
5b65e87225 feat(network): Sonarr support for fetching season history 2024-12-09 14:15:47 -07:00
1b8b19fde5 refactor(indexers_handler): Use the new handle_table_events macro 2024-12-08 14:42:18 -07:00
03d7aed258 refactor(root_folders_handler): Use the new handle_table_events macro 2024-12-08 14:38:26 -07:00
23d149093f refactor(blocklist_handler): Use the new handle_table_events macro 2024-12-08 14:34:47 -07:00
27f12716d9 refactor(downloads_handler): Use the new handle_table_events macro 2024-12-08 14:28:12 -07:00
048877bbb6 refactor(collection_details_handler): use the new handle_table_events macro 2024-12-08 14:22:59 -07:00
87a652d911 refactor(collections_handler): Use the new handle_table_events macro 2024-12-08 14:14:24 -07:00
d6863dc1fd refactor(movie_details_handler): Use the new handle_table_events macro 2024-12-08 14:04:34 -07:00
f1d934b0a6 refactor(library_handler): Radarr use the new handle_table_events macro 2024-12-08 13:43:01 -07:00
5850f7a621 refactor(indexers_handler): Use the new handle_table_events macro 2024-12-08 13:26:59 -07:00
dd23e84ccf refactor(indexers_handler): Use the new handle_table_events macro 2024-12-08 13:24:18 -07:00
b060518778 refactor(root_folder_handler): Use the new handle_table_events macro 2024-12-08 13:15:59 -07:00
de95f13feb fix(handler_tests): Fixed all delegation tests to have initial conditions set properly 2024-12-08 13:10:17 -07:00
0205f13e53 refactor(history_handler): Use the new handle_table_event macro 2024-12-08 13:08:43 -07:00
b4de97dfe2 refactor(blocklist_handler): Use the new handle_table_events macro 2024-12-08 12:39:07 -07:00
35bc6cf31c refactor(downloads_handler): Use the new handle_table_events macro 2024-12-08 12:35:12 -07:00
c58e8b1a00 refactor(series_details_handler): Use the new handle_table_events macro 2024-12-08 12:29:59 -07:00
accdf99503 fix(ui): Fixed a bug that requires a minimum height for all popups so all error messages and other simple popups appear 2024-12-07 19:36:43 -07:00
47b609369b refactor(handler): Created a macro to handle all table key events to reduce code duplication and make future implementations faster; Only refactored the Sonarr library to use it thus far 2024-12-07 19:20:13 -07:00
23b1ca4371 feat(ui): Sonarr support for the series details popup 2024-12-06 20:30:26 -07:00
73d666d1f5 feat(ui): Sonarr support for editing a series from within the series details popup 2024-12-05 19:11:54 -07:00
b27c13cf74 fix(handler): Fixed a bug in the history handler that wouldn't reset the filter or search if a user hit 'esc' on the History tab 2024-12-05 19:08:11 -07:00
bd1a4f0939 feat(ui): Sonarr Series details UI is now available 2024-12-05 19:07:45 -07:00
5abed23cf2 refactor(ui): all table search and filter functionality is now available directly through the ManagarrTable widget to make life easier moving forward 2024-12-05 19:07:03 -07:00
9d0948e124 refactor(keys): Created a auto search key instead of reusing the existing search key to make things easier 2024-12-05 12:29:09 -07:00
678bc77a23 fix(ui): Fix the System Details Tasks popup to be navigable in both Sonarr and Radarr 2024-12-05 11:45:46 -07:00
00cdeee5c6 feat(ui): Full Sonarr system tab support
Signed-off-by: Alex Clarke <alex.j.tusa@gmail.com>
2024-12-04 17:41:30 -07:00
2d251554ad feat(handler): System handler support for Sonarr 2024-12-04 17:04:36 -07:00
1b5d70ae2d perf: Improved performance by optimizing API calls to only refresh when the tick prompts a refresh. All UI is now significantly faster 2024-12-04 16:46:06 -07:00
2d2901f6dc feat(ui): Full Sonarr support for the indexer tab 2024-12-04 16:39:37 -07:00
a0b27ec105 feat(ui): Support for modifying the indexer priority in Radarr 2024-12-03 18:12:23 -07:00
093ef136e7 feat(handler): Full indexer tab handler support 2024-12-03 17:46:37 -07:00
8660de530d feat(ui): Root folder tab support 2024-12-03 16:24:23 -07:00
bda6f253e0 feat(handlers): Support for root folder actions 2024-12-03 16:18:39 -07:00
4eb974567f feat(ui): History tab support 2024-12-02 18:47:50 -07:00
4f5bad5874 feat(handler): History tab support 2024-12-02 18:03:59 -07:00
1c6e798632 feat(ui): Blocklist UI support 2024-12-02 16:54:27 -07:00
3186fb42e7 feat(handler): Wired in the blocklist handler to the main handlers 2024-12-02 16:39:40 -07:00
4b7185fbb0 feat(handler): Blocklist handler support 2024-12-02 16:37:46 -07:00
f0d8555a8a feat(ui): Downloads tab support 2024-12-02 15:57:48 -07:00
f338dfcb12 feat(handler): Download tab support 2024-12-02 15:40:11 -07:00
188d781b0d feat(ui): Edit series support 2024-12-02 15:31:12 -07:00
adb1f07fd0 feat(handler): Edit series support 2024-12-02 14:58:51 -07:00
82e51be096 feat(ui): Add series support Sonarr 2024-12-02 13:53:28 -07:00
d7f6d12f59 feat(handler): Add series support for Sonarr 2024-12-02 12:43:17 -07:00
0db57fbff1 feat(ui): Delete a series 2024-12-02 11:45:13 -07:00
b1bdc19afb feat(handler): Support for deleting a series in Sonarr 2024-12-02 11:30:34 -07:00
b75a95a708 feat(ui): Support for the Series table 2024-12-01 14:08:06 -07:00
c3fb5dcd5f feat(handlers): Sonarr key support for the Series table 2024-12-01 13:48:48 -07:00
21911f93d1 feat(models): Added the necessary contextual help and tabs for the Sonarr UI 2024-12-01 12:05:20 -07:00
f7c96d81e9 refactor(BlockSelectionState): Refactored so selection of blocks in 2x2 grids is more intuitive and added left() and right() methods to aid this effort. 2024-11-30 12:22:46 -07:00
9b2040059d fix(ui): Fixed a potential rare bug in the UI where the application would panic if the height of the downloads window is 0. 2024-11-29 16:31:51 -07:00
08f190fc6e feat(ui): Initial UI support for switching to Sonarr tabs 2024-11-29 15:58:19 -07:00
4d1b0fe301 docs(context): Updated the Servarr context clues to say how to switch Servarr tabs via TAB and SHIFT+TAB 2024-11-27 17:14:40 -07:00
f139db07d9 feat(app): Dispatch support for all relevant Sonarr blocks 2024-11-27 17:06:20 -07:00
Alex Clarke
73a4129000 Update environment variables table so it appears better in Crates.io [skip ci] 2024-11-25 20:25:04 -07:00
1ddf797e28 ci: Updated the release so all GitHub release names are correctly simply the version: v0.1.2 [skip ci] 2024-11-25 20:11:15 -07:00
github-actions[bot]
18280f0478 chore: Bump the version in Cargo.lock 2024-11-26 03:04:19 +00:00
github-actions[bot]
4348705a0a bump: version 0.3.6 → 0.3.7 [skip ci] 2024-11-26 03:04:17 +00:00
5a1b92547d fix(ci): Forgot to also pull in the most recent changes [skip ci] 2024-11-25 20:02:37 -07:00
github-actions[bot]
9e44713985 chore: Bump the version in Cargo.lock 2024-11-26 02:59:13 +00:00
github-actions[bot]
e93837fef7 bump: version 0.3.5 → 0.3.6 [skip ci] 2024-11-26 02:59:11 +00:00
6006c9d0e8 fix(ci): Ensure the Release Crate job fetches the most recent commit before publishing the crate [skip ci] 2024-11-25 19:58:34 -07:00
github-actions[bot]
c93543186a chore: Bump the version in Cargo.lock 2024-11-26 02:49:11 +00:00
github-actions[bot]
e1b74d7a36 bump: version 0.3.3 → 0.3.4 [skip ci] 2024-11-26 02:49:07 +00:00
6375bc3413 docs(README): Updated the README to not use GitHub markdown symbols and to directly use the unicode symbols so it can be displayed correctly in Crates.io [skip ci] 2024-11-25 19:47:57 -07:00
github-actions[bot]
9c99e0d2ef chore: Bump the version in Cargo.lock 2024-11-26 02:43:36 +00:00
github-actions[bot]
518ccaadc8 bump: version 0.3.2 → 0.3.3 [skip ci] 2024-11-26 02:43:34 +00:00
a766b395c1 fix(ci): Properly prefix version tags with 'v' [skip ci] 2024-11-25 19:41:11 -07:00
1746869b45 fix(ci): Bump the version in the Cargo.lock file and commit it as well when releasing [skip ci] 2024-11-25 19:39:36 -07:00
github-actions[bot]
533366b90b bump: version 0.3.1 → 0.3.2 [skip ci] 2024-11-26 02:33:19 +00:00
5c68df7246 fix(ci): Updated the Cargo.lock file [skip ci] 2024-11-25 19:28:40 -07:00
cc26d9a655 Merge remote-tracking branch 'origin/main' 2024-11-25 19:24:45 -07:00
02a1303557 fix(ci): Use a different GitHub action to release the crate to Crates.io [skip ci] 2024-11-25 19:24:24 -07:00
github-actions[bot]
817ffcf2b5 bump: version 0.3.0 → 0.3.1 [skip ci] 2024-11-26 02:20:25 +00:00
4a8b7eb837 fix(ci): Don't manually push the tags and let Commitizen do it [skip ci] 2024-11-25 19:19:59 -07:00
fcf81ad7d9 fix(ci): Fixed a typo in the version creation on GitHub [skip ci] 2024-11-25 19:17:35 -07:00
89a1b0dec7 ci: Undo selection for release steps [skip ci] 2024-11-25 19:12:38 -07:00
face51ae0c ci: Testing choices for release action [skip ci] 2024-11-25 19:11:32 -07:00
5ca6757fb0 ci: Set the default start_from job to be empty [skip ci] 2024-11-25 19:09:35 -07:00
4f4633c6b3 ci: Creating an enumerated selection of release jobs [skip ci] 2024-11-25 19:06:00 -07:00
17c70dfef2 ci: Adding fields to allow more dynamic executions of releases for testing purposes [skip ci] 2024-11-25 19:03:48 -07:00
92daaebf9d ci: specify the tag to use in release-plz [skip ci] 2024-11-25 18:59:41 -07:00
0c70191687 ci: Fixed release-plz to only release and not create the release MR 2024-11-25 18:57:12 -07:00
github-actions[bot]
e2a5f2f1b5 bump: version 0.2.2 → 0.3.0 [skip ci] 2024-11-26 01:52:23 +00:00
1893abd773 ci: Fix the GitHub release to use commitizen to fetch the new tag 2024-11-25 18:51:48 -07:00
41bb08418f ci: Fixed a typo in the GitHub release workflow 2024-11-25 18:49:12 -07:00
cf1794145c ci: Install conventional-changelog to generate the changelog for the GitHub release [skip ci] 2024-11-25 18:46:42 -07:00
7d9a25e599 ci: Populate the GitHub release with the changelog [skip ci] 2024-11-25 18:44:45 -07:00
github-actions[bot]
cd2175b5ad bump: version 0.2.2 → 0.3.0 [skip ci] 2024-11-26 01:38:08 +00:00
98ff5184e1 ci: Create a new GitHub tag as well when releasing [skip ci] 2024-11-25 18:37:30 -07:00
github-actions[bot]
da785355d6 bump: version 0.2.2 → 0.3.0 [skip ci] 2024-11-26 01:34:37 +00:00
4cabd5f0d0 ci: Update the release flow [skip ci] 2024-11-25 18:34:01 -07:00
a41f03c6b2 ci: Fetch all tags first before running Commitizen [skip ci] 2024-11-25 18:27:25 -07:00
cde86cf9fd fix: Reverted to old version to fix release [skip ci] 2024-11-25 18:24:00 -07:00
8d4981f10f ci: Release-plz fix [skip ci] 2024-11-25 18:21:13 -07:00
93d023c54a docs(README): Added Sonarr support to the README [skip ci] 2024-11-25 18:17:07 -07:00
bae450f23e ci: Fixed the commitizen commit to skip CI [skip ci] 2024-11-25 18:14:20 -07:00
42339e65d4 Merge remote-tracking branch 'origin/main' 2024-11-25 18:10:08 -07:00
0bb839c0a0 ci: Fixed the commitizen bump to not require a full re-run of test suites when releasing [skip ci] 2024-11-25 18:08:49 -07:00
github-actions[bot]
6381d7b742 bump: version 0.2.2 → 0.3.0 2024-11-26 01:04:58 +00:00
2efbdad4f0 ci: Changed ordering of git user commit [skip ci] 2024-11-25 18:04:18 -07:00
30bf0c22fa ci: Configure the git user for the Commitizen commit [skip ci] 2024-11-25 18:03:04 -07:00
169d6c7364 ci: Set up the release workflow to use a deploy key [skip ci] 2024-11-25 18:00:57 -07:00
e1fb5f570e ci: Created deploy key for releases to push directly to main branch [skip ci] 2024-11-25 17:56:29 -07:00
92362a5d8c ci: Fix the push to the release branch [skip ci] 2024-11-25 17:47:10 -07:00
7c88901185 ci: Update the bump step of the release workflow [skip ci] 2024-11-25 17:42:16 -07:00
5bce9b240e ci: Attempting to fix the release workflow 2024-11-25 17:35:55 -07:00
e675168798 ci: Configure the git user for Commitizen [skip ci] 2024-11-25 17:27:02 -07:00
8aa88d7343 ci: Tweaking the bump task to retrieve tags [skip ci] 2024-11-25 17:24:39 -07:00
Alex Clarke
9e6879c0f2 Merge pull request #18 from Dark-Alex-17/sonarr-cli-support
Sonarr CLI support
2024-11-25 17:04:03 -07:00
eb856e28d7 fix(minimal-versions): Addressed concerns with the minimal-versions CI checks 2024-11-25 16:58:15 -07:00
866b0c7537 fix(lint): Addressed linter complaints 2024-11-25 16:55:14 -07:00
3ef5c1911d docs(README): Updated the README to include new features that are available in the Sonarr CLI release 2024-11-25 16:52:08 -07:00
80787d1187 style(lint): Added allow dead code directives around certain structs that are causing linter complaints because these will either be used once sonarr UI work begins, or in future Servarr developments that will make life easier 2024-11-25 16:50:28 -07:00
ad0b3989ed fix(cli): Corrected some copy/paste typos 2024-11-25 16:43:29 -07:00
c7a0e33485 fix(network): Force sonarr to save edits to indexers 2024-11-25 16:28:21 -07:00
ee312a21eb feat(cli): Support for editing a sonarr series 2024-11-25 16:19:48 -07:00
3af22cceac feat(models): Added the ActiveSonarrBlocks for editing a series 2024-11-25 16:04:35 -07:00
c29e2ca9ae feat(network): Support for editing a series in Sonarr 2024-11-25 16:02:49 -07:00
06c9baf8df feat(models): Created the EditSeriesModal 2024-11-25 15:44:07 -07:00
97dc5054e9 feat(cli): Support for editing Sonarr indexers 2024-11-25 15:23:28 -07:00
d43862a3a7 feat(network): Support for editing a sonarr indexer 2024-11-25 15:17:13 -07:00
1dd4cd74c3 feat(cli): Support for deleting an episode file from disk 2024-11-25 14:46:33 -07:00
4f86cce497 feat(network): Support for deleting an episode file from disk in Sonarr 2024-11-25 14:43:53 -07:00
3968983002 feat(cli): Support for editing all indexer settings in Sonarr 2024-11-25 14:25:10 -07:00
4c7e8f0cf6 feat(models): Added the ActiveSonarrBlocks for editing all indexer settings 2024-11-25 14:01:47 -07:00
1e3141e4ee feat(network): Support for editing all sonarr indexer settings 2024-11-25 13:58:34 -07:00
45542cd3a9 feat(cli): Support for searching for new series to add to Sonarr 2024-11-24 14:58:30 -07:00
da3bb795b7 feat(network): Support for searching for new series 2024-11-24 14:54:41 -07:00
53a59cdb4c feat(cli): Support for adding a series to Sonarr 2024-11-24 14:38:23 -07:00
8125bd5ae0 feat(cli): Support for adding a series to Sonarr 2024-11-24 14:29:13 -07:00
5ba3f2b1ba feat(network): Support for adding a new series to Sonarr 2024-11-24 13:18:02 -07:00
c98828aec7 feat(cli): Support for fetching all sonarr language profiles 2024-11-24 11:36:14 -07:00
5ed278ec9c feat(network): Support for fetching all Sonarr language profiles 2024-11-24 11:34:09 -07:00
c8a2fea9cd feat(cli): Support for deleting a series from Sonarr 2024-11-23 12:47:22 -07:00
cac54c5447 feat(network): Support for deleting a series from Sonarr 2024-11-23 12:42:11 -07:00
374819b4f3 fix(network): Made the overview field nullable in the Sonarr series model 2024-11-23 12:23:33 -07:00
4d92c350de fix(network): Added filtering for full seasons specifically in the UI when performing a manual full season search and added a message to the CLI that noes to only try to download a full season if that release includes 'fullSeason: true' 2024-11-23 12:15:41 -07:00
3be9321df6 refactor(cli): the trigger-automatic-search commands now all have their own dedicated subcommand to keep things cleaner. Now they look like 'trigger-automatic-search episode/series/season' and their corresponding flags 2024-11-22 20:42:34 -07:00
746064c430 refactor(cli): Added an additional delegation test to ensure manual-search commands are delegated to the manual-search command handler 2024-11-22 20:26:34 -07:00
ffc00691cb refactor(cli): Moved the manual-season-search and manual-episode-search commands into their own dedicated handler so the commands can now be manual-search episode or manual-search season 2024-11-22 20:22:52 -07:00
1b5979c36c feat(cli): Support for downloading an episode release in Sonarr 2024-11-22 20:04:57 -07:00
5ed3372ae2 feat(cli): Support for downloading a season release in Sonarr 2024-11-22 20:00:41 -07:00
8002a5aa1e feat(cli): Support for downloading a Series release in Sonarr 2024-11-22 19:53:54 -07:00
896c50909a feat(network): Support for downloading releases from Sonarr 2024-11-22 19:33:29 -07:00
cea4632a22 feat(cli): Support for refreshing Sonarr downloads 2024-11-22 19:20:34 -07:00
7fdec15ba9 feat(network): Support for updating Sonarr downloads 2024-11-22 19:18:42 -07:00
eb06787bb2 feat(cli): Support for refreshing a specific series in Sonarr 2024-11-22 19:13:57 -07:00
c3577a0724 feat(network): Support for updating and scanning a series in Sonarr 2024-11-22 19:08:13 -07:00
8864e2c867 feat(cli): Support for refreshing all Sonarr series data 2024-11-22 18:59:55 -07:00
581975b941 feat(network): Support for updating all series in Sonarr 2024-11-22 18:44:51 -07:00
b8e4deb80f feat(cli): Support for triggering an automatic episode search in Sonarr 2024-11-22 18:35:38 -07:00
40bb22ef7c feat(cli): Support for triggering an automatic season search in Sonarr 2024-11-22 18:32:35 -07:00
74e9ea17ac feat(cli): Support for triggering an automatic series search in Sonarr 2024-11-22 18:22:33 -07:00
a11bce603d feat(network): Support for triggering an automatic episode search in Sonarr 2024-11-22 18:18:23 -07:00
c754275af3 feat(network): Support for triggering an automatic season search in Sonarr 2024-11-22 18:04:27 -07:00
3497a54c39 feat(network): Support for triggering an automatic series search in Sonarr 2024-11-22 17:53:09 -07:00
8807adea83 feat(cli): Support for testing all Sonarr indexers at once 2024-11-22 17:38:11 -07:00
6896fcc134 feat(network): Support for testing all Sonarr indexers at once 2024-11-22 17:35:36 -07:00
68830a8789 feat(cli): Support for testing an individual Sonarr indexer 2024-11-22 17:22:41 -07:00
2dce587ea8 feat(network): Added the ability to test an individual indexer in Sonarr 2024-11-22 17:18:47 -07:00
9403bdcbcb fix(network): Not all Sonarr tasks return the lastDuration field and was causing a crash 2024-11-22 17:10:06 -07:00
aa13735533 feat(cli): Support for starting a Sonarr task 2024-11-22 17:01:00 -07:00
33db3efacf feat(network): Support for starting a Sonarr task 2024-11-22 16:57:09 -07:00
8df74585bc feat(cli): Support for listing Sonarr updates 2024-11-22 16:48:09 -07:00
16ca8841a1 feat(network): Support for fetching Sonarr updates 2024-11-22 16:46:36 -07:00
22fbe025d9 feat(cli): Support for listing all Sonarr tasks 2024-11-22 16:37:21 -07:00
c54bd2bab0 feat(network): Support for fetching all Sonarr tasks 2024-11-22 16:35:39 -07:00
539ad75fe6 feat(cli): Support for marking a Sonarr history item as 'failed' 2024-11-22 16:21:43 -07:00
9476caa392 feat(network): Support for marking a Sonarr history item as failed 2024-11-22 16:13:35 -07:00
df3cf70682 feat(cli): Support for listing the available disk space for all provisioned root folders in both Radarr and Sonarr 2024-11-22 15:57:05 -07:00
a881d1f33a feat(network): Support for listing disk space on a Sonarr instance 2024-11-22 15:54:11 -07:00
d96316577a feat(cli): Support for listing all Sonarr tags 2024-11-22 15:35:30 -07:00
eefe6392df feat(cli): Support for adding a root folder to Sonarr 2024-11-22 15:25:33 -07:00
1cc95e2cd1 feat(cli): CLI support for adding a tag to Sonarr 2024-11-22 15:22:45 -07:00
c5328917de feat(network): Support for fetching and listing all Sonarr tags 2024-11-22 15:05:56 -07:00
208acafc73 feat(network): Support for deleting tags from Sonarr 2024-11-22 15:02:30 -07:00
57eced64c0 feat(network): Support for adding tags to Sonarr 2024-11-22 14:58:14 -07:00
ce701c1ab7 feat(network): Support for adding a root folder to Sonarr 2024-11-22 14:42:17 -07:00
b24e3bf9db feat(cli): Support for deleting a root folder from Sonarr 2024-11-21 16:46:00 -07:00
1227796e78 feat(network): Support for deleting a Sonarr root folder 2024-11-21 16:43:38 -07:00
bb1c08277e feat(cli): Support for fetching all Sonarr root folders 2024-11-21 16:39:20 -07:00
16538a3158 feat(network): Support for fetching all Sonarr root folders 2024-11-21 16:37:23 -07:00
f4c647342b feat(cli): Support for deleting a Sonarr indexer 2024-11-21 16:22:30 -07:00
72cb334b6a feat(network): Support for deleting an indexer from Sonarr 2024-11-21 16:19:53 -07:00
1a65a7f3e7 feat(cli): Support for deleting a download from Sonarr 2024-11-21 14:49:36 -07:00
6a0049eb8f feat(network): Support for deleting a download from Sonarr 2024-11-21 14:46:18 -07:00
d2e3750de6 style(README): Added a key and proper GitHub markdown emoji names instead of direct unicode symbols for the features tables 2024-11-21 13:04:21 -07:00
4cdad182ef docs(README): Updated the readme to use a table of checkmarks to indicate which features are present in the CLI and TUI 2024-11-21 12:44:57 -07:00
4ed1e99a15 Merge remote-tracking branch 'origin/main' into sonarr-cli-support 2024-11-21 12:23:19 -07:00
71870d9396 feat(cli): Support for fetching episode history events from Sonarr 2024-11-20 20:03:53 -07:00
fa4ec709c0 feat(network): Support for fetching episode history 2024-11-20 19:59:32 -07:00
d7d223400e style(linter): Removed unused imports that were missed on the last commit 2024-11-20 19:36:01 -07:00
34157ef32f feat(cli): Added a spinner to the CLI for long running commands like fetching releases 2024-11-20 19:33:40 -07:00
f5631376af fix(network): Fixed an issue with dynamic typing in responses from Sonarr for history items 2024-11-20 19:22:13 -07:00
df1eea22ab feat(cli): Support for fetching history for a given series ID 2024-11-20 14:58:54 -07:00
86d93377ac feat(network): Support for fetching Sonarr series history for a given series ID 2024-11-20 14:54:16 -07:00
5872a6ba72 feat(cli): Support for fetching all Sonarr history events 2024-11-20 14:13:20 -07:00
6da1ae93ef feat(network): Support to fetch all Sonarr history events 2024-11-20 14:06:44 -07:00
b8c60bf59a test(models): Fixed the test for the default series_history value in the sonarr_data 2024-11-20 13:25:24 -07:00
bd2d2875a5 feat(models): Added an additional History tab to the mocked tabs for viewing all Sonarr history at once 2024-11-20 13:24:44 -07:00
9d782af020 feat(models): Stubbed out the necessary ActiveSonarrBlocks for the UI mockup 2024-11-20 13:12:08 -07:00
a711c3d16c chore(modals): Removed the unnecessary season_details field from the SeasonDetailsModal 2024-11-19 17:24:41 -07:00
268cc13d27 feat(cli): Added support for manually searching for episode releases in Sonarr 2024-11-19 17:22:27 -07:00
a8328d3636 feat(network): Added support for fetching episode releases in Sonarr 2024-11-19 17:17:12 -07:00
d82a7f7674 feat(cli): Added CLI support for fetching series details in Sonarr 2024-11-19 17:01:48 -07:00
5e63c34a9f feat(network): Added support for fetching series details for a given series ID in Sonarr 2024-11-19 16:56:48 -07:00
540db5993b feat(cli): Added support for manually searching for season releases for Sonarr 2024-11-19 16:39:21 -07:00
16bf06426f fix(config): The CLI panics if the servarr you specify has no config defined 2024-11-19 16:29:25 -07:00
cc02832512 feat(network): Added support for fetching season releases for Sonarr 2024-11-19 15:59:35 -07:00
2876913f48 feat(cli): Added support for listing Sonarr queued events 2024-11-19 12:03:19 -07:00
6b64b5ecc4 feat(network): Added support for fetching Sonarr queued events 2024-11-19 12:01:07 -07:00
9ceb55a314 feat(cli): Added CLI support for fetching all indexer settings for Sonarr 2024-11-18 21:25:50 -07:00
7870bb4b5b feat(network): Added netwwork support for fetching all indexer settings for Sonarr 2024-11-18 21:19:20 -07:00
4fc2d3c94b feat(cli): Added Sonarr support for fetching host and security configs 2024-11-18 20:59:27 -07:00
a012945df2 feat(network): Added network support for fetching host and security configs from Sonarr 2024-11-18 20:49:07 -07:00
f094cf5ad3 feat(cli): Added CLI support for listing Sonarr indexers 2024-11-18 19:57:55 -07:00
d8979221c8 feat(network): Added the GetIndexers network call for Sonarr 2024-11-18 19:54:42 -07:00
aaa4e67f43 chore: published the managarr-tui-widget to crates.io so I changed the Cargo.toml to now pull from crates.io instead of directly using the repo for the widget 2024-11-18 18:47:49 -07:00
Alex Clarke
fff38704ab chore(readme): Updated the README to explicitly mention creating the config file first for docker installs 2024-11-16 18:04:47 -07:00
d5e6d64d0f fix: Imported a missing macro in the panic hook 2024-11-15 18:43:36 -07:00
003f319385 feat(cli): Added sonarr support for listing downloads, listing quality profiles, and fetching detailed information about an episode 2024-11-15 18:41:13 -07:00
e14b7072c6 feat(network): Added get quality profiles and get episode details events for Sonarr 2024-11-15 18:19:03 -07:00
1fe95d057b feat(cli): Sonarr CLI support for fetching all episodes for a given series 2024-11-15 15:14:34 -07:00
6dffc90e92 feat(sonarr_network): Added support for fetching episodes for a specified series to the network events 2024-11-15 14:57:19 -07:00
295cd56a1f feat(models): Added the Episode model to Sonarr models 2024-11-15 12:48:35 -07:00
214c89e8b5 feat(models): Created the StatefulTree struct for displaying seasons and episodes (and any other structured data) for the UI. 2024-11-15 12:08:35 -07:00
29047c3007 feat(sonarr): Added CLI support for listing Sonarr logs 2024-11-11 14:06:46 -07:00
a8f3bed402 feat(sonarr): Added the ability to fetch Sonarr logs 2024-11-11 14:00:07 -07:00
1ca9265a2a feat(sonarr): Added blocklist commands (List, Clear, Delete) 2024-11-11 13:45:32 -07:00
60d61b9e31 feat: Added initial Sonarr CLI support and the initial network handler setup for the TUI 2024-11-10 21:23:55 -07:00
b6f5b9d08c feat: Added a new command to the main managarr CLI: tail-logs, to enable users to tail the Managarr logs without needing to know where the log file itself is located 2024-11-09 16:18:43 -07:00
28f7bc6a4c ci: Updated the release workflow to use commitizen to bump the version, generate the changelog, and push those changes to the repo [skip ci] 2024-11-06 17:21:41 -07:00
5b42129f55 fix: Yet another typo in the release workflow [skip ci] 2024-11-06 17:15:17 -07:00
4f06b2b947 fix: Fixed typo in release name [skip ci] 2024-11-06 17:14:33 -07:00
0f98050a12 fix: Removed the release-patch workflow [skip ci] 2024-11-06 17:14:01 -07:00
14839642dc fix: Fixed a typo in the new release workflow name [skip ci] 2024-11-06 17:13:17 -07:00
eccc1a2df1 fix: Release-plz to perform the release and to use Commitizen for bumping and generating the CHANGELOG [skip ci] 2024-11-06 17:12:39 -07:00
9df929a8e3 fix: Updated the Commitizen config to also always commit the Cargo.lock when doing the version bump 2024-11-06 17:07:43 -07:00
1e008f9778 bump: version 0.2.1 → 0.2.2 2024-11-06 17:03:46 -07:00
Alex Clarke
fa811da5c2 Merge pull request #16 from Dark-Alex-17/commitizen-config
Added Commitizen to enforce commit styles
2024-11-06 16:53:40 -07:00
48ad17c6f1 style: Updated the contributing doc to also explain how to install commitizen 2024-11-06 16:48:27 -07:00
3cd15f34cd style: Test install for commitizen 2024-11-06 16:39:26 -07:00
53ca14e64d fix(handler): Fixed a bug in the movie details handler that would allow key events to be processed before the data was finished loading 2024-11-06 16:17:23 -07:00
0d8803d35d fix(ui): Fixed a bug that would freeze all user input while background network requests were running 2024-11-06 15:50:47 -07:00
8c90221a81 perf(network): Improved performance and reactiveness of the UI by speeding up network requests and clearing the channel whenever a request is cancelled/the UI is routing 2024-11-06 14:52:48 -07:00
a708f71d57 fix(radarr_ui): Fixed a race condition bug in the movie details UI that would panic if the user changes tabs too quickly 2024-11-06 11:29:49 -07:00
2a13f74a2b Updated the release workflows to use the correct flags and commands 2024-11-05 18:33:17 -07:00
2a97c49a8e Updated workflows to allow manual releases [skip ci] 2024-11-05 18:29:45 -07:00
8c155ce656 Updated release actions to only be executable by the repository owner [skip ci] 2024-11-05 18:25:26 -07:00
Alex Clarke
5245ba6d98 Merge pull request #14 from Dark-Alex-17/release-plz-2024-11-06T01-20-32Z
chore: release v0.2.1
2024-11-05 18:24:50 -07:00
github-actions[bot]
f9789ecc9b chore: release v0.2.1 2024-11-06 01:20:33 +00:00
9936ce1ab5 Removed the need for use_ssl to indicate SSL usage; instead just use the ssl_cert_path
Added the ability to specify either host/port, or uri for configuring Radarr
2024-11-05 18:16:01 -07:00
650c9783a6 Applied bug fix to the downloads tab as well as the context [skip ci] 2024-11-04 14:21:34 -07:00
b253a389eb Updated the README to not include the GitHub downloads badge since all binary releases are on crates.io [skip ci] 2024-11-04 10:12:45 -07:00
5023fbd3d1 Set all releases as manually triggered instead of automatic [skip ci] 2024-11-04 10:10:36 -07:00
fdb08fbd34 Added additional workflows for releasing minor and major releases, in addition to just patches so I can manually trigger them and update the Changelog dynamically. [skip ci] 2024-11-03 16:37:52 -07:00
b125d3341a Updated dockerfile to no longer use the --disable-terminal-size-checks flag [skip ci] 2024-11-03 16:20:32 -07:00
Alex Clarke
f73e3a4817 Merge pull request #11 from Dark-Alex-17/rc/v0.2.0-address-community-comments
Rc/v0.2.0 address community comments
2024-11-03 16:12:29 -07:00
1083 changed files with 155766 additions and 24096 deletions
+4
View File
@@ -0,0 +1,4 @@
--artifact-server-path=./.act/artifacts
--cache-server-path=./.act/cache
--container-options --privileged
--env ACT=true
+4 -4
View File
@@ -4,8 +4,8 @@ set -e
echo "Running pre-push hook:"
echo "Executing: make lint"
make lint
echo "Executing: cargo fmt"
cargo fmt
echo "Executing: cargo test"
cargo test
echo "Executing: cargo clippy --all"
cargo clippy --all
+7 -4
View File
@@ -4,8 +4,11 @@ set -e
echo "Running pre-push hook:"
echo "Executing: make lint"
make lint
echo "Executing: cargo fmt --check"
cargo fmt --check
echo "Executing: cargo test"
cargo test
echo "Executing: cargo clippy --all"
cargo clippy --all
echo "Executing: cargo test --all"
cargo test --all
+10
View File
@@ -0,0 +1,10 @@
[tool.commitizen]
name = "cz_conventional_commits"
tag_format = "v$version"
version_scheme = "semver"
version_provider = "cargo"
update_changelog_on_bump = true
major_version_zero = true
[tool.commitizen.hooks]
pre-commit = "git add Cargo.toml Cargo.lock"
+15 -6
View File
@@ -11,8 +11,6 @@ name: Check
env:
CARGO_TERM_COLOR: always
# ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel
# and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
@@ -24,14 +22,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Run cargo fmt
run: cargo fmt -- --check
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
clippy:
name: ${{ matrix.toolchain }} / clippy
runs-on: ubuntu-latest
@@ -45,12 +47,15 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Run clippy action
uses: clechasseur/rs-clippy-check@v3
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
doc:
@@ -61,21 +66,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
- name: Run cargo doc
run: cargo doc --no-deps --all-features
env:
RUSTDOCFLAGS: --cfg docsrs
msrv:
# check that we can build using the minimal rust version that is specified by this crate
name: 1.82.0 / check
name: 1.89.0 / check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install 1.82.0
- name: Install 1.89.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.82.0
- name: cargo +1.82.0 check
toolchain: 1.89.0
- name: cargo +1.89.0 check
run: cargo check
+672 -16
View File
@@ -1,32 +1,688 @@
# Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml
# Thanks to joshka for permission to use this template!
name: Create Release PR and Publish Release
name: Create release
permissions:
pull-requests: write
contents: write
on:
push:
branches:
- main
workflow_dispatch:
inputs:
bump_type:
description: 'Specify the type of version bump'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
jobs:
release-plz:
# see https://release-plz.ieni.dev/docs/github
# for more information
name: Release-plz
bump-version:
name: bump-version
runs-on: ubuntu-latest
steps:
- name: Configure SSH for Git
if: env.ACT != 'true'
run: |
mkdir -p ~/.ssh
echo "${{ secrets.RELEASE_BOT_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
- name: Checkout repository
if: env.ACT != 'true'
uses: actions/checkout@v3
with:
ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }}
fetch-depth: 0
- name: Checkout repository
if: env.ACT == 'true'
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Commitizen
run: |
python3 -m pip install --upgrade pip
pip3 install commitizen==4.8.3
npm install -g conventional-changelog-cli
- name: Configure Git user
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump version with Commitizen
run: |
cz bump --yes --increment ${{ github.event.inputs.bump_type }}
- name: Amend commit message to include '[skip ci]'
run: |
git commit --amend --no-edit -m "$(git log -1 --pretty=%B) [skip ci]"
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Get the new version tag
id: version
run: |
mkdir -p artifacts
NEW_TAG=$(cz version --project)
echo "New version: $NEW_TAG"
echo "version=$NEW_TAG" >> $GITHUB_ENV
echo "$NEW_TAG" > artifacts/release-version
- name: Get the previous version tag
id: prev_version
run: |
PREV_TAG=$(git describe --tags --abbrev=0 ${GITHUB_SHA}^)
echo "Previous tag: $PREV_TAG"
echo "prev_version=$PREV_TAG" >> $GITHUB_ENV
- name: Bump Cargo.toml version
shell: bash
working-directory: ${{ github.workspace }}
env:
VERSION: ${{ env.version }}
run: |
set -euo pipefail
: "${VERSION:?env.version is empty}"
# Ignore Act's local artifact dir noise
echo artifacts/ >> .git/info/exclude || true
# Edit the version line right after name="managarr"
sed -E -i '
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"managarr"[[:space:]]*$/ {
n
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
}
' Cargo.toml
cargo update || true
# Git config that helps in containers (Act)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Debug: show what changed
git status --porcelain
git diff --name-only -- Cargo.toml Cargo.lock || true
# Only commit if one of these files actually changed
if ! git diff --quiet -- Cargo.toml Cargo.lock; then
# Stage only modifications of already tracked files (won't pick up artifacts/)
git add -u -- Cargo.toml Cargo.lock
git commit -m "chore: bump Cargo.toml to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
- name: Bump validate_theme_derive/Cargo.toml version
shell: bash
working-directory: ${{ github.workspace }}/proc_macros/validate_theme_derive
env:
VERSION: ${{ env.version }}
run: |
set -euo pipefail
: "${VERSION:?env.version is empty}"
# Ignore Act's local artifact dir noise
echo artifacts/ >> .git/info/exclude || true
# Edit the version line right after name="managarr"
sed -E -i '
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"managarr"[[:space:]]*$/ {
n
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
}
' Cargo.toml
cargo update || true
# Git config that helps in containers (Act)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Debug: show what changed
git status --porcelain
git diff --name-only -- Cargo.toml || true
# Only commit if one of these files actually changed
if ! git diff --quiet -- Cargo.toml; then
# Stage only modifications of already tracked files (won't pick up artifacts/)
git add -u -- Cargo.toml
git commit -m "chore: bump validate_theme_derive Cargo.toml to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
- name: Bump enum_display_style_derive/Cargo.toml version
shell: bash
working-directory: ${{ github.workspace }}/proc_macros/enum_display_style_derive
env:
VERSION: ${{ env.version }}
run: |
set -euo pipefail
: "${VERSION:?env.version is empty}"
# Ignore Act's local artifact dir noise
echo artifacts/ >> .git/info/exclude || true
# Edit the version line right after name="managarr"
sed -E -i '
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"managarr"[[:space:]]*$/ {
n
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
}
' Cargo.toml
cargo update || true
# Git config that helps in containers (Act)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Debug: show what changed
git status --porcelain
git diff --name-only -- Cargo.toml || true
# Only commit if one of these files actually changed
if ! git diff --quiet -- Cargo.toml; then
# Stage only modifications of already tracked files (won't pick up artifacts/)
git add -u -- Cargo.toml
git commit -m "chore: bump enum_display_style_derive Cargo.toml to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
- name: Generate changelog for the version bump
id: changelog
run: |
conventional-changelog -p angular -i CHANGELOG.md --from ${{ env.prev_version }} --to v${{ env.version }} > artifacts/changelog.md
- name: Push changes
if: env.ACT != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git push origin --follow-tags
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
path: artifacts
- name: Upload the changed Cargo files (Act)
if: env.ACT == 'true'
uses: actions/upload-artifact@v4
with:
name: bumped-cargo-files
path: |
Cargo.toml
Cargo.lock
proc_macros/validate_theme_derive/Cargo.toml
proc_macros/enum_display_style_derive/Cargo.toml
build-release-artifacts:
name: build-release
needs: [bump-version]
runs-on: ${{ matrix.job.os }}
env:
RUST_BACKTRACE: 1
strategy:
fail-fast: true
matrix:
# prettier-ignore
job:
- { name: "macOS-arm64", os: "macOS-latest", target: "aarch64-apple-darwin", artifact_suffix: "macos-arm64", use-cross: true }
- { name: "macOS-amd64", os: "macOS-latest", target: "x86_64-apple-darwin", artifact_suffix: "macos" }
- { name: "windows-amd64", os: "windows-latest", target: "x86_64-pc-windows-msvc", artifact_suffix: "windows" }
- { name: "windows-aarch64", os: "windows-latest", target: "aarch64-pc-windows-msvc", artifact_suffix: "windows-aarch64", use-cross: true }
- { name: "linux-gnu", os: "ubuntu-latest", target: "x86_64-unknown-linux-gnu", artifact_suffix: "linux" }
- { name: "linux-musl", os: "ubuntu-latest", target: "x86_64-unknown-linux-musl", artifact_suffix: "linux-musl", use-cross: true, }
- { name: "linux-aarch64-gnu", os: "ubuntu-latest", target: "aarch64-unknown-linux-gnu", artifact_suffix: "aarch64-gnu", use-cross: true, test-bin: "--bin managarr" }
- { name: "linux-aarch64-musl", os: "ubuntu-latest", target: "aarch64-unknown-linux-musl", artifact_suffix: "aarch64-musl", use-cross: true, test-bin: "--bin managarr" }
- { name: "linux-arm-gnu", os: "ubuntu-latest", target: "arm-unknown-linux-gnueabi", artifact_suffix: "armv6-gnu", use-cross: true, test-bin: "--bin managarr" }
- { name: "linux-arm-musl", os: "ubuntu-latest", target: "arm-unknown-linux-musleabihf", artifact_suffix: "armv6-musl", use-cross: true, test-bin: "--bin managarr" }
- { name: "linux-armv7-gnu", os: "ubuntu-latest", target: "armv7-unknown-linux-gnueabihf", artifact_suffix: "armv7-gnu", use-cross: true, test-bin: "--bin managarr" }
- { name: "linux-armv7-musl", os: "ubuntu-latest", target: "armv7-unknown-linux-musleabihf", artifact_suffix: "armv7-musl", use-cross: true, test-bin: "--bin managarr" }
rust: [stable]
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: |
git fetch --all
git pull
- name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v4
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
- uses: actions/cache@v3
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3
if: startsWith(matrix.job.name, 'linux-')
with:
path: ~/.cargo/bin
key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }}
- uses: dtolnay/rust-toolchain@stable
name: Set Rust toolchain
with:
targets: ${{ matrix.job.target }}
- uses: taiki-e/setup-cross-toolchain-action@v1
with:
# NB: sets CARGO_BUILD_TARGET evar - do not need --target flag in build
target: ${{ matrix.job.target }}
- uses: taiki-e/install-action@cross
if: ${{ matrix.job.use-cross }}
- name: Installing needed Ubuntu dependencies
if: matrix.job.os == 'ubuntu-latest'
shell: bash
run: |
sudo apt-get -y update
case ${{ matrix.job.target }} in
arm*-linux-*) sudo apt-get -y install gcc-arm-linux-gnueabihf ;;
aarch64-*-linux-*) sudo apt-get -y install gcc-aarch64-linux-gnu ;;
esac
- name: Build
run: cargo build --release --verbose --target=${{ matrix.job.target }} --locked
- name: Verify file
shell: bash
run: |
file target/${{ matrix.job.target }}/release/managarr
- name: Test
if: matrix.job.target != 'aarch64-apple-darwin' && matrix.job.target != 'aarch64-pc-windows-msvc'
run: cargo test --release --verbose --target=${{ matrix.job.target }} ${{ matrix.job.test-bin }}
- name: Packaging final binary (Windows)
if: matrix.job.os == 'windows-latest'
shell: bash
run: |
cd target/${{ matrix.job.target }}/release
BINARY_NAME=managarr.exe
if [ "${{ matrix.job.target }}" != "aarch64-pc-windows-msvc" ]; then
# strip the binary
strip $BINARY_NAME
fi
RELEASE_NAME=managarr-${{ matrix.job.artifact_suffix }}
mkdir -p artifacts
tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME
# create sha checksum files
certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256
echo "RELEASE_NAME=$RELEASE_NAME" >> $GITHUB_ENV
- name: Packaging final binary (macOS and Linux)
if: matrix.job.os != 'windows-latest'
shell: bash
run: |
# set the right strip executable
STRIP="strip";
case ${{ matrix.job.target }} in
arm*-linux-*) STRIP="arm-linux-gnueabihf-strip" ;;
aarch64-*-linux-*) STRIP="aarch64-linux-gnu-strip" ;;
esac;
cd target/${{ matrix.job.target }}/release
BINARY_NAME=managarr
# strip the binary
"$STRIP" "$BINARY_NAME"
RELEASE_NAME=managarr-${{ matrix.job.artifact_suffix }}
tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME
# create sha checksum files
shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256
echo "RELEASE_NAME=$RELEASE_NAME" >> $GITHUB_ENV
- name: Add artifacts
run: |
mkdir -p artifacts
cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.tar.gz artifacts/
cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: artifacts-${{ env.RELEASE_NAME }}
path: artifacts
overwrite: true
publish-github-release:
name: publish-github-release
needs: [build-release-artifacts]
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: |
git fetch --all
git pull
- name: Set environment variables
run: |
release_version="$(cat ./artifacts/release-version)"
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Validate release environment variables
run: |
echo "Release version: ${{ env.RELEASE_VERSION }}"
echo "Changelog body: $(cat artifacts/changelog.md)"
- name: Create a GitHub Release
if: env.ACT != 'true'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
files: |
artifacts/managarr-macos-arm64.tar.gz
artifacts/managarr-macos-arm64.sha256
artifacts/managarr-macos.tar.gz
artifacts/managarr-macos.sha256
artifacts/managarr-windows.tar.gz
artifacts/managarr-windows.sha256
artifacts/managarr-windows-aarch64.tar.gz
artifacts/managarr-windows-aarch64.sha256
artifacts/managarr-linux.tar.gz
artifacts/managarr-linux.sha256
artifacts/managarr-linux-musl.tar.gz
artifacts/managarr-linux-musl.sha256
artifacts/managarr-aarch64-gnu.tar.gz
artifacts/managarr-aarch64-gnu.sha256
artifacts/managarr-aarch64-musl.tar.gz
artifacts/managarr-aarch64-musl.sha256
artifacts/managarr-armv6-gnu.tar.gz
artifacts/managarr-armv6-gnu.sha256
artifacts/managarr-armv6-musl.tar.gz
artifacts/managarr-armv6-musl.sha256
artifacts/managarr-armv7-gnu.tar.gz
artifacts/managarr-armv7-gnu.sha256
artifacts/managarr-armv7-musl.tar.gz
artifacts/managarr-armv7-musl.sha256
tag_name: v${{ env.RELEASE_VERSION }}
name: 'v${{ env.RELEASE_VERSION }}'
body_path: artifacts/changelog.md
draft: false
prerelease: false
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
path: artifacts
overwrite: true
publish-chocolatey-package:
needs: [publish-github-release]
name: Publish Chocolatey Package
runs-on: windows-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Set release assets and version
shell: pwsh
run: |
# Read the first column from the SHA256 file
$windows_sha = Get-Content ./artifacts/managarr-windows.sha256 | ForEach-Object { $_.Split(' ')[0] }
Add-Content -Path $env:GITHUB_ENV -Value "WINDOWS_SHA=$windows_sha"
# Read the release version from the release-version file
$release_version = Get-Content ./artifacts/release-version
Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_VERSION=$release_version"
- name: Validate release environment variables
run: |
echo "Release SHA windows: ${{ env.WINDOWS_SHA }}"
echo "Release version: ${{ env.RELEASE_VERSION }}"
- name: Package and Publish package to Chocolatey
if: env.ACT != 'true'
run: |
mkdir ./deployment/chocolatey/tools
# Run packaging script
python "./deployment/chocolatey/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/chocolatey/managarr.nuspec.template" "./deployment/chocolatey/managarr.nuspec" ${{ env.WINDOWS_SHA }}
python "./deployment/chocolatey/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/chocolatey/chocolateyinstall.ps1.template" "./deployment/chocolatey/tools/chocolateyinstall.ps1" ${{ env.WINDOWS_SHA }}
# Publish to Chocolatey
cd ./deployment/chocolatey
choco pack
echo y | choco install managarr -dv -s .
$version = managarr --version
$version = $version -replace " ", "."
choco push $version.nupkg -s https://push.chocolatey.org/ --api-key ${{ secrets.CHOCOLATEY_API_KEY }};
publish-homebrew-formula:
needs: [publish-github-release]
name: Update Homebrew formulas
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Set release assets and version
shell: bash
run: |
# Set environment variables
macos_sha="$(cat ./artifacts/managarr-macos.sha256 | awk '{print $1}')"
echo "MACOS_SHA=$macos_sha" >> $GITHUB_ENV
macos_sha_arm="$(cat ./artifacts/managarr-macos-arm64.sha256 | awk '{print $1}')"
echo "MACOS_SHA_ARM=$macos_sha_arm" >> $GITHUB_ENV
linux_sha="$(cat ./artifacts/managarr-linux-musl.sha256 | awk '{print $1}')"
echo "LINUX_SHA=$linux_sha" >> $GITHUB_ENV
release_version="$(cat ./artifacts/release-version)"
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Validate release environment variables
run: |
echo "Release SHA macos: ${{ env.MACOS_SHA }}"
echo "Release SHA macos-arm: ${{ env.MACOS_SHA_ARM }}"
echo "Release SHA linux musl: ${{ env.LINUX_SHA }}"
echo "Release version: ${{ env.RELEASE_VERSION }}"
- name: Execute Homebrew packaging script
if: env.ACT != 'true'
run: |
# run packaging script
python "./deployment/homebrew/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/homebrew/managarr.rb.template" "./managarr.rb" ${{ env.MACOS_SHA }} ${{ env.MACOS_SHA_ARM }} ${{ env.LINUX_SHA }}
- name: Push changes to Homebrew tap
if: env.ACT != 'true'
env:
TOKEN: ${{ secrets.MANAGARR_GITHUB_TOKEN }}
run: |
# push to Git
git config --global user.name "Dark-Alex-17"
git config --global user.email "alex.j.tusa@gmail.com"
git clone https://Dark-Alex-17:${{ secrets.MANAGARR_GITHUB_TOKEN }}@github.com/Dark-Alex-17/homebrew-managarr.git
rm homebrew-managarr/Formula/managarr.rb
cp managarr.rb homebrew-managarr/Formula
cd homebrew-managarr
git add .
git diff-index --quiet HEAD || git commit -am "Update formula for Managarr release ${{ env.RELEASE_VERSION }}"
git push https://$TOKEN@github.com/Dark-Alex-17/homebrew-managarr.git
publish-docker-image:
needs: [publish-github-release]
name: Publishing Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: |
git fetch --all
git pull
- name: Set version variable
run: |
version="$(cat artifacts/release-version)"
echo "version=$version" >> $GITHUB_ENV
- name: Validate release environment variables
run: |
echo "Release version: ${{ env.version }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: env.ACT != 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to Docker Hub
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ env.ACT != 'true' }}
tags: darkalex17/managarr:latest, darkalex17/managarr:${{ env.version }}
publish-crate:
needs: publish-github-release
name: Publish Crate
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v4
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
- name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: |
git fetch --all
git pull
- uses: actions/cache@v3
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3
with:
path: ~/.cargo/bin
key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }}
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: MarcoIeni/release-plz-action@v0.5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- uses: katyo/publish-crates@v2
if: env.ACT != 'true'
with:
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+63 -39
View File
@@ -34,55 +34,66 @@ jobs:
toolchain: [stable, beta]
steps:
- uses: actions/checkout@v4
- name: Install ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
# enable this ci template to run regardless of whether the lockfile is checked in or not
- name: cargo generate-lockfile
if: hashFiles('Cargo.lock') == ''
run: cargo generate-lockfile
- name: cargo test --locked
run: cargo test --locked --all-features --all-targets
minimal-versions:
# This action chooses the oldest version of the dependencies permitted by Cargo.toml to ensure
# that this crate is compatible with the minimal version that this crate and its dependencies
# require. This will pickup issues where this create relies on functionality that was introduced
# later than the actual version specified (e.g., when we choose just a major version, but a
# method was added after this version).
#
# This particular check can be difficult to get to succeed as often transitive dependencies may
# be incorrectly specified (e.g., a dependency specifies 1.0 but really requires 1.1.5). There
# is an alternative flag available -Zdirect-minimal-versions that uses the minimal versions for
# direct dependencies of this crate, while selecting the maximal versions for the transitive
# dependencies. Alternatively, you can add a line in your Cargo.toml to artificially increase
# the minimal dependency, which you do with e.g.:
# ```toml
# # for minimal-versions
# [target.'cfg(any())'.dependencies]
# openssl = { version = "0.10.55", optional = true } # needed to allow foo to build with -Zminimal-versions
# ```
# The optional = true is necessary in case that dependency isn't otherwise transitively required
# by your library, and the target bit is so that this dependency edge never actually affects
# Cargo build order. See also
# https://github.com/jonhoo/fantoccini/blob/fde336472b712bc7ebf5b4e772023a7ba71b2262/Cargo.toml#L47-L49.
# This action is run on ubuntu with the stable toolchain, as it is not expected to fail
runs-on: ubuntu-latest
name: ubuntu / stable / minimal-versions
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install nightly for -Zdirect-minimal-versions
uses: dtolnay/rust-toolchain@nightly
- name: rustup default stable
run: rustup default stable
- name: cargo update -Zdirect-minimal-versions
run: cargo +nightly update -Zdirect-minimal-versions
- name: cargo test
run: cargo test --locked --all-features --all-targets
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
# minimal-versions:
# # This action chooses the oldest version of the dependencies permitted by Cargo.toml to ensure
# # that this crate is compatible with the minimal version that this crate and its dependencies
# # require. This will pickup issues where this create relies on functionality that was introduced
# # later than the actual version specified (e.g., when we choose just a major version, but a
# # method was added after this version).
# #
# # This particular check can be difficult to get to succeed as often transitive dependencies may
# # be incorrectly specified (e.g., a dependency specifies 1.0 but really requires 1.1.5). There
# # is an alternative flag available -Zdirect-minimal-versions that uses the minimal versions for
# # direct dependencies of this crate, while selecting the maximal versions for the transitive
# # dependencies. Alternatively, you can add a line in your Cargo.toml to artificially increase
# # the minimal dependency, which you do with e.g.:
# # ```toml
# # # for minimal-versions
# # [target.'cfg(any())'.dependencies]
# # openssl = { version = "0.10.55", optional = true } # needed to allow foo to build with -Zminimal-versions
# # ```
# # The optional = true is necessary in case that dependency isn't otherwise transitively required
# # by your library, and the target bit is so that this dependency edge never actually affects
# # Cargo build order. See also
# # https://github.com/jonhoo/fantoccini/blob/fde336472b712bc7ebf5b4e772023a7ba71b2262/Cargo.toml#L47-L49.
# # This action is run on ubuntu with the stable toolchain, as it is not expected to fail
# runs-on: ubuntu-latest
# name: ubuntu / stable / minimal-versions
# steps:
# - uses: actions/checkout@v4
# - name: Install Rust stable
# uses: dtolnay/rust-toolchain@stable
# - name: Install nightly for -Zdirect-minimal-versions
# uses: dtolnay/rust-toolchain@nightly
# - name: rustup default stable
# run: rustup default stable
# - name: cargo update -Zdirect-minimal-versions
# run: cargo +nightly update -Zdirect-minimal-versions
# - name: cargo test
# run: cargo test --locked --all-features --all-targets
# - name: Cache Cargo dependencies
# uses: Swatinem/rust-cache@v2
os-check:
# run cargo test on mac and windows
runs-on: ${{ matrix.os }}
@@ -100,15 +111,20 @@ jobs:
# if: runner.os == 'Windows'
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: cargo generate-lockfile
if: hashFiles('Cargo.lock') == ''
run: cargo generate-lockfile
- name: cargo test
run: cargo test --locked --all-features --all-targets
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
coverage:
# use llvm-cov to build and collect coverage and outputs in a format that
# is compatible with codecov.io
@@ -136,22 +152,30 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: cargo install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: cargo generate-lockfile
if: hashFiles('Cargo.lock') == ''
run: cargo generate-lockfile
- name: cargo llvm-cov
run: cargo llvm-cov --locked --all-features --lcov --output-path lcov.info
- name: Record Rust version
run: echo "RUST=$(rustc --version)" >> "$GITHUB_ENV"
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
- name: Upload to codecov.io
if: env.ACT != 'true'
uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
+1
View File
@@ -1,3 +1,4 @@
/target
/.idea/
/.scannerwork/
/.act/
+5
View File
@@ -0,0 +1,5 @@
[keys.normal.backspace]
b = ":sh zellij run -x '4%%' --width '92%%' -f -n Build -- just build"
r = ":sh zellij run -x '3%%' -y '8%%' --width '95%%' --height '90%%' -fc -n 'Run' -- just run"
t = ":sh zellij run -x '4%%' --width '92%%' -f -n Tests -- just test"
l = ":sh zellij run -x '4%%' --width '92%%' -f -n Lint -- just lint"
+8
View File
@@ -0,0 +1,8 @@
repos:
- hooks:
- id: commitizen
- id: commitizen-branch
stages:
- pre-push
repo: https://github.com/commitizen-tools/commitizen
rev: v3.30.0
+520 -1
View File
@@ -5,7 +5,526 @@ 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).
## [Unreleased]
## 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
- Wrapped all Sonarr use of Language with Option to fix the 'null' array issue in the new Sonarr API
## v0.6.2 (2025-12-12)
### Fix
- Fixed breaking Sonarr Episode file API calls after recent Sonarr API update
### Refactor
- Replaced all modulo usages of tick_until_poll with is_multiple_of
## v0.6.1 (2025-09-02)
### Fix
- Fixed UI bugs introduced as part of the hotkey refactor
### Refactor
- Updated crate to publish properly with the procedural macros
## v0.6.0 (2025-08-29)
### Feat
- Support for custom headers to be added to every request to each server to support alternative authentication mechanisms [#47]
- Refactor all keybinding tips into a dynamically changing menu that can be invoked via '?' [#32]
- Display total disk usage for series in the Library view to mirror Radarr functionality [#44]
- Pagination support for jumping 20 items at a time in all table views [#45]
- Support toggling Movie monitoring directly from the library view [#43]
- Support toggling Movie monitoring from the CLI
- Support toggling Series monitoring directly from the Sonarr library view [#43]
- Support toggling Series monitoring from the CLI
- Fixed the Radarr downloads tab to display more than 10 downloads at a time and added a new --count flag to the CLI for specifying the number of downloads to return
- Fetch more than 10 downloads when listing Sonarr downloads, and add a --count flag to the CLI to specify how many downloads to fetch
- Support alternative keymappings for all keys, featuring hjkl movements
- Added the Eldritch theme and updated documentation
- Write built in themes to the themes file on first run so users can define custom themes
- Created a theme validation macro to verify theme configurations before allowing the TUI to start
- Initial support for custom user-defined themes
### Fix
- Marked Radarr studios as nullable to prevent crashes
- Fixed a bug where the Sonarr API was returning empty values for seeders when searching for season releases
- Improve fault tolerance for tag associations in Radarr and Sonarr
- Upgraded to the most recent version of Tokio to mitigate CWE-664
- Updated all dependencies and updated openssl to the most recent version to mitigate CWE-416
- Updated the name of the should_ignore_quit_key to ignore_special_keys_for_textbox_input to give a better idea of what the flag is used for; also added alt keybinding for backspace
- Marked videoCodecs as Option to resolve #38
- Marked the `Season.statistics` field as `Option` so that a panic does not happen for outdated Sonarr data. This resolves #35
- When adding a film from the Collection Details modal, the render order was wrong: Radarr Library -> Collection Table -> Add Movie Prompt (missing the Collection details prompt too). Correct order is: Collection Table -> Collection Details Modal -> Add Movie Modal
- Fixed a bug that was rendering encompassing blocks after other widgets were rendered, thus overwriting the custom styles on each previously rendered widget
- change the name of the theme configuration file to 'themes'
- Ensure key events are only processed on key press to avoid duplicates
- Updated ring dependency to mitigate CWE-770
- Modified the Sonarr DownloadRecord so that the episode_id is optional to prevent crashes for weird downloads
### Refactor
- Network module is now broken out into similar directory structures for each servarr to mimic the rest of the project to make it easier to develop and maintain
- Refactored the IndexerTestResut model into the general Servarr models
- Renamed 'ctrl-*' keyboard shortcuts to 'C-*' to simplify and shrink the on-screen help
- Formatted files using rustfmt
- Reformatted code to make the format checks pass
- Created a derive macro for defining the display style of Enum models and removed the use of the EnumDisplayStyle trait
- Expanded the serde_enum_from macro to further reduce code duplication
## v0.5.1 (2025-03-01)
### Feat
- CLI Support for multiple Servarr instances
- Support for multiple servarr definitions - no tests [skip ci]
- Support for loading Servarr API tokens from a file
- Tweaked the implementation for environment variables in the config a bit
- var interpolation
### Fix
- Updated openssl to 0.10.70 to mitigate CVE-2025-24898
- Addressed rustfmt complaints
- Corrected typo in the managarr.nuspec.template
### Refactor
- Updated dependencies
- Addressed Cargo fmt complaints
- Added a debug line for logging to output the config used when starting Managarr
- Updated the 2018 idiom lint to the 2021_compatibility lint
- Removed unnecessary clones in the networking module to speed up network request handling
- Corrected some clone instead of copy behaviors in the command line handlers
- Removed unnecessary clone from stateful table
- Removed unnecessary clone call from extract_and_add_tag_ids_vec method
- Reduced the number of clones necessary when building modal structs
- Refactored a handful of Option calls to use take instead
- Renamed KeyEventHandler::with to KeyEventHandler::new to keep with Rust best practices and conventions
## v0.4.2 (2024-12-21)
### Fix
- Revert failed release [skip ci]
- **sonarr**: Pass the series ID alongside all UpdateAndScan events when publishing to the networking channel
- **sonarr**: pass the series ID alongside all TriggerAutomaticSeriesSearch events when publishing to the networking channel
- **sonarr**: Pass the series ID and season number alongside all TriggerAutomaticSeasonSearch events when publishing to the networking channel
- **sonarr**: Pass the episode ID alongside all TriggerAutomaticEpisodeSearch events when publishing to the networking channel
- **sonarr**: Pass the episode ID alongside all ToggleEpisodeMonitoring events when publishing to the networking channel
- **sonarr**: Pass the series ID and season number alongside all toggle season monitoring events when publishing to the networking channel
- **sonarr**: Pass the indexer ID directly alongside all TestIndexer events when publishing to the networking channel
- **sonarr**: Provide the task name directly alongside all StartTask events when publishing to the networking channel
- **sonarr**: Pass the search query directly to the networking channel when searching for a new series
- **sonarr**: Pass the series ID alongside all GetSeriesHistory events when publishing to the networking channel
- **sonarr**: Pass the series ID alongside all GetSeriesDetails events when publishing to the networking channel
- **sonarr**: Pass series ID and season number alongside all ManualSeasonSearch events when publishing to the networking channel
- **sonarr**: Provide the series ID and season number alongside all GetSeasonHistory events when publishing to the networking channel
- **sonarr**: Pass the episode ID alongside all ManualEpisodeSearch events when publishing to the networking channel
- **sonarr**: Pass events alongside all GetLogs events when publishing to the networking channel
- **sonarr**: Pass the episode ID alongside all GetEpisodeHistory events when publishing to the networking channel
- **sonarr**: Pass series ID alongside all GetEpisodeFiles events when publishing to the networking channel
- **sonarr**: Pass series ID alognside all GetEpisodes events when publishing to the networking channel
- **sonarr**: Pass the episode ID alongside all GetEpisodeDetails events when publishing to the networking channel
- **sonarr**: Pass history events alongside all GetHistory events when publishing to the networking channel
- **sonarr**: Construct and pass edit series parameters alongside all EditSeries events when publishing to the networking channel
- **sonarr**: Construct and pass edit indexer parameters alongside all EditIndexer events when publishing to the networking channel
- **sonarr**: Construct and pass edit all indexer settings alongside all EditAllIndexerSettings events when publishing to the networking channel
- **sonarr**: Construct and pass delete series params alongside all DeleteSeries events when publishing to the networking channel
- **sonarr**: Corrected a bug that would cause a crash if a user spams the ESC key while searching for a new series and the search results are still loading
- **sonarr**: Pass the root folder ID alongside all DeleteRootFolder events when publishing to the networking channel
- **sonarr**: Pass the indexer ID alongside all DeleteIndexer events when publishing to the networking channel
- **sonarr**: Pass the episode file ID alongside all DeleteEpisodeFile events when publishing to the networking channel
- **sonarr**: Pass the download ID alongside all DeleteDownload events published to the networking channel
- **sonarr**: Pass the blocklist item ID alongside the DeleteBlocklistItem event when publishing to the networking channel
- **sonarr**: Construct and pass the add series body alongside AddSeries events when publishing to the networking channel
- **sonarr**: Construct and pass the AddRootFolderBody alongside all AddRootFolder events when publishing to the networking channel
- **radarr**: Pass the movie ID alongside all UpdateAndScan events published to the networking channel
- **radarr**: Provide the movie ID alongside all TriggerAutomaticMovieSearch events when publishing to the networking channel
- **radarr**: Pass in the indexer id with all TestIndexer events when publishing to the networking channel
- **radarr**: Pass in the task name alongside the StartTask event when publishing to the networking channel
- **radarr**: Pass in the search query for the SearchNewMovie event when publishing to the networking channel
- **radarr**: Pass in the movie ID alongside the GetReleases event when publishing to the networking channel
- **radarr**: Pass in the movie ID alongside the GetMovieHistory event when publishing to the networking channel
- **radarr**: Pass the movie ID in alongside the GetMovieDetaisl event when publishing to the networking channel
- **radarr**: Provide the movie id alongside the GetMovieCredits event when publishing to the networking channel
- **radarr**: Pass the number of log events to fetch in with the GetLogs event when publishing to the networking channel
- **radarr**: Construct and pass the edit movie parameters alongside the EditMovie event when publishing to the networking channel
- **radarr**: Construct and pass params when publishing the EditIndexer event to the networking channel
- **radarr**: Construct and pass edit collection parameters alongside the EditCollection event when publishing to the networking channel
- **radarr**: Build and pass the edit indexer settings body with the EditAllIndexerSettings event when publishing to the networking channel
- **radarr**: Send the parameters alongside the DownloadRelease event when publishing to the networking channel
- **radarr**: Pass the root folder ID in with the DeleteRootFolder event when publishing to the networking channel
- Pass the delete movie params in with the DeleteMovie event when publishing to the networking channel
- Pass the indexer ID in with the DeleteIndexer event when sending to the networking channel
- Pass the download ID directly in the DeleteDownload event when publishing into the networking channel
- Blocklist Item ID passed in the DeleteBlocklistItem event when sent to the networking channel
- AddRootFolderBody now constructed prior to AddRootFolder event being sent down the network channel
- Cancel all requests when switching Servarr tabs to both improve performance and fix issue #15
- **add_movie_handler_tests**: Added in a forgotten test for the build_add_movie_body function
- Missing tagged version of docker builds in release flow
- AddMovie Radarr event is now populated in the dispatch thread before being sent to the network thread
- dynamically load servarrs in UI based on what configs are provided
## v0.4.1 (2024-12-14)
### Feat
- **docs**: Updated the README with new screeshots for the Sonarr release
- **handler**: Support for toggling the monitoring status of a specified episode in the Sonarr UI
- **handlers**: Support for toggling the monitoring status of a season in the Sonarr UI
- **keybindings**: Added a new keybinding for toggling the monitoring of a highlighted table item
- **cli**: Support for toggling monitoring on a specific episode in Sonarr
- **network**: Support for toggling the monitoring status of an episode in Sonarr
- **cli**: Support for toggling monitoring for a specific season in Sonarr
- **network**: Support for toggling monitoring/unmonitoring a season
- **handlers**: Support for the episode details popup
- **ui**: Support for the episode details UI
- **handler**: Full handler support for the Season details UI in Sonarr
- **ui**: Sonarr support for viewing season details
- **cli**: Sonarr support for fetching a list of all episode files for a given series ID
- **app**: Dispatch support for Season Details to fetch both the current downloads as well as the episode files to match qualities to them
- **network**: Support for fetching all episode files for a given series
- **app**: Model and modal support for the season and episode details popups
- **cli**: Sonarr support for fetching season history events
- **network**: Sonarr support for fetching season history
- **ui**: Sonarr support for the series details popup
- **ui**: Sonarr support for editing a series from within the series details popup
- **ui**: Sonarr Series details UI is now available
- **ui**: Full Sonarr system tab support
- **handler**: System handler support for Sonarr
- **ui**: Full Sonarr support for the indexer tab
- **ui**: Support for modifying the indexer priority in Radarr
- **handler**: Full indexer tab handler support
- **ui**: Root folder tab support
- **handlers**: Support for root folder actions
- **ui**: History tab support
- **handler**: History tab support
- **ui**: Blocklist UI support
- **handler**: Wired in the blocklist handler to the main handlers
- **handler**: Blocklist handler support
- **ui**: Downloads tab support
- **handler**: Download tab support
- **ui**: Edit series support
- **handler**: Edit series support
- **ui**: Add series support Sonarr
- **handler**: Add series support for Sonarr
- **ui**: Delete a series
- **handler**: Support for deleting a series in Sonarr
- **ui**: Support for the Series table
- **handlers**: Sonarr key support for the Series table
- **models**: Added the necessary contextual help and tabs for the Sonarr UI
- **ui**: Initial UI support for switching to Sonarr tabs
- **app**: Dispatch support for all relevant Sonarr blocks
### Fix
- **blocklist_handler**: Fixed a breaking change between Sonarr v3 and v4
- **style**: Addressed linter complaints on formatting
- Implemented a handful of fixes that are breaking changes between Sonarr v3 and v4
- **handler_tests**: Fixed all delegation tests to have initial conditions set properly
- **ui**: Fixed a bug that requires a minimum height for all popups so all error messages and other simple popups appear
- **handler**: Fixed a bug in the history handler that wouldn't reset the filter or search if a user hit 'esc' on the History tab
- **ui**: Fix the System Details Tasks popup to be navigable in both Sonarr and Radarr
- **ui**: Fixed a potential rare bug in the UI where the application would panic if the height of the downloads window is 0.
### Refactor
- **network**: Changed the toggle episode monitoring handler to simply return empty since the response is always empty from Sonarr
- **ui**: Tweaked some of the color schemes in the series table
- Fixed a couple of typos in some test function names
- **handlers**: Refactored the handlers to all use the handle_table_events macro when appropriate and created tests for the macro so tests don't have to be duplicated across each handler
- **ui**: Simplified the popup delegation so all future UI is easier to implement
- **indexers_handler**: Use the new handle_table_events macro
- **root_folders_handler**: Use the new handle_table_events macro
- **blocklist_handler**: Use the new handle_table_events macro
- **downloads_handler**: Use the new handle_table_events macro
- **collection_details_handler**: use the new handle_table_events macro
- **collections_handler**: Use the new handle_table_events macro
- **movie_details_handler**: Use the new handle_table_events macro
- **library_handler**: Radarr use the new handle_table_events macro
- **indexers_handler**: Use the new handle_table_events macro
- **indexers_handler**: Use the new handle_table_events macro
- **root_folder_handler**: Use the new handle_table_events macro
- **history_handler**: Use the new handle_table_event macro
- **blocklist_handler**: Use the new handle_table_events macro
- **downloads_handler**: Use the new handle_table_events macro
- **series_details_handler**: Use the new handle_table_events macro
- **handler**: Created a macro to handle all table key events to reduce code duplication and make future implementations faster; Only refactored the Sonarr library to use it thus far
- **ui**: all table search and filter functionality is now available directly through the ManagarrTable widget to make life easier moving forward
- **keys**: Created a auto search key instead of reusing the existing search key to make things easier
- **BlockSelectionState**: Refactored so selection of blocks in 2x2 grids is more intuitive and added left() and right() methods to aid this effort.
### Perf
- Improved performance by optimizing API calls to only refresh when the tick prompts a refresh. All UI is now significantly faster
## v0.3.7 (2024-11-26)
### Fix
- **ci**: Forgot to also pull in the most recent changes [skip ci]
## v0.3.6 (2024-11-26)
### Fix
- **ci**: Ensure the Release Crate job fetches the most recent commit before publishing the crate [skip ci]
## v0.3.4 (2024-11-26)
## v0.3.3 (2024-11-26)
### Fix
- **ci**: Properly prefix version tags with 'v' [skip ci]
- **ci**: Bump the version in the Cargo.lock file and commit it as well when releasing [skip ci]
## v0.3.2 (2024-11-26)
### Fix
- **ci**: Updated the Cargo.lock file [skip ci]
- **ci**: Use a different GitHub action to release the crate to Crates.io [skip ci]
- **ci**: Don't manually push the tags and let Commitizen do it [skip ci]
## v0.3.1 (2024-11-26)
### Fix
- **ci**: Don't manually push the tags and let Commitizen do it [skip ci]
- **ci**: Fixed a typo in the version creation on GitHub [skip ci]
## v0.3.0 (2024-11-26)
### Feat
- **cli**: Support for editing a sonarr series
- **models**: Added the ActiveSonarrBlocks for editing a series
- **network**: Support for editing a series in Sonarr
- **models**: Created the EditSeriesModal
- **cli**: Support for editing Sonarr indexers
- **network**: Support for editing a sonarr indexer
- **cli**: Support for deleting an episode file from disk
- **network**: Support for deleting an episode file from disk in Sonarr
- **cli**: Support for editing all indexer settings in Sonarr
- **models**: Added the ActiveSonarrBlocks for editing all indexer settings
- **network**: Support for editing all sonarr indexer settings
- **cli**: Support for searching for new series to add to Sonarr
- **network**: Support for searching for new series
- **cli**: Support for adding a series to Sonarr
- **cli**: Support for adding a series to Sonarr
- **network**: Support for adding a new series to Sonarr
- **cli**: Support for fetching all sonarr language profiles
- **network**: Support for fetching all Sonarr language profiles
- **cli**: Support for deleting a series from Sonarr
- **network**: Support for deleting a series from Sonarr
- **cli**: Support for downloading an episode release in Sonarr
- **cli**: Support for downloading a season release in Sonarr
- **cli**: Support for downloading a Series release in Sonarr
- **network**: Support for downloading releases from Sonarr
- **cli**: Support for refreshing Sonarr downloads
- **network**: Support for updating Sonarr downloads
- **cli**: Support for refreshing a specific series in Sonarr
- **network**: Support for updating and scanning a series in Sonarr
- **cli**: Support for refreshing all Sonarr series data
- **network**: Support for updating all series in Sonarr
- **cli**: Support for triggering an automatic episode search in Sonarr
- **cli**: Support for triggering an automatic season search in Sonarr
- **cli**: Support for triggering an automatic series search in Sonarr
- **network**: Support for triggering an automatic episode search in Sonarr
- **network**: Support for triggering an automatic season search in Sonarr
- **network**: Support for triggering an automatic series search in Sonarr
- **cli**: Support for testing all Sonarr indexers at once
- **network**: Support for testing all Sonarr indexers at once
- **cli**: Support for testing an individual Sonarr indexer
- **network**: Added the ability to test an individual indexer in Sonarr
- **cli**: Support for starting a Sonarr task
- **network**: Support for starting a Sonarr task
- **cli**: Support for listing Sonarr updates
- **network**: Support for fetching Sonarr updates
- **cli**: Support for listing all Sonarr tasks
- **network**: Support for fetching all Sonarr tasks
- **cli**: Support for marking a Sonarr history item as 'failed'
- **network**: Support for marking a Sonarr history item as failed
- **cli**: Support for listing the available disk space for all provisioned root folders in both Radarr and Sonarr
- **network**: Support for listing disk space on a Sonarr instance
- **cli**: Support for listing all Sonarr tags
- **cli**: Support for adding a root folder to Sonarr
- **cli**: CLI support for adding a tag to Sonarr
- **network**: Support for fetching and listing all Sonarr tags
- **network**: Support for deleting tags from Sonarr
- **network**: Support for adding tags to Sonarr
- **network**: Support for adding a root folder to Sonarr
- **cli**: Support for deleting a root folder from Sonarr
- **network**: Support for deleting a Sonarr root folder
- **cli**: Support for fetching all Sonarr root folders
- **network**: Support for fetching all Sonarr root folders
- **cli**: Support for deleting a Sonarr indexer
- **network**: Support for deleting an indexer from Sonarr
- **cli**: Support for deleting a download from Sonarr
- **network**: Support for deleting a download from Sonarr
- **cli**: Support for fetching episode history events from Sonarr
- **network**: Support for fetching episode history
- **cli**: Added a spinner to the CLI for long running commands like fetching releases
- **cli**: Support for fetching history for a given series ID
- **network**: Support for fetching Sonarr series history for a given series ID
- **cli**: Support for fetching all Sonarr history events
- **network**: Support to fetch all Sonarr history events
- **models**: Added an additional History tab to the mocked tabs for viewing all Sonarr history at once
- **models**: Stubbed out the necessary ActiveSonarrBlocks for the UI mockup
- **cli**: Added support for manually searching for episode releases in Sonarr
- **network**: Added support for fetching episode releases in Sonarr
- **cli**: Added CLI support for fetching series details in Sonarr
- **network**: Added support for fetching series details for a given series ID in Sonarr
- **cli**: Added support for manually searching for season releases for Sonarr
- **network**: Added support for fetching season releases for Sonarr
- **cli**: Added support for listing Sonarr queued events
- **network**: Added support for fetching Sonarr queued events
- **cli**: Added CLI support for fetching all indexer settings for Sonarr
- **network**: Added netwwork support for fetching all indexer settings for Sonarr
- **cli**: Added Sonarr support for fetching host and security configs
- **network**: Added network support for fetching host and security configs from Sonarr
- **cli**: Added CLI support for listing Sonarr indexers
- **network**: Added the GetIndexers network call for Sonarr
- **cli**: Added sonarr support for listing downloads, listing quality profiles, and fetching detailed information about an episode
- **network**: Added get quality profiles and get episode details events for Sonarr
- **cli**: Sonarr CLI support for fetching all episodes for a given series
- **sonarr_network**: Added support for fetching episodes for a specified series to the network events
- **models**: Added the Episode model to Sonarr models
- **models**: Created the StatefulTree struct for displaying seasons and episodes (and any other structured data) for the UI.
- **sonarr**: Added CLI support for listing Sonarr logs
- **sonarr**: Added the ability to fetch Sonarr logs
- **sonarr**: Added blocklist commands (List, Clear, Delete)
- Added initial Sonarr CLI support and the initial network handler setup for the TUI
- Added a new command to the main managarr CLI: tail-logs, to enable users to tail the Managarr logs without needing to know where the log file itself is located
### Fix
- Reverted to old version to fix release [skip ci]
- **minimal-versions**: Addressed concerns with the minimal-versions CI checks
- **lint**: Addressed linter complaints
- **cli**: Corrected some copy/paste typos
- **network**: Force sonarr to save edits to indexers
- **network**: Made the overview field nullable in the Sonarr series model
- **network**: Added filtering for full seasons specifically in the UI when performing a manual full season search and added a message to the CLI that noes to only try to download a full season if that release includes 'fullSeason: true'
- **network**: Not all Sonarr tasks return the lastDuration field and was causing a crash
- **network**: Fixed an issue with dynamic typing in responses from Sonarr for history items
- **config**: The CLI panics if the servarr you specify has no config defined
- Imported a missing macro in the panic hook
### Refactor
- **cli**: the trigger-automatic-search commands now all have their own dedicated subcommand to keep things cleaner. Now they look like 'trigger-automatic-search episode/series/season' and their corresponding flags
- **cli**: Added an additional delegation test to ensure manual-search commands are delegated to the manual-search command handler
- **cli**: Moved the manual-season-search and manual-episode-search commands into their own dedicated handler so the commands can now be manual-search episode or manual-search season
## v0.2.2 (2024-11-06)
### Fix
- **handler**: Fixed a bug in the movie details handler that would allow key events to be processed before the data was finished loading
- **ui**: Fixed a bug that would freeze all user input while background network requests were running
- **radarr_ui**: Fixed a race condition bug in the movie details UI that would panic if the user changes tabs too quickly
### Perf
- **network**: Improved performance and reactiveness of the UI by speeding up network requests and clearing the channel whenever a request is cancelled/the UI is routing
## v0.2.1 (2024-11-06)
## [0.2.1](https://github.com/Dark-Alex-17/managarr/compare/v0.2.0...v0.2.1) - 2024-11-06
### Other
- Removed the need for use_ssl to indicate SSL usage; instead just use the ssl_cert_path
- Applied bug fix to the downloads tab as well as the context [skip ci]
- Updated the README to not include the GitHub downloads badge since all binary releases are on crates.io [skip ci]
- Set all releases as manually triggered instead of automatic [skip ci]
- Updated dockerfile to no longer use the --disable-terminal-size-checks flag [skip ci]
## [0.1.5](https://github.com/Dark-Alex-17/managarr/compare/v0.1.4...v0.1.5) - 2024-11-03
+72 -2
View File
@@ -1,6 +1,24 @@
# Contributing
Contributors are very welcome! **No contribution is too small and all contributions are valued.**
## License and Attribution
#### _If you plan on contributing to the base project, no attribution is needed!_ Feel free to proceed to the [next steps](CONTRIBUTING.md#Rust).
Otherwise, below are key points to understand from the [Managarr License, Version 1.0](LICENSE):
1. **Non-Commercial Use**:
- Managarr is licensed solely for non-commercial purposes. Any commercial use of Managarr (e.g., selling or offering as a paid service) requires separate permission.
2. **Attribution when Forking and Redistributing Without Contributing back to Main Project**:
- **If you fork the project and distribute it separately** (e.g., publish or _publicly_ host it independently from the original project), you are required to provide attribution.
- You may credit the original author by using any of the following phrasing:
- "Thanks to Alexander J. Clarke (Dark-Alex-17) for creating the original Managarr project!"
- "Forked from the Managarr project, created by Alexander J. Clarke (Dark-Alex-17)"
- "This software is based on the original Managarr project by Alexander J. Clarke (Dark-Alex-17)"
- "Inspired by Alexander J. Clarke (Dark-Alex-17)'s Managarr project"
- If changes are made to the base Managarr project, please note those modifications and provide the new attribution accordingly.
## Rust
You'll need to have the stable Rust toolchain installed in order to develop Managarr.
The Rust toolchain (stable) can be installed via rustup using the following command:
@@ -11,15 +29,67 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This will install `rustup`, `rustc` and `cargo`. For more information, refer to the [official Rust installation documentation](https://www.rust-lang.org/tools/install).
## Commitizen
[Commitizen](https://github.com/commitizen-tools/commitizen?tab=readme-ov-file) is a nifty tool that helps us write better commit messages. It ensures that our
commits have a consistent style and makes it easier to generate CHANGELOGS. Additionally,
Commitizen is used to run pre-commit checks to enforce style constraints.
To install `commitizen` and the `pre-commit` prerequisite, run the following command:
```shell
python3 -m pip install commitizen pre-commit
```
### Commitizen Quick Guide
To see an example commit to get an idea for the Commitizen style, run:
```shell
cz example
```
To see the allowed types of commits and their descriptions, run:
```shell
cz info
```
If you'd like to create a commit using Commitizen with an interactive prompt to help you get
comfortable with the style, use:
```shell
cz commit
```
## Setup workspace
1. Clone this repo
2. Run `cargo test` to setup hooks
2. Run `cargo test` to set up hooks
3. Make changes
4. Run the application using `make run` or `cargo run`
4. Run the application using `just run` or `just run`
- Install `just` (`cargo install just`) if you haven't already to use the [justfile](./justfile) in this project.
5. Commit changes. This will trigger pre-commit hooks that will run format, test and lint. If there are errors or warnings from Clippy, please fix them.
6. Push your code to a new branch named after the feature/bug/etc. you're adding. This will trigger pre-push hooks that will run lint and test.
7. Create a PR
### CI/CD Testing with Act
If you also are planning on testing out your changes before pushing them with [Act](https://github.com/nektos/act), you will need to set up `act`,
`docker`, and configure your local system to run different architectures:
1. Install `docker` by following the instructions on the [official Docker installation page](https://docs.docker.com/get-docker/).
2. Install `act` by following the instructions on the [official Act installation page](https://nektosact.com/installation/index.html).
3. Install `binfmt` on your system once so that `act` can run the correct architecture for the CI/CD workflows.
You can do this by running:
```shell
sudo docker run --rm --privileged tonistiigi/binfmt --install all
```
Then, you can run workflows locally without having to commit and see if the GitHub action passes or fails.
**For example**: To test the [release.yml](.github/workflows/release.yml) workflow locally, you can run:
```shell
act -W .github/workflows/release.yml --input_type bump=minor
```
## Questions? Reach out to me!
If you encounter any questions while developing Managarr, please don't hesitate to reach out to me at alex.j.tusa@gmail.com. I'm happy to help contributors, new and experienced in any way I can!
Generated
+2418 -843
View File
File diff suppressed because it is too large Load Diff
+62 -34
View File
@@ -1,6 +1,6 @@
[package]
name = "managarr"
version = "0.2.0"
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"]
@@ -8,53 +8,79 @@ documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr"
readme = "README.md"
edition = "2021"
edition = "2024"
license = "MIT"
rust-version = "1.82.0"
rust-version = "1.89.0"
exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"]
[workspace]
members = [
"proc_macros/enum_display_style_derive",
"proc_macros/validate_theme_derive",
]
[dependencies]
anyhow = "1.0.68"
backtrace = "0.3.67"
anyhow = "1.0.100"
backtrace = "0.3.76"
bimap = { version = "0.6.3", features = ["serde"] }
chrono = { version = "0.4.38", features = ["serde"] }
confy = { version = "0.6.0", default-features = false, features = [
chrono = { version = "0.4.43", features = ["serde"] }
confy = { version = "2.0.0", default-features = false, features = [
"yaml_conf",
] }
crossterm = "0.27.0"
crossterm = "0.28.1"
derivative = "2.2.0"
human-panic = "1.1.3"
indoc = "2.0.0"
log = "0.4.17"
log4rs = { version = "1.2.0", features = ["file_appender"] }
regex = "1.11.1"
reqwest = { version = "0.11.14", features = ["json"] }
serde_yaml = "0.9.16"
serde_json = "1.0.91"
serde = { version = "1.0.214", features = ["derive"] }
human-panic = "2.0.6"
indoc = "2.0.7"
log = "0.4.29"
log4rs = { version = "1.4.0", features = ["file_appender"] }
regex = "1.12.2"
reqwest = { version = "0.12.28", features = ["json"] }
serde_yaml = "0.9.34"
serde_json = "1.0.149"
serde = { version = "1.0.228", features = ["derive"] }
strum = { version = "0.26.3", features = ["derive"] }
strum_macros = "0.26.4"
tokio = { version = "1.36.0", features = ["full"] }
tokio-util = "0.7.8"
ratatui = { version = "0.28.0", features = ["all-widgets"] }
urlencoding = "2.1.2"
clap = { version = "4.5.20", features = ["derive", "cargo", "env"] }
clap_complete = "4.5.33"
itertools = "0.13.0"
ctrlc = "3.4.5"
colored = "2.1.0"
async-trait = "0.1.83"
tokio = { version = "1.49.0", features = ["full"] }
tokio-util = "0.7.18"
ratatui = { version = "0.30.0", features = [
"all-widgets",
"unstable-widget-ref",
] }
urlencoding = "2.1.3"
clap = { version = "4.5.56", features = [
"derive",
"cargo",
"env",
"wrap_help",
] }
clap_complete = "4.5.65"
itertools = "0.14.0"
ctrlc = "3.5.1"
colored = "3.1.1"
async-trait = "0.1.89"
dirs-next = "2.0.0"
managarr-tree-widget = "0.25.0"
indicatif = "0.17.11"
derive_setters = "0.1.9"
deunicode = "1.6.2"
openssl = { version = "0.10.75", features = ["vendored"] }
veil = "0.2.0"
validate_theme_derive = "0.1.0"
enum_display_style_derive = "0.1.0"
[dev-dependencies]
assert_cmd = "2.0.16"
mockall = "0.13.0"
mockito = "1.0.0"
pretty_assertions = "1.3.0"
rstest = "0.18.2"
assert_cmd = "2.1.2"
mockall = "0.13.1"
mockito = "1.7.1"
pretty_assertions = "1.4.1"
proptest = "1.9.0"
rstest = "0.25.0"
serial_test = "3.3.1"
assertables = "9.8.4"
insta = "1.46.1"
[dev-dependencies.cargo-husky]
version = "1"
version = "1.5.0"
default-features = false
features = ["user-hooks"]
@@ -65,4 +91,6 @@ name = "managarr"
[profile.release]
lto = true
codegen-units = 1
codegen-units = 3
opt-level = "s"
strip = true
+7 -6
View File
@@ -1,4 +1,4 @@
FROM clux/muslrust:stable AS builder
FROM rust:1.89 AS builder
WORKDIR /usr/src
# Download and compile Rust dependencies in an empty project and cache as a separate Docker layer
@@ -6,21 +6,22 @@ RUN USER=root cargo new --bin managarr-temp
WORKDIR /usr/src/managarr-temp
COPY Cargo.* .
RUN cargo build --release --target x86_64-unknown-linux-musl
COPY proc_macros ./proc_macros
RUN cargo build --release
# remove src from empty project
RUN rm -r src
COPY src ./src
# remove previous deps
RUN rm ./target/x86_64-unknown-linux-musl/release/deps/managarr*
RUN rm ./target/release/deps/managarr*
RUN --mount=type=cache,target=/volume/target \
--mount=type=cache,target=/root/.cargo/registry \
cargo build --release --target x86_64-unknown-linux-musl --bin managarr
RUN mv target/x86_64-unknown-linux-musl/release/managarr .
cargo build --release --bin managarr
RUN mv target/release/managarr .
FROM debian:stable-slim
# Copy the compiled binary from the builder container
COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin
ENTRYPOINT [ "/usr/local/bin/managarr", "--disable-terminal-size-checks" ]
ENTRYPOINT [ "/usr/local/bin/managarr" ]
+47 -10
View File
@@ -1,16 +1,53 @@
MIT License
Managarr License
Version 1.0, 2025
Copyright (c) 2023 Alexander J. Clarke
Copyright (c) 2025 Alexander J. Clarke (Dark-Alex-17)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to use,
copy, modify, merge, publish, and distribute the Software solely for
non-commercial purposes, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Attribution:
- The above copyright notice, this permission notice, and a prominent notice stating
that the Software is part of the "Managarr" project shall be included in all copies or
substantial portions of the Software **when the Software is forked and redistributed separately** from the original project.
- If you fork the software and **distribute it separately** without merging it back into the original base project (the Managarr repository), you must provide attribution to the original author.
You may use any of the following forms of attribution:
- "Thanks to Alexander J. Clarke (Dark-Alex-17) for creating the original Managarr project!"
- "Forked from the Managarr project, created by Alexander J. Clarke (Dark-Alex-17)"
- "This software is based on the original Managarr project by Alexander J. Clarke (Dark-Alex-17)"
- "Inspired by Alexander J. Clarke (Dark-Alex-17)'s Managarr project"
- If you modify the software, the attribution must also note that changes were made and describe those modifications, if feasible.
2. Non-Commercial Use Only:
The use of this Software for commercial purposes, including but not limited
to sale, licensing, or use in any product or service for monetary
compensation, is strictly prohibited without prior written permission from
Alexander J. Clarke (Dark-Alex-17).
For avoidance of doubt:
- **Allowed:** Private use, educational purposes, research, or any usage
that does not directly generate revenue.
- **Prohibited:** Selling, sublicensing, or incorporating the Software into
commercial products or services.
3. Modifications and Derivatives:
- Any modifications or derivative works based on this Software must clearly
document all changes made and prominently credit the original "Managarr"
project as described in section 1.
- Derivative works must retain this license, the original copyright notice,
and all terms and conditions described herein. This applies to the entire
derivative work, even if combined with other software.
4. Warranty Disclaimer:
This Software is provided "as is," without warranty of any kind, express
or implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose, and noninfringement. In no event shall the
authors or copyright holders be liable for any claim, damages, or other
liability, whether in an action of contract, tort, or otherwise, arising
from, out of, or in connection with the Software or the use or other
dealings in the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-47
View File
@@ -1,47 +0,0 @@
#!make
VERSION := latest
IMG_NAME := darkalex17/managarr
IMAGE := ${IMG_NAME}:${VERSION}
default: run
.PHONY: test test-cov build run lint lint-fix fmt analyze sonar release delete-tag
test:
@cargo test
## Run all tests with coverage - `cargo install cargo-tarpaulin`
test-cov:
@cargo tarpaulin
build: test
@cargo build --release
docker:
@DOCKER_BUILDKIT=1 docker build --rm -t ${IMAGE} .
run:
@CARGO_INCREMENTAL=1 cargo fmt && make lint && cargo run
lint:
@find . | grep '\.\/src\/.*\.rs$$' | xargs touch && CARGO_INCREMENTAL=0 cargo clippy --all-targets --workspace
lint-fix:
@cargo fix
fmt:
@cargo fmt
minimal-versions:
@cargo +nightly update -Zdirect-minimal-versions
## Analyze for unsafe usage - `cargo install cargo-geiger`
analyze:
@cargo geiger
release:
@git tag -a ${V} -m "Release ${V}" && git push origin ${V}
delete-tag:
@git tag -d ${V} && git push --delete origin ${V}
+341 -124
View File
@@ -1,32 +1,33 @@
# managarr - A TUI and CLI to manage your Servarrs
![check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/release.yml/badge.svg)
![Check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg)
![Test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg)
![License](https://img.shields.io/badge/license-MIT-blueviolet.svg)
![LOC](https://tokei.rs/b1/github/Dark-Alex-17/managarr?category=code)
[![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr)
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff)
[![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr)
[![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/managarr/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/managarr/releases)
![Crate.io downloads](https://img.shields.io/crates/d/managarr?label=Crate%20downloads)
[![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/managarr/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/managarr/releases)
![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/library.png)
![library](screenshots/lidarr/lidarr_library.png)
## What Servarrs are supported?
- [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr)
- [ ] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr)
- [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/)
- [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/)
## Try Before You Buy
## Try Out the Demo
To try out Managarr before linking it to your HTPC, you can use the purpose built [managarr-demo](https://github.com/Dark-Alex-17/managarr-demo) repository.
Simply run the following command to start a demo:
@@ -34,7 +35,10 @@ Simply run the following command to start a demo:
curl https://raw.githubusercontent.com/Dark-Alex-17/managarr-demo/main/managarr-demo.sh > /tmp/managarr-demo.sh && bash /tmp/managarr-demo.sh
```
Alternatively, you can try out the demo container without downloading anything by visiting the [Managarr Demo site](https://managarr-demo.alexjclarke.com).
## Installation
### Cargo
If you have Cargo installed, then you can install Managarr from Crates.io:
@@ -48,43 +52,159 @@ cargo install --locked managarr
### Docker
Run Managarr as a docker container by mounting your `config.yml` file to `/root/.config/managarr/config.yml`. For example:
```shell
docker run --rm -it -v ~/.config/managarr:/root/.config/managarr darkalex17/managarr
docker run --rm -it -v /home/aclarke/.config/managarr/config.yml:/root/.config/managarr/config.yml darkalex17/managarr:latest
```
You can also clone this repo and run `make docker` to build a docker image locally and run it using the above command.
Please note that you will need to create and popular your configuration file first before starting the container. Otherwise, the container will fail to start.
**Note:** If you run into errors using relative file paths when mounting the volume with the configuration file, try using an absolute path.
### Homebrew (Mac and Linux)
To install Managarr from Homebrew, install the Managarr tap. Then you'll be able to install Managarr:
```shell
brew tap Dark-Alex-17/managarr
brew install managarr
# If you need to be more specific, use the following:
brew install Dark-Alex-17/managarr/managarr
```
To upgrade to a newer version of Managarr:
```shell
brew upgrade managarr
```
### Nix (Externally Maintained)
To install Managarr on NixOS, you can use the following command:
```shell
nix-env --install managarr
# Alternatively, for non-NixOS users, you can spawn a temporary shell with Managarr available like so:
nix-shell -p managarr
```
### Chocolatey (Windows)
The Managarr Chocolatey package is located [here](https://community.chocolatey.org/packages/managarr). Please note that validation
of Chocolatey packages take quite some time, and thus the package may not be available immediately after a new release.
```powershell
choco install managarr
# Some newer releases may require a version number, so you can specify it like so:
choco install managarr --version=0.7.0
```
To upgrade to the latest and greatest version of Managarr:
```powershell
choco upgrade managarr
# To upgrade to a specific version:
choco upgrade managarr --version=0.7.0
```
### Manual
Binaries are available on the [releases](https://github.com/Dark-Alex-17/managarr/releases) page for the following platforms:
| Platform | Architecture(s) |
|----------------|----------------------------|
| macOS | x86_64, arm64 |
| Linux GNU/MUSL | x86_64,armv6,armv7,aarch64 |
| Windows | x86_64,aarch64 |
#### Windows Instructions
To use a binary from the releases page on Windows, do the following:
1. Download the latest [binary](https://github.com/Dark-Alex-17/managarr/releases) for your OS.
2. Use 7-Zip or TarTool to unpack the Tar file.
3. Run the executable `managarr.exe`!
#### Linux/MacOS Instructions
To use a binary from the releases page on Linux/MacOS, do the following:
1. Download the latest [binary](https://github.com/Dark-Alex-17/managarr/releases) for your OS.
2. `cd` to the directory where you downloaded the binary.
3. Extract the binary with `tar -C /usr/local/bin -xzf managarr-<arch>.tar.gz` (Note: This may require `sudo`)
4. Now you can run `managarr`!
## Features
Key:
| Symbol | Status |
|--------|-----------|
| ✅ | Supported |
| ❌ | Missing |
| 🕒 | Planned |
| 🚫 | Won't Add |
### Radarr
- [x] View your library, downloads, collections, and blocklist
- [x] View details of a specific movie including description, history, downloaded file info, or the credits
- [x] View details of any collection and the movies in them
- [x] View your host and security configs from the CLI to programmatically fetch the API token, among other settings
- [x] Search your library or collections
- [x] Add movies to your library
- [x] Delete movies, downloads, and indexers
- [x] Trigger automatic searches for movies
- [x] Trigger refresh and disk scan for movies, downloads, and collections
- [x] Manually search for movies
- [x] Edit your movies, collections, and indexers
- [x] Manage your tags
- [x] Manage your root folders
- [x] Manage your blocklist
- [x] View and browse logs, tasks, events queues, and updates
- [x] Manually trigger scheduled tasks
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Sonarr
- [ ] Support for Sonarr
| TUI | CLI | Feature |
|-----|-----|--------------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### 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
@@ -101,19 +221,32 @@ You can also clone this repo and run `make docker` to build a docker image local
- [ ] Support for Tautulli
### Themes
Managarr ships with a few themes out of the box. Here's a few examples:
#### Default
![default](themes/default/manual_episode_search.png)
#### Dracula
![dracula](themes/dracula/manual_episode_search.png)
#### Watermelon Dark
![watermelon-dark](themes/watermelon-dark/manual_episode_search.png)
You can also create your own custom themes as well. To learn more about what themes are built-in to Managarr and how
to create your own custom themes, check out the [Themes README](themes/README.md).
### The Managarr CLI
Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your Servarrs.
All management features available in the TUI are also available in the CLI. However, the CLI is
equipped with additional features to allow for more advanced usage and automation.
The CLI can be helpful for automating tasks or for use in scripts. For example, you can use the CLI to trigger a search for a movie, or to add a movie to your library.
The CLI can be helpful for automating tasks or for use in scripts. For example, you can use the CLI to trigger a search for a movie, or to add a movie to your Radarr library.
To see all available commands, simply run `managarr --help`:
```shell
$ managarr --help
managarr 0.1.5
managarr 0.7.0
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
@@ -122,43 +255,79 @@ 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
config-path Print the full path to the default configuration file.
This file can be changed to another location using the '--config-file' flag
help Print this message or the help of the given subcommand(s)
Options:
--config <CONFIG> The Managarr configuration file to use
-h, --help Print help
-V, --version Print version
-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; defaults to the
path shown by 'managarr config-path' [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.
```
All subcommands also have detailed help menus to show you how to use them. For example, to see all available commands for Radarr, you would run:
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:
```shell
$ managarr radarr --help
Commands for manging your Radarr instance
$ managarr sonarr --help
Commands for manging your Sonarr instance
Usage: managarr radarr [OPTIONS] <COMMAND>
Usage: managarr sonarr [OPTIONS] <COMMAND>
Commands:
add Commands to add or create new resources within your Radarr instance
delete Commands to delete resources from your Radarr instance
edit Commands to edit resources in your Radarr instance
get Commands to fetch details of the resources in your Radarr instance
list Commands to list attributes from your Radarr instance
refresh Commands to refresh the data in your Radarr instance
clear-blocklist Clear the blocklist
download-release Manually download the given release for the specified movie ID
manual-search Trigger a manual search of releases for the movie with the given ID
search-new-movie Search for a new film to add to Radarr
start-task Start the specified Radarr task
test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'
test-all-indexers Test all indexers
trigger-automatic-search Trigger an automatic search for the movie with the specified ID
help Print this message or the help of the given subcommand(s)
add Commands to add or create new resources within your Sonarr instance
delete Commands to delete resources from your Sonarr instance
edit Commands to edit resources in your Sonarr instance
get Commands to fetch details of the resources in your Sonarr instance
download Commands to download releases in your Sonarr instance
list Commands to list attributes from your Sonarr instance
refresh Commands to refresh the data in your Sonarr instance
manual-search Commands to manually search for releases
trigger-automatic-search Commands to trigger automatic searches for releases of different resources in your Sonarr instance
clear-blocklist Clear the blocklist
mark-history-item-as-failed Mark the Sonarr history item with the given ID as 'failed'
search-new-series Search for a new series to add to Sonarr
start-task Start the specified Sonarr task
test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'
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:
--config <CONFIG> The Managarr configuration file to use
-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:
@@ -172,21 +341,11 @@ $ managarr radarr list movies | jq '.[] | select(.title == "Ad Astra") | .id'
Managarr assumes reasonable defaults to connect to each service (i.e. Radarr is on localhost:7878),
but all servers will require you to input the API token.
The configuration file is located somewhere different for each OS
The configuration file is located somewhere different for each OS, so run the following command to print out the default
location of the `managarr` configuration file for your system:
### Linux
```
$HOME/.config/managarr/config.yml
```
### Mac
```
$HOME/Library/Application Support/managarr/config.yml
```
### Windows
```
%APPDATA%/Roaming/managarr/config.yml
```shell
managarr config-path
```
## Specify Which Configuration File to Use
@@ -195,73 +354,131 @@ where you may have more than one instance of a given Servarr running. Thus, you
config file using the `--config` flag:
```shell
managarr --config /path/to/config.yml
managarr --config-file /path/to/config.yml
```
### Example Configuration:
```yaml
theme: default
radarr:
host: 127.0.0.1
port: 7878
api_token: someApiToken1234567890
use_ssl: true
ssl_cert_path: /path/to/radarr.crt
- host: 192.168.0.78
port: 7878
api_token: someApiToken1234567890
ssl_cert_path: /path/to/radarr.crt # Required to enable SSL
- uri: http://htpc.local/radarr # Example of using the 'uri' key instead of 'host' and 'port'
api_token: someApiToken1234567890
sonarr:
host: 127.0.0.1
port: 8989
api_token: someApiToken1234567890
readarr:
host: 127.0.0.1
port: 8787
api_token: someApiToken1234567890
use_ssl: false
- host: 192.168.0.89
port: 8989
api_token_file: /root/.config/sonarr_api_token # Example of loading the API token from a file instead of hardcoding it in the configuration file
- name: Anime Sonarr # An example of a custom name for a secondary Sonarr instance
host: 192.168.1.89
port: 8989
api_token: someApiToken1234567890
lidarr:
host: 127.0.0.1
port: 8686
api_token: someApiToken1234567890
use_ssl: false
whisparr:
host: 127.0.0.1
port: 6969
api_token: someApiToken1234567890
use_ssl: false
bazarr:
host: 127.0.0.1
port: 6767
api_token: someApiToken1234567890
use_ssl: false
prowlarr:
host: 127.0.0.1
port: 9696
api_token: someApiToken1234567890
use_ssl: false
tautulli:
host: 127.0.0.1
port: 8181
api_token: someApiToken1234567890
use_ssl: false
- host: 192.168.0.86
port: 8686
api_token: ${MY_LIDARR_API_TOKEN} # Example of configuring using environment variables
monitored_storage_paths: # Filter which Root Folders or Disk Storage you want displayed in the UI's 'Stats' block
# Note: Setting these values does not affect what shows up in the 'Root Folders' tab of the UI.
- /nfs # An example disk (i.e. '<servarr> list disk-space' command) you want displayed in the UI under 'Storage:'
- /media # An example root folder you want displayed in the UI
# Root folders collapse up to the super-directory to reduce duplication in the UI. For example:
# if you have root folders '/media/tv', '/media/cartoons' and '/media/reality', and you set this
# monitored path, the UI will show '/media/[tv,cartoons,reality]' under Root Folders
- host: 192.168.1.86
port: 8686
api_token: someApiToken1234567890
ssl_cert_path: /path/to/lidarr_1.crt
custom_headers: # Example of adding custom headers to all requests to the Servarr instance
traefik-auth-bypass-key: someBypassKey1234567890
SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE}
```
### Example Multi-Instance Configuration:
```yaml
theme: default
radarr:
- host: 192.168.0.78 # No name specified, so this instance's name will default to 'Radarr 1'
port: 7878
api_token: someApiToken1234567890
ssl_cert_path: /path/to/radarr.crt
- name: International Movies
host: 192.168.0.79
port: 7878
api_token: someApiToken1234567890
sonarr:
- name: Anime
weight: 1 # This instance will be the first tab in the TUI
uri: http://htpc.local/sonarr
api_token: someApiToken1234567890
- name: TV Shows
weight: 2 # This instance will be the second tab in the TUI
host: 192.168.0.89
port: 8989
api_token: someApiToken1234567890
```
In this configuration, you can see that we have multiple instances of Radarr and Sonarr configured. The `weight` key is
used to specify the order in which the tabs will appear in the TUI. The lower the weight, the further to the left the
tab will appear. If no weight is specified, then tabs will be ordered in the order they appear in the configuration
file.
When no `name` is specified for a Servarr instance, the name will default to the name of the Servarr with a number
appended to it. For example, if you have two Radarr instances and neither has a name, they will be named `Radarr 1` and
`Radarr 2`, respectively.
In this example configuration, the tabs in the TUI would appear as follows:
`Anime | TV Shows | Radarr 1 | International Movies`
### Specify Which Servarr Instance to Use in the CLI
If you have multiple instances of the same Servarr running, you can specify which instance you want to use by using the `--servarr-name` flag:
```shell
managarr --servarr-name "International Movies"
```
## Environment Variables
Managarr supports using environment variables on startup so you don't have to always specify certain flags:
| Variable | Description | Equivalent Flag |
| --------------------------------------- | -------------------------------- | -------------------------------- |
| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` |
## Track My Progress for the Beta release (With Sonarr Support!)
Progress for the beta release can be followed on my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr)
with all items tagged `Beta`.
| Variable | Description | Equivalent Flag |
|-----------------------------------------|--------------------------------------------------------------------------------|----------------------------------|
| `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` |
## Screenshots
![library](screenshots/library.png)
![manual_search](screenshots/manual_search.png)
![logs](screenshots/logs.png)
![new_movie_search](screenshots/new_movie_search.png)
![add_new_movie](screenshots/add_new_movie.png)
![collection_details](screenshots/collection_details.png)
![indexers](screenshots/indexers.png)
### Radarr
![radarr_library](screenshots/radarr/radarr_library.png)
![manual_search](screenshots/radarr/manual_search.png)
![new_movie_search](screenshots/radarr/new_movie_search.png)
![add_new_movie](screenshots/radarr/add_new_movie.png)
![collection_details](screenshots/radarr/collection_details.png)
### Sonarr
![sonarr_library](screenshots/sonarr/sonarr_library.png)
![series_details](screenshots/sonarr/series_details.png)
![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)
## Dependencies
* [ratatui](https://github.com/tui-rs-revival/ratatui)
@@ -273,9 +490,9 @@ with all items tagged `Beta`.
## Servarr Requirements
* [Radarr >= 5.3.6.8612](https://radarr.video/docs/api/)
* [Sonarr >= v3](https://sonarr.tv/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Sonarr >= v4](https://sonarr.tv/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)
+9 -1
View File
@@ -1,6 +1,14 @@
coverage:
range: "80..100"
status:
project:
default:
threshold: 0
target: 80%
patch:
default:
threshold: 0
target: 80%
ignore:
- "**/*_tests.rs"
- "src/ui"
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop';
$PackageName = 'managarr'
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$url64 = 'https://github.com/Dark-Alex-17/managarr/releases/download/v$version/managarr-windows.tar.gz'
$checksum64 = '$hash_64'
$packageArgs = @{
packageName = $packageName
softwareName = $packageName
unzipLocation = $toolsDir
fileType = 'exe'
url = $url64
checksum = $checksum64
checksumType = 'sha256'
}
Install-ChocolateyZipPackage @packageArgs
$File = Get-ChildItem -File -Path $env:ChocolateyInstall\lib\$packageName\tools\ -Filter *.tar
Get-ChocolateyUnzip -fileFullPath $File.FullName -destination $env:ChocolateyInstall\lib\$packageName\tools\
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Read this before creating packages: https://chocolatey.org/docs/create-packages -->
<!-- It is especially important to read the above link to understand additional requirements when publishing packages to the community feed aka dot org (https://chocolatey.org/packages). -->
<!-- Test your packages in a test environment: https://github.com/chocolatey/chocolatey-test-environment -->
<!--
This is a nuspec. It mostly adheres to https://docs.nuget.org/create/Nuspec-Reference. Chocolatey uses a special version of NuGet.Core that allows us to do more than was initially possible. As such there are certain things to be aware of:
* the package xmlns schema url may cause issues with nuget.exe
* Any of the following elements can ONLY be used by choco tools - projectSourceUrl, docsUrl, mailingListUrl, bugTrackerUrl, packageSourceUrl, provides, conflicts, replaces
* nuget.exe can still install packages with those elements but they are ignored. Any authoring tools or commands will error on those elements
-->
<!-- You can embed software files directly into packages, as long as you are not bound by distribution rights. -->
<!-- * If you are an organization making private packages, you probably have no issues here -->
<!-- * If you are releasing to the community feed, you need to consider distribution rights. -->
<!-- Do not remove this test for UTF-8: if “Ω” doesnt appear as greek uppercase omega letter enclosed in quotation marks, you should use an editor that supports UTF-8, not this one. -->
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<!-- == PACKAGE SPECIFIC SECTION == -->
<id>managarr</id>
<version>$version</version>
<!-- == SOFTWARE SPECIFIC SECTION == -->
<!-- This section is about the software itself -->
<title>Managarr</title>
<authors>Alex Clarke</authors>
<projectUrl>https://github.com/Dark-Alex-17/managarr</projectUrl>
<licenseUrl>https://github.com/Dark-Alex-17/managarr/blob/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>true</requireLicenseAcceptance>
<projectSourceUrl>https://github.com/Dark-Alex-17/managarr</projectSourceUrl>
<docsUrl>https://github.com/Dark-Alex-17/managarr/blob/main/README.md</docsUrl>
<bugTrackerUrl>https://github.com/Dark-Alex-17/managarr/issues</bugTrackerUrl>
<tags>cli cross-platform terminal servarr tui sonarr radarr rust</tags>
<summary>A TUI and CLI for managing *arr servers.</summary>
<description>
A TUI and CLI for managing *arr servers. Built with love in Rust!
**Usage**
To use, run `managarr` in a terminal.
For more [documentation and usage](https://github.com/Dark-Alex-17/managarr/blob/main/README.md), see the [official repo](https://github.com/Dark-Alex-17/managarr).
</description>
<releaseNotes>https://github.com/Dark-Alex-17/managarr/releases/tag/v$version/</releaseNotes>
</metadata>
<files>
<!-- this section controls what actually gets packaged into the Chocolatey package -->
<file src="tools\**" target="tools" />
<!--Building from Linux? You may need this instead: <file src="tools/**" target="tools" />-->
</files>
</package>
+28
View File
@@ -0,0 +1,28 @@
import hashlib
import sys
from string import Template
sys.stdout.reconfigure(encoding='utf-8')
args = sys.argv
version = args[1].replace("v", "")
template_file_path = args[2]
generated_file_path = args[3]
# Deployment files
hash_64 = args[4].strip()
print("Generating formula")
print(" VERSION: %s" % version)
print(" TEMPLATE PATH: %s" % template_file_path)
print(" SAVING AT: %s" % generated_file_path)
print(" HASH: %s" % hash_64)
with open(template_file_path, "r", encoding="utf-8") as template_file:
template = Template(template_file.read())
substitute = template.safe_substitute(version=version, hash_64=hash_64)
print("\n================== Generated package file ==================\n")
print(substitute)
print("\n============================================================\n")
with open(generated_file_path, "w", encoding="utf-8") as generated_file:
generated_file.write(substitute)
+24
View File
@@ -0,0 +1,24 @@
# Documentation: https://docs.brew.sh/Formula-Cookbook
# https://rubydoc.brew.sh/Formula
class Managarr < Formula
desc "Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC)"
homepage "https://github.com/Dark-Alex-17/managarr"
if OS.mac? and Hardware::CPU.arm?
url "https://github.com/Dark-Alex-17/managarr/releases/download/v$version/managarr-macos-arm64.tar.gz"
sha256 "$hash_mac_arm"
elsif OS.mac? and Hardware::CPU.intel?
url "https://github.com/Dark-Alex-17/managarr/releases/download/v$version/managarr-macos.tar.gz"
sha256 "$hash_mac"
else
url "https://github.com/Dark-Alex-17/managarr/releases/download/v$version/managarr-linux-musl.tar.gz"
sha256 "$hash_linux"
end
version "$version"
license "MIT"
def install
bin.install "managarr"
ohai "You're done! Run with \"managarr\""
ohai "For runtime flags, see \"managarr --help\""
end
end
+31
View File
@@ -0,0 +1,31 @@
import hashlib
import sys
from string import Template
args = sys.argv
version = args[1]
template_file_path = args[2]
generated_file_path = args[3]
# Deployment files
hash_mac = args[4].strip()
hash_mac_arm = args[5].strip()
hash_linux = args[6].strip()
print("Generating formula")
print(" VERSION: %s" % version)
print(" TEMPLATE PATH: %s" % template_file_path)
print(" SAVING AT: %s" % generated_file_path)
print(" MAC HASH: %s" % hash_mac)
print(" MAC ARM HASH: %s" % hash_mac_arm)
print(" LINUX HASH: %s" % hash_linux)
with open(template_file_path, "r") as template_file:
template = Template(template_file.read())
substitute = template.safe_substitute(version=version, hash_mac=hash_mac, hash_mac_arm=hash_mac_arm, hash_linux=hash_linux)
print("\n================== Generated package file ==================\n")
print(substitute)
print("\n============================================================\n")
with open(generated_file_path, "w") as generated_file:
generated_file.write(substitute)
+89
View File
@@ -0,0 +1,89 @@
VERSION := "latest"
IMG_NAME := "darkalex17/managarr"
# List all recipes
default:
@just --list
# Format all files
[group: 'style']
fmt:
@cargo fmt --all
alias clippy := lint
# Run Clippy to inspect all files
[group: 'style']
lint:
@cargo clippy --all
alias clippy-fix := lint-fix
# Automatically fix clippy issues where possible
[group: 'style']
lint-fix:
@cargo fix
# Analyze the project for unsafe usage
[group: 'style']
@analyze:
#!/usr/bin/env bash
cargo geiger -h > /dev/null 2>&1 | cargo install cargo-geiger
cargo geiger
# Run all tests
[group: 'test']
test:
@cargo test --all
# Run all tests with coverage
[group:'test']
@test-cov:
#!/usr/bin/env bash
cargo tarpaulin -h > /dev/null 2>&1 || cargo install cargo-tarpaulin
cargo tarpaulin
# Run all doc tests
[group: 'test']
doctest:
@cargo test --all --doc
# Run all proptests
[group: 'test']
proptest:
@cargo test proptest
alias test-snapshots := snapshot-tests
# Run all snapshot tests
[group: 'test']
snapshot-tests:
@cargo test snapshot
alias review := snapshot-review
# Review snapshot test changes
[group: 'test']
@snapshot-review:
#!/usr/bin/env bash
cargo insta -h > /dev/null 2>&1 || cargo install cargo-insta
cargo insta review
alias clean-orphaned-snapshots := snapshot-delete-unreferenced
# Delete any unreferenced snapshots
[group: 'test']
@snapshot-delete-unreferenced:
#!/usr/bin/env bash
cargo insta -h > /dev/null 2>&1 || cargo install cargo-insta
cargo insta test --unreferenced=delete
# Build and run the binary for the current system
run:
@cargo run
# Build the project for the current system architecture
[group: 'build']
[arg('build_type', pattern="debug|release")]
build build_type='debug':
@cargo build {{ if build_type == "release" { "--release" } else { "" } }}
# Build the docker image
[group: 'build']
build-docker:
@DOCKER_BUILDKIT=1 docker build --rm -t {{IMG_NAME}}:{{VERSION}} .
+6
View File
@@ -0,0 +1,6 @@
{
"name": "managarr",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1
View File
@@ -0,0 +1 @@
{}
@@ -0,0 +1,18 @@
[package]
name = "enum_display_style_derive"
version = "0.6.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A proc-macro to derive a `Display` and `FromStr` implementation for enums with a `style` attribute."
license = "MIT"
documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.39"
syn = "2.0.99"
darling = "0.20.10"
@@ -0,0 +1,76 @@
mod macro_models;
use crate::macro_models::DisplayStyleArgs;
use darling::FromVariant;
use quote::quote;
use syn::{Data, DeriveInput, parse_macro_input};
/// Derive macro for generating a `to_display_str` method for an enum.
///
/// # Example
///
/// Using default values for the display style:
///
/// ```
/// use enum_display_style_derive::EnumDisplayStyle;
///
/// #[derive(EnumDisplayStyle)]
/// enum Weekend {
/// Saturday,
/// Sunday,
/// }
///
/// assert_eq!(Weekend::Saturday.to_display_str(), "Saturday");
/// assert_eq!(Weekend::Sunday.to_display_str(), "Sunday");
/// ```
///
/// Using custom values for the display style:
///
/// ```
/// use enum_display_style_derive::EnumDisplayStyle;
///
/// #[derive(EnumDisplayStyle)]
/// enum MonitorStatus {
/// #[display_style(name = "Monitor Transactions")]
/// Active,
/// #[display_style(name = "Don't Monitor Transactions")]
/// None,
/// }
///
/// assert_eq!(MonitorStatus::Active.to_display_str(), "Monitor Transactions");
/// assert_eq!(MonitorStatus::None.to_display_str(), "Don't Monitor Transactions");
/// ```
#[proc_macro_derive(EnumDisplayStyle, attributes(display_style))]
pub fn enum_display_style_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let enum_name = &input.ident;
let mut match_arms = Vec::new();
if let Data::Enum(data_enum) = &input.data {
let variants = &data_enum.variants;
for variant in variants {
let variant_ident = &variant.ident;
let variant_display_name = DisplayStyleArgs::from_variant(variant)
.unwrap()
.name
.unwrap_or_else(|| variant_ident.to_string());
match_arms.push(quote! {
#enum_name::#variant_ident => #variant_display_name,
});
}
}
quote! {
impl<'a> #enum_name {
pub fn to_display_str(self) -> &'a str {
match self {
#(#match_arms)*
}
}
}
}
.into()
}
@@ -0,0 +1,7 @@
use darling::FromVariant;
#[derive(Debug, FromVariant)]
#[darling(attributes(display_style))]
pub struct DisplayStyleArgs {
pub name: Option<String>,
}
@@ -0,0 +1,20 @@
[package]
name = "validate_theme_derive"
version = "0.6.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A proc-macro to validate a theme."
license = "MIT"
documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.39"
syn = "2.0.99"
[dev-dependencies]
log = "0.4.17"
@@ -0,0 +1,106 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
/// Derive macro for generating a `validate` method for a Theme struct.
/// The `validate` method ensures that all values with the `validate` attribute are not `None`.
/// Otherwise, an error message it output to both the log file and stdout and the program exits.
///
/// # Example
///
/// Valid themes pass through the program transitively without any messages being output.
///
/// ```
/// use validate_theme_derive::ValidateTheme;
///
/// #[derive(ValidateTheme, Default)]
/// struct Theme {
/// pub name: String,
/// #[validate]
/// pub good: Option<Style>,
/// #[validate]
/// pub bad: Option<Style>,
/// pub ugly: Option<Style>,
/// }
///
/// struct Style {
/// color: String,
/// }
///
/// let theme = Theme {
/// good: Some(Style { color: "Green".to_owned() }),
/// bad: Some(Style { color: "Red".to_owned() }),
/// ..Theme::default()
/// };
///
/// // Since only `good` and `bad` have the `validate` attribute, the `validate` method will only check those fields.
/// theme.validate();
/// // Since both `good` and `bad` have values, the program will not exit and no message is output.
/// ```
///
/// Invalid themes will output an error message to both the log file and stdout and the program will exit.
///
/// ```should_panic
/// use validate_theme_derive::ValidateTheme;
///
/// #[derive(ValidateTheme, Default)]
/// struct Theme {
/// pub name: String,
/// #[validate]
/// pub good: Option<Style>,
/// #[validate]
/// pub bad: Option<Style>,
/// pub ugly: Option<Style>,
/// }
///
/// struct Style {
/// color: String,
/// }
///
/// let theme = Theme {
/// bad: Some(Style { color: "Red".to_owned() }),
/// ..Theme::default()
/// };
///
/// // Since `good` has the `validate` attribute and since `good` is `None`, the `validate` method will output an error message and exit the program.
/// theme.validate();
/// ```
#[proc_macro_derive(ValidateTheme, attributes(validate))]
pub fn derive_validate_theme(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let struct_name = &input.ident;
let mut validation_checks = Vec::new();
if let Data::Struct(data_struct) = &input.data
&& let Fields::Named(fields) = &data_struct.fields
{
for field in &fields.named {
let field_name = &field.ident;
let has_validate_attr = field
.attrs
.iter()
.any(|attr| attr.path().is_ident("validate"));
if has_validate_attr {
validation_checks.push(quote! {
if self.#field_name.is_none() {
log::error!("{} is missing a color value.", stringify!(#field_name));
eprintln!("{} is missing a color value.", stringify!(#field_name));
std::process::exit(1);
}
})
}
}
}
quote! {
impl #struct_name {
pub fn validate(&self) {
#(#validation_checks)*
}
}
}
.into()
}
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 56330c025ad79db641d0eb9f429ab74e95822e1fb015b58f0e158ea674cd42a1 # shrinks to list_size = 1, page_ops = 1
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc fb4b58aa3015a125fc33a78dfaf27981db4191247151b327a351fc445e07c231 # shrinks to input = "j"
cc d6ec17d4d3f635f0a095ade650a316d26abc1f9fe2b6d9cf67bf2f8b4ebedb60 # shrinks to backspace_count = 0
cc cd46ee46e18cf86c940fb89c7206f0b482909880b8f2eabe3dd20682b9912c8a # shrinks to input = "h"
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 24ae243412a324cb46c36cb4f629ddd4c9326b1479d1186d9b5545ac5e86dbba # shrinks to num_scroll_attempts = 0
cc c06a1cc1e4740b2498c50d7be64715bf09ef3ac4cf3bb3642f960578a3e06c74 # shrinks to is_loading = false, num_items = 1
cc 930207899afea2d389c7fa3974e31c2eb1803e71bcbd8179246c795903905ec7 # shrinks to parent_width = 20, parent_height = 12, percent_x = 1, percent_y = 1
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+527 -63
View File
@@ -2,65 +2,124 @@
mod tests {
use anyhow::anyhow;
use pretty_assertions::{assert_eq, assert_str_eq};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::Value;
use serial_test::serial;
use tokio::sync::mpsc;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
use crate::app::{App, Data, RadarrConfig, DEFAULT_ROUTE};
use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars};
use crate::models::servarr_data::lidarr::lidarr_data::LidarrData;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::{HorizontallyScrollableText, Route, TabRoute};
use crate::network::radarr_network::RadarrEvent;
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, TabRoute};
use crate::network::NetworkEvent;
use crate::network::radarr_network::RadarrEvent;
use tokio_util::sync::CancellationToken;
#[test]
fn test_app_new() {
let radarr_config_1 = ServarrConfig {
name: Some("Radarr Test".to_owned()),
..ServarrConfig::default()
};
let radarr_config_2 = ServarrConfig {
weight: Some(3),
..ServarrConfig::default()
};
let sonarr_config_1 = ServarrConfig {
name: Some("Sonarr Test".to_owned()),
weight: Some(1),
..ServarrConfig::default()
};
let sonarr_config_2 = ServarrConfig::default();
let config = AppConfig {
theme: None,
radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]),
sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]),
lidarr: None,
};
let expected_tab_routes = vec![
TabRoute {
title: "Sonarr Test".to_owned(),
route: ActiveSonarrBlock::default().into(),
contextual_help: None,
config: Some(sonarr_config_1),
},
TabRoute {
title: "Radarr 1".to_owned(),
route: ActiveRadarrBlock::default().into(),
contextual_help: None,
config: Some(radarr_config_2),
},
TabRoute {
title: "Radarr Test".to_owned(),
route: ActiveRadarrBlock::default().into(),
contextual_help: None,
config: Some(radarr_config_1),
},
TabRoute {
title: "Sonarr 1".to_owned(),
route: ActiveSonarrBlock::default().into(),
contextual_help: None,
config: Some(sonarr_config_2),
},
];
let app = App::new(
mpsc::channel::<NetworkEvent>(500).0,
config,
CancellationToken::new(),
);
assert_is_empty!(app.navigation_stack);
assert_eq!(app.get_current_route(), ActiveSonarrBlock::default().into());
assert_some!(app.network_tx);
assert!(!app.cancellation_token.is_cancelled());
assert!(app.is_first_render);
assert_eq!(app.error, HorizontallyScrollableText::default());
assert_eq!(app.server_tabs.index, 0);
assert_eq!(app.server_tabs.tabs, expected_tab_routes);
assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 64);
assert_eq!(app.tick_count, 0);
assert_eq!(app.ui_scroll_tick_count, 0);
assert!(!app.is_loading);
assert!(!app.is_routing);
assert!(!app.should_refresh);
assert!(!app.ignore_special_keys_for_textbox_input);
assert!(!app.cli_mode);
}
#[test]
fn test_app_default() {
let app = App::default();
assert_eq!(app.navigation_stack, vec![DEFAULT_ROUTE]);
assert!(app.network_tx.is_none());
assert_is_empty!(app.navigation_stack);
assert_none!(app.network_tx);
assert!(!app.cancellation_token.is_cancelled());
assert!(app.is_first_render);
assert_eq!(app.error, HorizontallyScrollableText::default());
assert_eq!(app.server_tabs.index, 0);
assert_eq!(
app.server_tabs.tabs,
vec![
TabRoute {
title: "Radarr",
route: ActiveRadarrBlock::Movies.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
},
TabRoute {
title: "Sonarr",
route: Route::Sonarr,
help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)),
contextual_help: None,
},
]
);
assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 4);
assert_eq!(app.ticks_until_scroll, 64);
assert_eq!(app.tick_count, 0);
assert!(!app.is_loading);
assert!(!app.is_routing);
assert!(!app.should_refresh);
assert!(!app.should_ignore_quit_key);
assert!(!app.ignore_special_keys_for_textbox_input);
assert!(!app.cli_mode);
}
#[test]
fn test_navigation_stack_methods() {
let mut app = App::default();
let mut app = App::test_default();
let default_route = app.server_tabs.tabs.first().unwrap().route;
assert_eq!(app.get_current_route(), &DEFAULT_ROUTE);
assert_eq!(app.get_current_route(), default_route);
app.push_navigation_stack(ActiveRadarrBlock::Downloads.into());
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::Downloads.into()
);
assert_eq!(app.get_current_route(), ActiveRadarrBlock::Downloads.into());
assert!(app.is_routing);
app.is_routing = false;
@@ -68,26 +127,30 @@ mod tests {
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::Collections.into()
ActiveRadarrBlock::Collections.into()
);
assert!(app.is_routing);
app.is_routing = false;
app.pop_navigation_stack();
assert_eq!(app.get_current_route(), &DEFAULT_ROUTE);
assert_eq!(app.get_current_route(), default_route);
assert!(app.is_routing);
app.is_routing = false;
app.pop_navigation_stack();
assert_eq!(app.get_current_route(), &DEFAULT_ROUTE);
assert_eq!(app.get_current_route(), default_route);
assert!(app.is_routing);
}
#[test]
fn test_reset_cancellation_token() {
let mut app = App::default();
let mut app = App {
is_loading: true,
should_refresh: false,
..App::test_default()
};
app.cancellation_token.cancel();
assert!(app.cancellation_token.is_cancelled());
@@ -96,13 +159,15 @@ mod tests {
assert!(!app.cancellation_token.is_cancelled());
assert!(!new_token.is_cancelled());
assert!(!app.is_loading);
assert!(app.should_refresh);
}
#[test]
fn test_reset_tick_count() {
let mut app = App {
tick_count: 2,
..App::default()
..App::test_default()
};
app.reset_tick_count();
@@ -112,28 +177,39 @@ mod tests {
#[test]
fn test_reset() {
let radarr_data = RadarrData {
version: "test".into(),
..RadarrData::default()
};
let sonarr_data = SonarrData {
version: "test".into(),
..SonarrData::default()
};
let data = Data {
lidarr_data: LidarrData::default(),
radarr_data,
sonarr_data,
};
let mut app = App {
tick_count: 2,
error: "Test error".to_owned().into(),
data: Data {
radarr_data: RadarrData {
version: "test".to_owned(),
..RadarrData::default()
},
},
..App::default()
is_first_render: false,
data,
..App::test_default()
};
app.reset();
assert_eq!(app.tick_count, 0);
assert_eq!(app.error, HorizontallyScrollableText::default());
assert!(app.is_first_render);
assert!(app.data.radarr_data.version.is_empty());
assert!(app.data.sonarr_data.version.is_empty());
}
#[test]
fn test_handle_error() {
let mut app = App::default();
let mut app = App::test_default();
let test_string = "Testing";
app.handle_error(anyhow!(test_string));
@@ -145,6 +221,50 @@ mod tests {
assert_eq!(app.error.text, test_string);
}
#[tokio::test]
async fn test_dispatch_network_event() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App {
tick_until_poll: 2,
network_tx: Some(sync_network_tx),
..App::test_default()
};
assert_eq!(app.tick_count, 0);
app
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert_eq!(app.tick_count, 0);
}
#[test]
fn test_on_ui_scroll_tick() {
let mut app = App {
ticks_until_scroll: 1,
..App::default()
};
assert_eq!(app.ui_scroll_tick_count, 0);
assert_eq!(app.tick_count, 0);
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 1);
assert_eq!(app.tick_count, 0);
app.on_ui_scroll_tick();
assert_eq!(app.ui_scroll_tick_count, 0);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_on_tick_first_render() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
@@ -152,12 +272,14 @@ mod tests {
let mut app = App {
tick_until_poll: 2,
network_tx: Some(sync_network_tx),
..App::default()
is_first_render: true,
..App::test_default()
};
assert_eq!(app.tick_count, 0);
app.on_tick(true).await;
app.on_tick().await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into()
@@ -172,7 +294,11 @@ mod tests {
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
RadarrEvent::GetDownloads(500).into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDiskSpace.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
@@ -180,11 +306,15 @@ mod tests {
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetMovies.into()
RadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
RadarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetMovies.into()
);
assert!(!app.is_routing);
assert!(!app.should_refresh);
@@ -197,10 +327,10 @@ mod tests {
tick_until_poll: 2,
tick_count: 2,
is_routing: true,
..App::default()
..App::test_default()
};
app.on_tick(false).await;
app.on_tick().await;
assert!(!app.is_routing);
}
@@ -210,21 +340,355 @@ mod tests {
tick_until_poll: 2,
tick_count: 2,
should_refresh: true,
..App::default()
..App::test_default()
};
app.on_tick(false).await;
app.on_tick().await;
assert!(!app.should_refresh);
}
#[test]
fn test_radarr_config_default() {
let radarr_config = RadarrConfig::default();
fn test_app_config_default() {
let app_config = AppConfig::default();
assert_str_eq!(radarr_config.host, "localhost");
assert_eq!(radarr_config.port, Some(7878));
assert!(radarr_config.api_token.is_empty());
assert!(!radarr_config.use_ssl);
assert_eq!(radarr_config.ssl_cert_path, None);
assert_none!(app_config.radarr);
assert_none!(app_config.sonarr);
}
#[test]
fn test_servarr_config_default() {
let servarr_config = ServarrConfig::default();
assert_none!(servarr_config.name);
assert_some_eq_x!(&servarr_config.host, "localhost");
assert_none!(servarr_config.port);
assert_none!(servarr_config.uri);
assert_none!(servarr_config.weight);
assert_some_eq_x!(&servarr_config.api_token, "");
assert_none!(servarr_config.api_token_file);
assert_none!(servarr_config.ssl_cert_path);
assert_none!(servarr_config.custom_headers);
}
#[test]
fn serialize_header_map_basic() {
let mut header_map = HeaderMap::new();
header_map.insert(
HeaderName::from_static("x-api-key"),
HeaderValue::from_static("abc123"),
);
header_map.insert(
HeaderName::from_static("header-1"),
HeaderValue::from_static("test"),
);
let config = ServarrConfig {
custom_headers: Some(header_map),
..ServarrConfig::default()
};
let v: Value = serde_json::to_value(&config).expect("serialize ok");
let custom = v.get("custom_headers").unwrap();
assert!(custom.is_object());
let obj = custom.as_object().unwrap();
assert_some_eq_x!(obj.get("x-api-key"), "abc123");
assert_some_eq_x!(obj.get("header-1"), "test");
assert_none!(obj.get("X-Api-Key"));
assert_none!(obj.get("HEADER-1"));
}
#[test]
fn serialize_header_map_none_is_null() {
let config = ServarrConfig::default();
let v: Value = serde_json::to_value(&config).expect("serialize ok");
assert!(v.get("custom_headers").unwrap().is_null());
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION", "localhost") };
let yaml_data = r#"
host: ${TEST_VAR_DESERIALIZE_OPTION}
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.host, "localhost");
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_does_not_overwrite_non_env_value() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE", "localhost") };
let yaml_data = r#"
host: www.example.com
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.host, "www.example.com");
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.port);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_header_map_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_HEADER_OPTION", "localhost") };
let expected_custom_headers = {
let mut headers = HeaderMap::new();
headers.insert("X-Api-Host", "localhost".parse().unwrap());
headers.insert("api-token", "test123".parse().unwrap());
headers
};
let yaml_data = r#"
custom_headers:
X-Api-Host: ${TEST_VAR_DESERIALIZE_HEADER_OPTION}
api-token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.custom_headers, &expected_custom_headers);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_HEADER_OPTION") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_header_map_does_not_overwrite_non_env_value() {
unsafe {
std::env::set_var(
"TEST_VAR_DESERIALIZE_HEADER_OPTION_NO_OVERWRITE",
"localhost",
)
};
let expected_custom_headers = {
let mut headers = HeaderMap::new();
headers.insert("X-Api-Host", "www.example.com".parse().unwrap());
headers.insert("api-token", "test123".parse().unwrap());
headers
};
let yaml_data = r#"
custom_headers:
X-Api-Host: www.example.com
api-token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.custom_headers, &expected_custom_headers);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_HEADER_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_header_map_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.custom_headers);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_string_vec_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_STRING_VEC_OPTION", "/path1") };
let expected_monitored_paths = ["/path1", "/path2"];
let yaml_data = r#"
monitored_storage_paths:
- ${TEST_VAR_DESERIALIZE_STRING_VEC_OPTION}
- /path2
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.monitored_storage_paths, &expected_monitored_paths);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_STRING_VEC_OPTION") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_string_vec_does_not_overwrite_non_env_value() {
unsafe {
std::env::set_var(
"TEST_VAR_DESERIALIZE_STRING_VEC_OPTION_NO_OVERWRITE",
"/path3",
)
};
let expected_monitored_paths = ["/path1", "/path2"];
let yaml_data = r#"
monitored_storage_paths:
- /path1
- /path2
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.monitored_storage_paths, &expected_monitored_paths);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_STRING_VEC_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_string_vec_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.monitored_storage_paths);
}
#[test]
#[serial]
fn test_deserialize_optional_u16_env_var_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_U16", "1") };
let yaml_data = r#"
port: ${TEST_VAR_DESERIALIZE_OPTION_U16}
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(config.port, 1);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_U16") };
}
#[test]
#[serial]
fn test_deserialize_optional_u16_env_var_does_not_overwrite_non_env_value() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_U16_UNUSED", "1") };
let yaml_data = r#"
port: 1234
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(config.port, 1234);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_U16_UNUSED") };
}
#[test]
fn test_deserialize_optional_u16_env_var_invalid_number() {
let yaml_data = r#"
port: "hi"
api_token: "test123"
"#;
let result: Result<ServarrConfig, _> = serde_yaml::from_str(yaml_data);
assert_err!(&result);
let err = result.unwrap_err().to_string();
assert_contains!(err, "invalid digit found in string");
}
#[test]
fn test_deserialize_optional_u16_env_var_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.port);
}
#[test]
#[serial]
fn test_interpolate_env_vars() {
unsafe { std::env::set_var("TEST_VAR_INTERPOLATION", "testing") };
let var = interpolate_env_vars("${TEST_VAR_INTERPOLATION}");
assert_str_eq!(var, "testing");
unsafe { std::env::remove_var("TEST_VAR_INTERPOLATION") };
}
#[test]
fn test_interpolate_env_vars_defaults_to_original_string_if_not_in_yaml_interpolation_format() {
let var = interpolate_env_vars("TEST_VAR_INTERPOLATION_NON_YAML");
assert_str_eq!(var, "TEST_VAR_INTERPOLATION_NON_YAML");
}
#[test]
#[serial]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters() {
unsafe {
std::env::set_var(
"TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS",
r#"""
`"'https://dontdo:this@testing.com/query?test=%20query#results'"` {([\|$!])}
"""#,
)
};
let var = interpolate_env_vars("${TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS}");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
unsafe { std::env::remove_var("TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS") };
}
#[test]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters_from_non_environment_variable() {
let var = interpolate_env_vars("https://dontdo:this@testing.com/query?test=%20query#results");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
}
#[test]
fn test_servarr_config_redacted_debug() {
let name = "Servarr".to_owned();
let host = "localhost".to_owned();
let port = 1234;
let uri = "http://localhost:1234".to_owned();
let weight = 100;
let api_token = "thisisatest".to_owned();
let api_token_file = "/root/.config/api_token".to_owned();
let ssl_cert_path = "/some/path".to_owned();
let monitored_storage = vec!["/path1".to_owned(), "/path2".to_owned()];
let mut custom_headers = HeaderMap::new();
custom_headers.insert("X-Custom-Header", "value".parse().unwrap());
let expected_str = format!(
"ServarrConfig {{ name: Some(\"{name}\"), host: Some(\"{host}\"), port: Some({port}), uri: Some(\"{uri}\"), weight: Some({weight}), api_token: Some(\"***********\"), api_token_file: Some(\"{api_token_file}\"), ssl_cert_path: Some(\"{ssl_cert_path}\"), custom_headers: Some({{\"x-custom-header\": \"value\"}}), monitored_storage_paths: Some([\"/path1\", \"/path2\"]) }}"
);
let servarr_config = ServarrConfig {
name: Some(name),
host: Some(host),
port: Some(port),
uri: Some(uri),
weight: Some(weight),
api_token: Some(api_token),
api_token_file: Some(api_token_file),
ssl_cert_path: Some(ssl_cert_path),
custom_headers: Some(custom_headers),
monitored_storage_paths: Some(monitored_storage),
};
assert_str_eq!(format!("{servarr_config:?}"), expected_str);
}
}
+118 -10
View File
@@ -1,23 +1,131 @@
use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS};
use crate::app::App;
use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding};
use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider;
use crate::app::radarr::radarr_context_clues::RadarrContextClueProvider;
use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider;
use crate::models::Route;
#[cfg(test)]
#[path = "context_clues_tests.rs"]
mod context_clues_tests;
pub(in crate::app) type ContextClue = (KeyBinding, &'static str);
pub type ContextClue = (KeyBinding, &'static str);
pub fn build_context_clue_string(context_clues: &[(KeyBinding, &str)]) -> String {
context_clues
.iter()
.map(|(key_binding, desc)| format!("{} {desc}", key_binding.key))
.collect::<Vec<String>>()
.join(" | ")
pub trait ContextClueProvider {
fn get_context_clues(_app: &mut App<'_>) -> Option<&'static [ContextClue]>;
}
pub static SERVARR_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.tab, "change servarr"),
pub struct ServarrContextClueProvider;
impl ContextClueProvider for ServarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
match app.get_current_route() {
Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app),
Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app),
Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app),
_ => None,
}
}
}
pub static SERVARR_CONTEXT_CLUES: [ContextClue; 10] = [
(DEFAULT_KEYBINDINGS.up, "scroll up"),
(DEFAULT_KEYBINDINGS.down, "scroll down"),
(DEFAULT_KEYBINDINGS.left, "previous tab"),
(DEFAULT_KEYBINDINGS.right, "next tab"),
(DEFAULT_KEYBINDINGS.pg_up, DEFAULT_KEYBINDINGS.pg_up.desc),
(
DEFAULT_KEYBINDINGS.pg_down,
DEFAULT_KEYBINDINGS.pg_down.desc,
),
(
DEFAULT_KEYBINDINGS.next_servarr,
DEFAULT_KEYBINDINGS.next_servarr.desc,
),
(
DEFAULT_KEYBINDINGS.previous_servarr,
DEFAULT_KEYBINDINGS.previous_servarr.desc,
),
(DEFAULT_KEYBINDINGS.quit, DEFAULT_KEYBINDINGS.quit.desc),
(DEFAULT_KEYBINDINGS.help, DEFAULT_KEYBINDINGS.help.desc),
];
pub static BARE_POPUP_CONTEXT_CLUES: [ContextClue; 1] =
[(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)];
pub static BLOCKLIST_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.clear, "clear blocklist"),
];
pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.confirm, "submit"),
(DEFAULT_KEYBINDINGS.esc, "cancel"),
];
pub static DOWNLOADS_CONTEXT_CLUES: [ContextClue; 3] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.update, "update downloads"),
];
pub static ROOT_FOLDERS_CONTEXT_CLUES: [ContextClue; 3] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
];
pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.submit, "edit indexer"),
(
DEFAULT_KEYBINDINGS.settings,
DEFAULT_KEYBINDINGS.settings.desc,
),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.test, "test indexer"),
(DEFAULT_KEYBINDINGS.test_all, "test all indexers"),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
];
pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.tasks, "open tasks"),
(DEFAULT_KEYBINDINGS.events, "open events"),
(DEFAULT_KEYBINDINGS.logs, "open logs"),
(DEFAULT_KEYBINDINGS.update, "open updates"),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
];
pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "start task"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
+299 -31
View File
@@ -1,47 +1,315 @@
#[cfg(test)]
mod test {
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES};
use crate::app::{context_clues::build_context_clue_string, key_binding::DEFAULT_KEYBINDINGS};
#[test]
fn test_build_context_clue_string() {
let test_context_clues_array = [
(DEFAULT_KEYBINDINGS.add, "add"),
(DEFAULT_KEYBINDINGS.delete, "delete"),
];
assert_str_eq!(
build_context_clue_string(&test_context_clues_array),
"<a> add | <del> delete"
);
}
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
SYSTEM_TASKS_CONTEXT_CLUES, ServarrContextClueProvider,
};
use crate::app::{App, key_binding::DEFAULT_KEYBINDINGS};
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
#[test]
fn test_servarr_context_clues() {
let mut servarr_context_clues_iter = SERVARR_CONTEXT_CLUES.iter();
let (key_binding, description) = servarr_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.tab);
assert_str_eq!(*description, "change servarr");
let (key_binding, description) = servarr_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.quit);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.quit.desc);
assert_eq!(servarr_context_clues_iter.next(), None);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.up, "scroll up")
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.down, "scroll down")
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.left, "previous tab")
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.right, "next tab")
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.pg_up, DEFAULT_KEYBINDINGS.pg_up.desc)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.pg_down,
DEFAULT_KEYBINDINGS.pg_down.desc
)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.next_servarr,
DEFAULT_KEYBINDINGS.next_servarr.desc
)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.previous_servarr,
DEFAULT_KEYBINDINGS.previous_servarr.desc
)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.quit, DEFAULT_KEYBINDINGS.quit.desc)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.help, DEFAULT_KEYBINDINGS.help.desc)
);
assert_none!(servarr_context_clues_iter.next());
}
#[test]
fn test_bare_popup_context_clues() {
let mut bare_popup_context_clues_iter = BARE_POPUP_CONTEXT_CLUES.iter();
let (key_binding, description) = bare_popup_context_clues_iter.next().unwrap();
assert_some_eq_x!(
bare_popup_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(bare_popup_context_clues_iter.next());
}
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_eq!(bare_popup_context_clues_iter.next(), None);
#[test]
fn test_downloads_context_clues() {
let mut downloads_context_clues_iter = DOWNLOADS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
downloads_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
downloads_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
downloads_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update downloads")
);
assert_none!(downloads_context_clues_iter.next());
}
#[test]
fn test_blocklist_context_clues() {
let mut blocklist_context_clues_iter = BLOCKLIST_CONTEXT_CLUES.iter();
assert_some_eq_x!(
blocklist_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
blocklist_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
blocklist_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
blocklist_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
blocklist_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.clear, "clear blocklist")
);
assert_none!(blocklist_context_clues_iter.next());
}
#[test]
fn test_confirmation_prompt_context_clues() {
let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter();
assert_some_eq_x!(
confirmation_prompt_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.confirm, "submit")
);
assert_some_eq_x!(
confirmation_prompt_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel")
);
assert_none!(confirmation_prompt_context_clues_iter.next());
}
#[test]
fn test_root_folders_context_clues() {
let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
root_folders_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
root_folders_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
root_folders_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_none!(root_folders_context_clues_iter.next());
}
#[test]
fn test_indexers_context_clues() {
let mut indexers_context_clues_iter = INDEXERS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "edit indexer")
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.settings,
DEFAULT_KEYBINDINGS.settings.desc
)
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.test, "test indexer")
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.test_all, "test all indexers")
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_none!(indexers_context_clues_iter.next());
}
#[test]
fn test_history_context_clues() {
let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(history_context_clues_iter.next());
}
#[test]
fn test_system_context_clues() {
let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter();
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.tasks, "open tasks")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.events, "open events")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.logs, "open logs")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "open updates")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_none!(system_context_clues_iter.next());
}
#[test]
fn test_system_tasks_context_clues() {
let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "start task")
);
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(system_tasks_context_clues_iter.next());
}
#[test]
fn test_servarr_context_clue_provider_delegates_to_radarr_provider() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,);
}
#[test]
fn test_servarr_context_clue_provider_delegates_to_sonarr_provider() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into());
let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,);
}
#[test]
fn test_servarr_context_clue_provider_unsupported_route_returns_none() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveKeybindingBlock::Help.into());
let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_none!(context_clues);
}
}
+100 -7
View File
@@ -14,10 +14,16 @@ generate_keybindings! {
down,
left,
right,
pg_down,
pg_up,
backspace,
next_servarr,
previous_servarr,
clear,
search,
auto_search,
settings,
help,
filter,
sort,
edit,
@@ -25,12 +31,12 @@ generate_keybindings! {
tasks,
test,
test_all,
toggle_monitoring,
refresh,
update,
events,
home,
end,
tab,
delete,
submit,
confirm,
@@ -41,116 +47,203 @@ generate_keybindings! {
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub struct KeyBinding {
pub key: Key,
pub alt: Option<Key>,
pub desc: &'static str,
}
pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
add: KeyBinding {
key: Key::Char('a'),
alt: None,
desc: "add",
},
up: KeyBinding {
key: Key::Up,
alt: Some(Key::Char('k')),
desc: "up",
},
down: KeyBinding {
key: Key::Down,
alt: Some(Key::Char('j')),
desc: "down",
},
left: KeyBinding {
key: Key::Left,
alt: Some(Key::Char('h')),
desc: "left",
},
right: KeyBinding {
key: Key::Right,
alt: Some(Key::Char('l')),
desc: "right",
},
pg_down: KeyBinding {
key: Key::PgDown,
alt: Some(Key::Ctrl('d')),
desc: "page down",
},
pg_up: KeyBinding {
key: Key::PgUp,
alt: Some(Key::Ctrl('u')),
desc: "page up",
},
backspace: KeyBinding {
key: Key::Backspace,
alt: Some(Key::Ctrl('h')),
desc: "backspace",
},
next_servarr: KeyBinding {
key: Key::Tab,
alt: None,
desc: "next servarr",
},
previous_servarr: KeyBinding {
key: Key::BackTab,
alt: None,
desc: "previous servarr",
},
clear: KeyBinding {
key: Key::Char('c'),
alt: None,
desc: "clear",
},
auto_search: KeyBinding {
key: Key::Char('S'),
alt: None,
desc: "auto search",
},
search: KeyBinding {
key: Key::Char('s'),
alt: None,
desc: "search",
},
settings: KeyBinding {
key: Key::Char('s'),
key: Key::Char('S'),
alt: None,
desc: "settings",
},
help: KeyBinding {
key: Key::Char('?'),
alt: None,
desc: "show/hide keybindings",
},
filter: KeyBinding {
key: Key::Char('f'),
alt: None,
desc: "filter",
},
sort: KeyBinding {
key: Key::Char('o'),
alt: None,
desc: "sort",
},
edit: KeyBinding {
key: Key::Char('e'),
alt: None,
desc: "edit",
},
events: KeyBinding {
key: Key::Char('e'),
alt: None,
desc: "events",
},
logs: KeyBinding {
key: Key::Char('l'),
key: Key::Char('L'),
alt: None,
desc: "logs",
},
tasks: KeyBinding {
key: Key::Char('t'),
alt: None,
desc: "tasks",
},
test: KeyBinding {
key: Key::Char('t'),
alt: None,
desc: "test",
},
test_all: KeyBinding {
key: Key::Char('T'),
alt: None,
desc: "test all",
},
toggle_monitoring: KeyBinding {
key: Key::Char('m'),
alt: None,
desc: "toggle monitoring",
},
refresh: KeyBinding {
key: Key::Ctrl('r'),
alt: None,
desc: "refresh",
},
update: KeyBinding {
key: Key::Char('u'),
alt: None,
desc: "update",
},
home: KeyBinding {
key: Key::Home,
alt: None,
desc: "home",
},
end: KeyBinding {
key: Key::End,
alt: None,
desc: "end",
},
tab: KeyBinding {
key: Key::Tab,
desc: "tab",
},
delete: KeyBinding {
key: Key::Delete,
alt: None,
desc: "delete",
},
submit: KeyBinding {
key: Key::Enter,
alt: None,
desc: "submit",
},
confirm: KeyBinding {
key: Key::Ctrl('s'),
alt: None,
desc: "submit",
},
quit: KeyBinding {
key: Key::Char('q'),
alt: None,
desc: "quit",
},
esc: KeyBinding {
key: Key::Esc,
alt: None,
desc: "close",
},
};
#[macro_export]
macro_rules! matches_key {
($binding:ident, $key:expr) => {
$crate::app::key_binding::DEFAULT_KEYBINDINGS.$binding.key == $key
|| ($crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.is_some()
&& $crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.unwrap()
== $key)
};
($binding:ident, $key:expr, $ignore_special_keys:expr) => {
$crate::app::key_binding::DEFAULT_KEYBINDINGS.$binding.key == $key
|| !$ignore_special_keys
&& ($crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.is_some()
&& $crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.unwrap()
== $key)
};
}
+75 -28
View File
@@ -3,43 +3,90 @@ mod test {
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS};
use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding};
use crate::event::Key;
use crate::matches_key;
#[rstest]
#[case(DEFAULT_KEYBINDINGS.add, Key::Char('a'), "add")]
#[case(DEFAULT_KEYBINDINGS.up, Key::Up, "up")]
#[case(DEFAULT_KEYBINDINGS.down, Key::Down, "down")]
#[case(DEFAULT_KEYBINDINGS.left, Key::Left, "left")]
#[case(DEFAULT_KEYBINDINGS.right, Key::Right, "right")]
#[case(DEFAULT_KEYBINDINGS.backspace, Key::Backspace, "backspace")]
#[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), "clear")]
#[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), "search")]
#[case(DEFAULT_KEYBINDINGS.settings, Key::Char('s'), "settings")]
#[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), "filter")]
#[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), "sort")]
#[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), "edit")]
#[case(DEFAULT_KEYBINDINGS.events, Key::Char('e'), "events")]
#[case(DEFAULT_KEYBINDINGS.logs, Key::Char('l'), "logs")]
#[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), "tasks")]
#[case(DEFAULT_KEYBINDINGS.test, Key::Char('t'), "test")]
#[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('T'), "test all")]
#[case(DEFAULT_KEYBINDINGS.refresh, Key::Ctrl('r'), "refresh")]
#[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), "update")]
#[case(DEFAULT_KEYBINDINGS.home, Key::Home, "home")]
#[case(DEFAULT_KEYBINDINGS.end, Key::End, "end")]
#[case(DEFAULT_KEYBINDINGS.tab, Key::Tab, "tab")]
#[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, "delete")]
#[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, "submit")]
#[case(DEFAULT_KEYBINDINGS.confirm, Key::Ctrl('s'), "submit")]
#[case(DEFAULT_KEYBINDINGS.quit, Key::Char('q'), "quit")]
#[case(DEFAULT_KEYBINDINGS.esc, Key::Esc, "close")]
#[case(DEFAULT_KEYBINDINGS.add, Key::Char('a'), None, "add")]
#[case(DEFAULT_KEYBINDINGS.up, Key::Up, Some(Key::Char('k')), "up")]
#[case(DEFAULT_KEYBINDINGS.down, Key::Down, Some(Key::Char('j')), "down")]
#[case(DEFAULT_KEYBINDINGS.left, Key::Left, Some(Key::Char('h')), "left")]
#[case(DEFAULT_KEYBINDINGS.right, Key::Right, Some(Key::Char('l')), "right")]
#[case(DEFAULT_KEYBINDINGS.pg_down, Key::PgDown, Some(Key::Ctrl('d')), "page down")]
#[case(DEFAULT_KEYBINDINGS.pg_up, Key::PgUp, Some(Key::Ctrl('u')), "page up")]
#[case(DEFAULT_KEYBINDINGS.backspace, Key::Backspace, Some(Key::Ctrl('h')), "backspace")]
#[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, None, "next servarr")]
#[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, None, "previous servarr")]
#[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), None, "clear")]
#[case(DEFAULT_KEYBINDINGS.auto_search, Key::Char('S'), None, "auto search")]
#[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), None, "search")]
#[case(DEFAULT_KEYBINDINGS.settings, Key::Char('S'), None, "settings")]
#[case(DEFAULT_KEYBINDINGS.help, Key::Char('?'), None, "show/hide keybindings")]
#[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), None, "filter")]
#[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), None, "sort")]
#[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), None, "edit")]
#[case(DEFAULT_KEYBINDINGS.events, Key::Char('e'), None, "events")]
#[case(DEFAULT_KEYBINDINGS.logs, Key::Char('L'), None, "logs")]
#[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), None, "tasks")]
#[case(DEFAULT_KEYBINDINGS.test, Key::Char('t'), None, "test")]
#[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('T'), None, "test all")]
#[case(DEFAULT_KEYBINDINGS.toggle_monitoring, Key::Char('m'), None, "toggle monitoring")]
#[case(DEFAULT_KEYBINDINGS.refresh, Key::Ctrl('r'), None, "refresh")]
#[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), None, "update")]
#[case(DEFAULT_KEYBINDINGS.home, Key::Home, None, "home")]
#[case(DEFAULT_KEYBINDINGS.end, Key::End, None, "end")]
#[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, None, "delete")]
#[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, None, "submit")]
#[case(DEFAULT_KEYBINDINGS.confirm, Key::Ctrl('s'), None, "submit")]
#[case(DEFAULT_KEYBINDINGS.quit, Key::Char('q'), None, "quit")]
#[case(DEFAULT_KEYBINDINGS.esc, Key::Esc, None, "close")]
fn test_default_key_bindings_and_descriptions(
#[case] key_binding: KeyBinding,
#[case] expected_key: Key,
#[case] expected_alt_key: Option<Key>,
#[case] expected_desc: &str,
) {
assert_eq!(key_binding.key, expected_key);
assert_eq!(key_binding.alt, expected_alt_key);
assert_str_eq!(key_binding.desc, expected_desc);
}
#[test]
fn test_matches_key_macro() {
let key = Key::Char('t');
assert!(matches_key!(test, key));
assert!(!matches_key!(test, Key::Char('T')));
}
#[test]
fn test_matches_key_macro_with_alt_keybinding() {
let alt_key = Key::Char('k');
let key = Key::Up;
assert!(matches_key!(up, key));
assert!(matches_key!(up, alt_key));
assert!(!matches_key!(up, Key::Char('t')));
}
#[test]
fn test_matches_key_macro_with_alt_keybinding_uses_alt_key_when_ignore_special_keys_is_false() {
let alt_key = Key::Char('k');
let key = Key::Up;
assert!(matches_key!(up, key, false));
assert!(matches_key!(up, alt_key, false));
assert!(!matches_key!(up, Key::Char('t'), false));
}
#[test]
fn test_matches_key_macro_with_alt_keybinding_ignores_alt_key_when_ignore_special_keys_is_true() {
let alt_key = Key::Char('k');
let key = Key::Up;
assert!(matches_key!(up, key, true));
assert!(!matches_key!(up, alt_key, true));
assert!(!matches_key!(up, Key::Char('t'), true));
}
}
+225
View File
@@ -0,0 +1,225 @@
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS,
ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS,
TRACK_DETAILS_BLOCKS,
};
#[cfg(test)]
#[path = "lidarr_context_clues_tests.rs"]
mod lidarr_context_clues_tests;
pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 10] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.update, "update all"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"),
];
pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit artist"),
(DEFAULT_KEYBINDINGS.delete, "delete album"),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
"toggle album monitoring",
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static ARTIST_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit artist"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static MANUAL_ARTIST_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit artist"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static ALBUM_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
(DEFAULT_KEYBINDINGS.submit, "track details"),
(DEFAULT_KEYBINDINGS.delete, "delete track"),
];
pub static ALBUM_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static MANUAL_ALBUM_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static TRACK_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static TRACK_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub(in crate::app) struct LidarrContextClueProvider;
impl ContextClueProvider for LidarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() else {
panic!("LidarrContextClueProvider::get_context_clues called with non-Lidarr route");
};
match active_lidarr_block {
_ if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.artist_info_tabs
.get_active_route_contextual_help(),
_ if ALBUM_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.album_details_modal
.as_ref()
.expect("album_details_modal is empty")
.album_details_tabs
.get_active_route_contextual_help(),
_ if TRACK_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.album_details_modal
.as_ref()
.expect("album_details_modal is empty")
.track_details_modal
.as_ref()
.expect("track_details_modal is empty")
.track_details_tabs
.get_active_route_contextual_help(),
ActiveLidarrBlock::AddArtistSearchInput
| ActiveLidarrBlock::AddArtistEmptySearchResults
| ActiveLidarrBlock::TestAllIndexers
| ActiveLidarrBlock::SystemLogs
| ActiveLidarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if EDIT_ARTIST_BLOCKS.contains(&active_lidarr_block)
|| EDIT_INDEXER_BLOCKS.contains(&active_lidarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_lidarr_block)
|| ADD_ROOT_FOLDER_BLOCKS.contains(&active_lidarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveLidarrBlock::AddArtistPrompt
| ActiveLidarrBlock::AddArtistSelectMonitor
| ActiveLidarrBlock::AddArtistSelectMonitorNewItems
| ActiveLidarrBlock::AddArtistSelectQualityProfile
| ActiveLidarrBlock::AddArtistSelectMetadataProfile
| ActiveLidarrBlock::AddArtistSelectRootFolder
| ActiveLidarrBlock::AddArtistTagsInput
| ActiveLidarrBlock::AddArtistAlreadyInLibrary => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) => {
Some(&ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveLidarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ => app
.data
.lidarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
@@ -0,0 +1,589 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::lidarr::lidarr_context_clues::{
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ALBUM_DETAILS_CONTEXT_CLUES,
ALBUM_HISTORY_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES,
ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, MANUAL_ALBUM_SEARCH_CONTEXT_CLUES,
MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, TRACK_DETAILS_CONTEXT_CLUES, TRACK_HISTORY_CONTEXT_CLUES,
};
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ROOT_FOLDER_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS,
INDEXER_SETTINGS_BLOCKS, LidarrData,
};
use crate::models::servarr_data::lidarr::modals::{AlbumDetailsModal, TrackDetailsModal};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use rstest::rstest;
#[test]
fn test_artists_context_clues() {
let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(artists_context_clues_iter.next());
}
#[test]
fn test_artist_details_context_clues() {
let mut artist_details_context_clues_iter = ARTIST_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit artist")
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete album")
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
"toggle album monitoring",
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(artist_details_context_clues_iter.next());
}
#[test]
fn test_add_artist_search_results_context_clues() {
let mut add_artist_search_results_context_clues_iter =
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
add_artist_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
add_artist_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
);
assert_none!(add_artist_search_results_context_clues_iter.next());
}
#[test]
#[should_panic(
expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route"
)]
fn test_lidarr_context_clue_provider_get_context_clues_non_lidarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
LidarrContextClueProvider::get_context_clues(&mut app);
}
#[test]
fn test_artist_history_context_clues() {
let mut artist_history_context_clues_iter = ARTIST_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit artist")
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(artist_history_context_clues_iter.next());
}
#[test]
fn test_manual_artist_search_context_clues() {
let mut manual_artist_search_context_clues_iter = MANUAL_ARTIST_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit artist")
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_artist_search_context_clues_iter.next());
}
#[test]
fn test_album_details_context_clues() {
let mut album_details_context_clues_iter = ALBUM_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "track details")
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete track")
);
assert_none!(album_details_context_clues_iter.next());
}
#[test]
fn test_album_history_context_clues() {
let mut album_history_context_clues_iter = ALBUM_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(album_history_context_clues_iter.next());
}
#[test]
fn test_manual_album_search_context_clues() {
let mut manual_album_search_context_clues_iter = MANUAL_ALBUM_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_album_search_context_clues_iter.next());
}
#[test]
fn test_track_details_context_clues() {
let mut track_details_context_clues_iter = TRACK_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
track_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
track_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(track_details_context_clues_iter.next());
}
#[test]
fn test_track_history_context_clues() {
let mut track_history_context_clues_iter = TRACK_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(track_history_context_clues_iter.next());
}
#[rstest]
#[case(0, ActiveLidarrBlock::ArtistDetails, &ARTIST_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveLidarrBlock::ArtistHistory, &ARTIST_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveLidarrBlock::ManualArtistSearch, &MANUAL_ARTIST_SEARCH_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_artist_info_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.lidarr_data = LidarrData::default();
app.data.lidarr_data.artist_info_tabs.set_index(index);
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveLidarrBlock::AlbumDetails, &ALBUM_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveLidarrBlock::AlbumHistory, &ALBUM_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveLidarrBlock::ManualAlbumSearch, &MANUAL_ALBUM_SEARCH_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_album_details_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut album_details_modal = AlbumDetailsModal::default();
album_details_modal.album_details_tabs.set_index(index);
let lidarr_data = LidarrData {
album_details_modal: Some(album_details_modal),
..LidarrData::default()
};
app.data.lidarr_data = lidarr_data;
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveLidarrBlock::TrackDetails, &TRACK_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveLidarrBlock::TrackHistory, &TRACK_HISTORY_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_track_details_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut track_details_modal = TrackDetailsModal::default();
track_details_modal.track_details_tabs.set_index(index);
let album_details_modal = AlbumDetailsModal {
track_details_modal: Some(track_details_modal),
..AlbumDetailsModal::default()
};
let lidarr_data = LidarrData {
album_details_modal: Some(album_details_modal),
..LidarrData::default()
};
app.data.lidarr_data = lidarr_data;
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[test]
fn test_lidarr_context_clue_provider_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_artists_sort_prompt_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistsSortPrompt.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_search_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::SearchArtists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_filter_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::FilterArtists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_bare_popup_context_clues(
#[values(
ActiveLidarrBlock::AddArtistSearchInput,
ActiveLidarrBlock::AddArtistEmptySearchResults,
ActiveLidarrBlock::TestAllIndexers
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks() {
let mut blocks = EDIT_ARTIST_BLOCKS.to_vec();
blocks.extend(ADD_ROOT_FOLDER_BLOCKS);
blocks.extend(INDEXER_SETTINGS_BLOCKS);
blocks.extend(EDIT_INDEXER_BLOCKS);
for active_lidarr_block in blocks {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
}
#[test]
fn test_lidarr_context_clue_provider_add_artist_search_results_context_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_add_artist_blocks(
#[values(
ActiveLidarrBlock::AddArtistPrompt,
ActiveLidarrBlock::AddArtistSelectMonitor,
ActiveLidarrBlock::AddArtistSelectMonitorNewItems,
ActiveLidarrBlock::AddArtistSelectQualityProfile,
ActiveLidarrBlock::AddArtistSelectMetadataProfile,
ActiveLidarrBlock::AddArtistSelectRootFolder,
ActiveLidarrBlock::AddArtistTagsInput,
ActiveLidarrBlock::AddArtistAlreadyInLibrary
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_system_tasks_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::SystemTasks.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES);
}
}
+783
View File
@@ -0,0 +1,783 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::models::lidarr_models::{Album, Artist, LidarrRelease};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal;
use crate::models::servarr_models::Indexer;
use crate::network::NetworkEvent;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
album, artist, track,
};
use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc;
#[tokio::test]
async fn test_dispatch_by_lidarr_block_artists() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::ListArtists.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_lidarr_block_artist_details() {
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::ArtistDetails)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetAlbums(1).into());
assert!(!app.data.lidarr_data.prompt_confirm);
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);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ArtistHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetArtistHistory(1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_artist_search_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualArtistSearch)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDiscographyReleases(1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_artist_search_block_discography_releases_non_empty() {
let mut app = App::test_default();
app
.data
.lidarr_data
.discography_releases
.set_items(vec![LidarrRelease::default()]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualArtistSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_album_details_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.data.lidarr_data.albums.set_items(vec![Album {
id: 1,
..Album::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumDetails)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTracks(1, 1).into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackFiles(1).into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_album_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.data.lidarr_data.albums.set_items(vec![Album {
id: 1,
..Album::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetAlbumHistory(1, 1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_album_history_block_no_op_when_albums_table_is_empty() {
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumHistory)
.await;
assert!(!app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_album_search_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.data.lidarr_data.albums.set_items(vec![Album {
id: 1,
..Album::default()
}]);
app.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default());
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualAlbumSearch)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetAlbumReleases(1, 1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_album_search_block_is_loading() {
let mut app = App {
is_loading: true,
..App::test_default()
};
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualAlbumSearch)
.await;
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_album_search_block_album_releases_non_empty() {
let mut app = App::test_default();
let mut album_details_modal = AlbumDetailsModal::default();
album_details_modal
.album_releases
.set_items(vec![LidarrRelease::default()]);
app.data.lidarr_data.album_details_modal = Some(album_details_modal);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualAlbumSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_downloads_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Downloads)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_lidarr_block_add_artist_search_results() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.add_artist_search = Some("test artist".into());
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AddArtistSearchResults)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::SearchNewArtist("test artist".to_owned()).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::History)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetHistory(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_root_folders_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::RootFolders)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_indexers_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Indexers)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetIndexers.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_all_indexer_settings_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AllIndexerSettingsPrompt)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetAllIndexerSettings.into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_indexer_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.indexers.set_items(vec![Indexer {
id: 1,
..Indexer::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TestIndexer)
.await;
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::TestIndexer(1).into());
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_all_indexers_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TestAllIndexers)
.await;
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::TestAllIndexers.into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::System)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTasks.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQueuedEvents.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetLogs(500).into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_updates_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::SystemUpdates)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetUpdates.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_track_details_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TrackDetails)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackDetails(1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_track_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TrackHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackHistory(1, 1, 1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_check_for_lidarr_prompt_action_no_prompt_confirm() {
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = false;
app.check_for_lidarr_prompt_action().await;
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.should_refresh);
}
#[tokio::test]
async fn test_check_for_lidarr_prompt_action() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::GetStatus);
app.check_for_lidarr_prompt_action().await;
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetStatus.into());
assert!(app.should_refresh);
assert_eq!(app.data.lidarr_data.prompt_confirm_action, None);
}
#[tokio::test]
async fn test_lidarr_refresh_metadata() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.refresh_lidarr_metadata().await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetDiskSpace.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetStatus.into());
assert!(app.is_loading);
}
#[tokio::test]
async fn test_lidarr_on_tick_first_render() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_first_render = true;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetDiskSpace.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetStatus.into());
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.is_first_render);
}
#[tokio::test]
async fn test_lidarr_on_tick_routing() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_lidarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.should_refresh = false;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert!(app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_lidarr_on_tick_should_refresh() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(app.should_refresh);
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_lidarr_on_tick_should_refresh_does_not_cancel_prompt_requests() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_loading = true;
app.is_routing = true;
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
assert!(app.should_refresh);
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_lidarr_on_tick_network_tick_frequency() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.tick_count = 2;
app.tick_until_poll = 2;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
}
#[tokio::test]
async fn test_extract_add_new_artist_search_query() {
let app = App::test_default_fully_populated();
let query = app.extract_add_new_artist_search_query().await;
assert_str_eq!(query, "Test Artist");
}
#[tokio::test]
#[should_panic(expected = "Add artist search is empty")]
async fn test_extract_add_new_artist_search_query_panics_when_the_query_is_not_set() {
let app = App::test_default();
app.extract_add_new_artist_search_query().await;
}
#[tokio::test]
async fn test_extract_artist_id() {
let mut app = App::test_default();
app.data.lidarr_data.artists.set_items(vec![artist()]);
assert_eq!(app.extract_artist_id().await, 1);
}
#[tokio::test]
async fn test_extract_album_id() {
let mut app = App::test_default();
app.data.lidarr_data.albums.set_items(vec![album()]);
assert_eq!(app.extract_album_id().await, 1);
}
#[tokio::test]
async fn test_extract_track_id() {
let mut app = App::test_default();
let mut album_details_modal = AlbumDetailsModal::default();
album_details_modal.tracks.set_items(vec![track()]);
app.data.lidarr_data.album_details_modal = Some(album_details_modal);
assert_eq!(app.extract_track_id().await, 1);
}
#[tokio::test]
#[should_panic(expected = "album_details_modal is empty")]
async fn test_extract_track_id_panics_when_album_details_modal_is_not_set() {
let app = App::test_default();
app.extract_track_id().await;
}
#[tokio::test]
async fn test_extract_lidarr_indexer_id() {
let mut app = App::test_default();
app.data.lidarr_data.indexers.set_items(vec![Indexer {
id: 1,
..Indexer::default()
}]);
assert_eq!(app.extract_lidarr_indexer_id().await, 1);
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::App;
use crate::{
models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
network::lidarr_network::LidarrEvent,
};
pub mod lidarr_context_clues;
#[cfg(test)]
#[path = "lidarr_tests.rs"]
mod lidarr_tests;
impl App<'_> {
pub(super) async fn dispatch_by_lidarr_block(&mut self, active_lidarr_block: &ActiveLidarrBlock) {
match active_lidarr_block {
ActiveLidarrBlock::Artists => {
self
.dispatch_network_event(LidarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.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())
.await;
}
ActiveLidarrBlock::ArtistDetails => {
self
.dispatch_network_event(LidarrEvent::GetAlbums(self.extract_artist_id().await).into())
.await;
}
ActiveLidarrBlock::ArtistHistory => {
self
.dispatch_network_event(
LidarrEvent::GetArtistHistory(self.extract_artist_id().await).into(),
)
.await;
}
ActiveLidarrBlock::ManualArtistSearch => {
if self.data.lidarr_data.discography_releases.is_empty() {
self
.dispatch_network_event(
LidarrEvent::GetDiscographyReleases(self.extract_artist_id().await).into(),
)
.await;
}
}
ActiveLidarrBlock::AlbumDetails => {
let artist_id = self.extract_artist_id().await;
let album_id = self.extract_album_id().await;
self
.dispatch_network_event(LidarrEvent::GetTracks(artist_id, album_id).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTrackFiles(album_id).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
.await;
}
ActiveLidarrBlock::AlbumHistory => {
if !self.data.lidarr_data.albums.is_empty() {
self
.dispatch_network_event(
LidarrEvent::GetAlbumHistory(
self.extract_artist_id().await,
self.extract_album_id().await,
)
.into(),
)
.await;
}
}
ActiveLidarrBlock::ManualAlbumSearch => {
match self.data.lidarr_data.album_details_modal.as_ref() {
Some(album_details_modal) if album_details_modal.album_releases.is_empty() => {
self
.dispatch_network_event(
LidarrEvent::GetAlbumReleases(
self.extract_artist_id().await,
self.extract_album_id().await,
)
.into(),
)
.await;
}
_ => (),
}
}
ActiveLidarrBlock::AddArtistSearchResults => {
self
.dispatch_network_event(
LidarrEvent::SearchNewArtist(self.extract_add_new_artist_search_query().await).into(),
)
.await;
}
ActiveLidarrBlock::History => {
self
.dispatch_network_event(LidarrEvent::GetHistory(500).into())
.await
}
ActiveLidarrBlock::RootFolders => {
self
.dispatch_network_event(LidarrEvent::GetRootFolders.into())
.await;
}
ActiveLidarrBlock::Indexers => {
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetIndexers.into())
.await;
}
ActiveLidarrBlock::AllIndexerSettingsPrompt => {
self
.dispatch_network_event(LidarrEvent::GetAllIndexerSettings.into())
.await;
}
ActiveLidarrBlock::TestIndexer => {
self
.dispatch_network_event(
LidarrEvent::TestIndexer(self.extract_lidarr_indexer_id().await).into(),
)
.await;
}
ActiveLidarrBlock::TestAllIndexers => {
self
.dispatch_network_event(LidarrEvent::TestAllIndexers.into())
.await;
}
ActiveLidarrBlock::System => {
self
.dispatch_network_event(LidarrEvent::GetTasks.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetQueuedEvents.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetLogs(500).into())
.await;
}
ActiveLidarrBlock::SystemUpdates => {
self
.dispatch_network_event(LidarrEvent::GetUpdates.into())
.await;
}
ActiveLidarrBlock::TrackDetails => {
self
.dispatch_network_event(
LidarrEvent::GetTrackDetails(self.extract_track_id().await).into(),
)
.await;
}
ActiveLidarrBlock::TrackHistory => {
let artist_id = self.extract_artist_id().await;
let album_id = self.extract_album_id().await;
let track_id = self.extract_track_id().await;
self
.dispatch_network_event(
LidarrEvent::GetTrackHistory(artist_id, album_id, track_id).into(),
)
.await;
}
_ => (),
}
self.check_for_lidarr_prompt_action().await;
self.reset_tick_count();
}
async fn extract_add_new_artist_search_query(&self) -> String {
self
.data
.lidarr_data
.add_artist_search
.as_ref()
.expect("Add artist search is empty")
.text
.clone()
}
async fn extract_artist_id(&self) -> i64 {
self.data.lidarr_data.artists.current_selection().id
}
async fn extract_album_id(&self) -> i64 {
self.data.lidarr_data.albums.current_selection().id
}
async fn extract_track_id(&self) -> i64 {
self
.data
.lidarr_data
.album_details_modal
.as_ref()
.expect("album_details_modal is empty")
.tracks
.current_selection()
.id
}
async fn extract_lidarr_indexer_id(&self) -> i64 {
self.data.lidarr_data.indexers.current_selection().id
}
async fn check_for_lidarr_prompt_action(&mut self) {
if self.data.lidarr_data.prompt_confirm {
self.data.lidarr_data.prompt_confirm = false;
if let Some(lidarr_event) = self.data.lidarr_data.prompt_confirm_action.take() {
self.dispatch_network_event(lidarr_event.into()).await;
self.should_refresh = true;
}
}
}
pub(super) async fn lidarr_on_tick(&mut self, active_lidarr_block: ActiveLidarrBlock) {
if self.is_first_render {
self.refresh_lidarr_metadata().await;
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
self.is_first_render = false;
return;
}
if self.should_refresh {
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
self.refresh_lidarr_metadata().await;
}
if self.is_routing {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
}
}
if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_lidarr_metadata().await;
}
}
async fn refresh_lidarr_metadata(&mut self) {
self
.dispatch_network_event(LidarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetStatus.into())
.await;
}
}
+483 -69
View File
@@ -1,51 +1,151 @@
use anyhow::anyhow;
use anyhow::{Error, Result, anyhow};
use colored::Colorize;
use itertools::Itertools;
use log::{debug, error};
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::{fs, process};
use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken;
use veil::Redact;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
use crate::cli::Command;
use crate::models::servarr_data::Notification;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::servarr_models::KeybindingItem;
use crate::models::stateful_table::StatefulTable;
use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState};
use crate::network::NetworkEvent;
#[cfg(test)]
#[path = "app_tests.rs"]
mod app_tests;
pub mod context_clues;
pub mod key_binding;
mod key_binding_tests;
pub mod lidarr;
pub mod radarr;
const DEFAULT_ROUTE: Route = Route::Radarr(ActiveRadarrBlock::Movies, None);
pub mod sonarr;
pub struct App<'a> {
navigation_stack: Vec<Route>,
network_tx: Option<Sender<NetworkEvent>>,
cancellation_token: CancellationToken,
pub cancellation_token: CancellationToken,
pub is_first_render: bool,
pub server_tabs: TabState,
pub keymapping_table: Option<StatefulTable<KeybindingItem>>,
pub error: HorizontallyScrollableText,
pub notification: Option<Notification>,
pub tick_until_poll: u64,
pub ticks_until_scroll: u64,
pub tick_count: u64,
pub ui_scroll_tick_count: u64,
pub is_routing: bool,
pub is_loading: bool,
pub should_refresh: bool,
pub should_ignore_quit_key: bool,
pub config: AppConfig,
pub ignore_special_keys_for_textbox_input: bool,
pub cli_mode: bool,
pub data: Data<'a>,
}
impl<'a> App<'a> {
impl App<'_> {
pub fn new(
network_tx: Sender<NetworkEvent>,
config: AppConfig,
cancellation_token: CancellationToken,
) -> Self {
let mut server_tabs = Vec::new();
if let Some(radarr_configs) = config.radarr {
let mut unnamed_idx = 0;
let radarr_tabs = radarr_configs.into_iter().map(|radarr_config| {
let name = if let Some(name) = radarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Radarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveRadarrBlock::Movies.into(),
contextual_help: None,
config: Some(radarr_config),
}
});
server_tabs.extend(radarr_tabs);
}
if let Some(sonarr_configs) = config.sonarr {
let mut unnamed_idx = 0;
let sonarr_tabs = sonarr_configs.into_iter().map(|sonarr_config| {
let name = if let Some(name) = sonarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Sonarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveSonarrBlock::Series.into(),
contextual_help: None,
config: Some(sonarr_config),
}
});
server_tabs.extend(sonarr_tabs);
}
if let Some(lidarr_configs) = config.lidarr {
let mut unnamed_idx = 0;
let lidarr_tabs = lidarr_configs.into_iter().map(|lidarr_config| {
let name = if let Some(name) = lidarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Lidarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(lidarr_config),
}
});
server_tabs.extend(lidarr_tabs);
}
let weight_sorted_tabs = server_tabs
.into_iter()
.sorted_by(|tab1, tab2| {
Ord::cmp(
tab1
.config
.as_ref()
.unwrap()
.weight
.as_ref()
.unwrap_or(&1000),
tab2
.config
.as_ref()
.unwrap()
.weight
.as_ref()
.unwrap_or(&1000),
)
})
.collect();
App {
network_tx: Some(network_tx),
config,
cancellation_token,
server_tabs: TabState::new(weight_sorted_tabs),
..App::default()
}
}
@@ -53,13 +153,16 @@ impl<'a> App<'a> {
pub async fn dispatch_network_event(&mut self, action: NetworkEvent) {
debug!("Dispatching network event: {action:?}");
self.is_loading = true;
if let Some(network_tx) = &self.network_tx {
if let Err(e) = network_tx.send(action).await {
self.is_loading = false;
error!("Failed to send event. {e:?}");
self.handle_error(anyhow!(e));
}
if !self.should_refresh {
self.is_loading = true;
}
if let Some(network_tx) = &self.network_tx
&& let Err(e) = network_tx.send(action).await
{
self.is_loading = false;
error!("Failed to send event. {e:?}");
self.handle_error(anyhow!(e));
}
}
@@ -67,26 +170,38 @@ impl<'a> App<'a> {
self.tick_count = 0;
}
// Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then
pub fn on_ui_scroll_tick(&mut self) {
if self.ui_scroll_tick_count == self.ticks_until_scroll {
self.ui_scroll_tick_count = 0;
} else {
self.ui_scroll_tick_count += 1;
}
}
#[allow(dead_code)]
pub fn reset(&mut self) {
self.reset_tick_count();
self.error = HorizontallyScrollableText::default();
self.is_first_render = true;
self.data = Data::default();
}
pub fn handle_error(&mut self, error: anyhow::Error) {
pub fn handle_error(&mut self, error: Error) {
if self.error.text.is_empty() {
self.error = error.to_string().into();
}
}
pub async fn on_tick(&mut self, is_first_render: bool) {
if self.tick_count % self.tick_until_poll == 0 || self.is_routing || self.should_refresh {
if let Route::Radarr(active_radarr_block, _) = self.get_current_route() {
self
.radarr_on_tick(*active_radarr_block, is_first_render)
.await;
pub async fn on_tick(&mut self) {
if self.tick_count.is_multiple_of(self.tick_until_poll)
|| self.is_routing
|| self.should_refresh
{
match self.get_current_route() {
Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await,
Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await,
Route::Lidarr(active_lidarr_block, _) => self.lidarr_on_tick(active_lidarr_block).await,
_ => (),
}
self.is_routing = false;
@@ -103,13 +218,15 @@ impl<'a> App<'a> {
pub fn pop_navigation_stack(&mut self) {
self.is_routing = true;
if self.navigation_stack.len() > 1 {
if !self.navigation_stack.is_empty() {
self.navigation_stack.pop();
}
}
pub fn reset_cancellation_token(&mut self) -> CancellationToken {
self.cancellation_token = CancellationToken::new();
self.should_refresh = true;
self.is_loading = false;
self.cancellation_token.clone()
}
@@ -119,76 +236,373 @@ impl<'a> App<'a> {
self.push_navigation_stack(route);
}
pub fn get_current_route(&self) -> &Route {
self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE)
pub fn get_current_route(&self) -> Route {
*self.navigation_stack.last().unwrap_or(
&self
.server_tabs
.tabs
.first()
.expect("At least one server tab must exist")
.route,
)
}
}
impl<'a> Default for App<'a> {
impl Default for App<'_> {
fn default() -> Self {
App {
navigation_stack: vec![DEFAULT_ROUTE],
navigation_stack: Vec::new(),
network_tx: None,
cancellation_token: CancellationToken::new(),
keymapping_table: None,
error: HorizontallyScrollableText::default(),
server_tabs: TabState::new(vec![
TabRoute {
title: "Radarr",
route: ActiveRadarrBlock::Movies.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
},
TabRoute {
title: "Sonarr",
route: Route::Sonarr,
help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)),
contextual_help: None,
},
]),
notification: None,
is_first_render: true,
server_tabs: TabState::new(Vec::new()),
tick_until_poll: 400,
ticks_until_scroll: 4,
ticks_until_scroll: 64,
tick_count: 0,
ui_scroll_tick_count: 0,
is_loading: false,
is_routing: false,
should_refresh: false,
should_ignore_quit_key: false,
config: AppConfig::default(),
ignore_special_keys_for_textbox_input: false,
cli_mode: false,
data: Data::default(),
}
}
}
#[cfg(test)]
impl App<'_> {
pub fn test_default() -> Self {
App {
server_tabs: TabState::new(vec![
TabRoute {
title: "Radarr".to_owned(),
route: ActiveRadarrBlock::Movies.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Sonarr".to_owned(),
route: ActiveSonarrBlock::Series.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]),
..App::default()
}
}
pub fn test_default_fully_populated() -> Self {
App {
data: Data {
lidarr_data: LidarrData::test_default_fully_populated(),
radarr_data: RadarrData::test_default_fully_populated(),
sonarr_data: SonarrData::test_default_fully_populated(),
},
server_tabs: TabState::new(vec![
TabRoute {
title: "Radarr".to_owned(),
route: ActiveRadarrBlock::Movies.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Sonarr".to_owned(),
route: ActiveSonarrBlock::Series.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]),
..App::default()
}
}
}
#[derive(Default)]
pub struct Data<'a> {
pub lidarr_data: LidarrData<'a>,
pub radarr_data: RadarrData<'a>,
pub sonarr_data: SonarrData<'a>,
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppConfig {
pub radarr: RadarrConfig,
pub theme: Option<String>,
pub lidarr: Option<Vec<ServarrConfig>>,
pub radarr: Option<Vec<ServarrConfig>>,
pub sonarr: Option<Vec<ServarrConfig>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RadarrConfig {
pub host: String,
pub port: Option<u16>,
pub api_token: String,
#[serde(default)]
pub use_ssl: bool,
pub ssl_cert_path: Option<String>,
}
impl AppConfig {
pub fn validate(&self, config_path: &str) {
if self.lidarr.is_none() && self.radarr.is_none() && self.sonarr.is_none() {
log_and_print_error(format!(
"No Servarrs are configured in the file: {config_path}"
));
process::exit(1);
}
impl Default for RadarrConfig {
fn default() -> Self {
RadarrConfig {
host: "localhost".to_string(),
port: Some(7878),
api_token: "".to_string(),
use_ssl: false,
ssl_cert_path: None,
if let Some(radarr_configs) = &self.radarr {
radarr_configs.iter().for_each(|config| config.validate());
}
if let Some(sonarr_configs) = &self.sonarr {
sonarr_configs.iter().for_each(|config| config.validate());
}
if let Some(lidarr_configs) = &self.lidarr {
lidarr_configs.iter().for_each(|config| config.validate());
}
}
pub fn verify_config_present_for_cli(&self, command: &Command) {
let msg = |servarr: &str| {
log_and_print_error(format!(
"{servarr} configuration missing; Unable to run any {servarr} commands."
))
};
match command {
Command::Radarr(_) if self.radarr.is_none() => {
msg("Radarr");
process::exit(1);
}
Command::Sonarr(_) if self.sonarr.is_none() => {
msg("Sonarr");
process::exit(1);
}
Command::Lidarr(_) if self.lidarr.is_none() => {
msg("Lidarr");
process::exit(1);
}
_ => (),
}
}
pub fn post_process_initialization(&mut self) {
if let Some(radarr_configs) = self.radarr.as_mut() {
for radarr_config in radarr_configs {
radarr_config.post_process_initialization();
}
}
if let Some(sonarr_configs) = self.sonarr.as_mut() {
for sonarr_config in sonarr_configs {
sonarr_config.post_process_initialization();
}
}
if let Some(lidarr_configs) = self.lidarr.as_mut() {
for lidarr_config in lidarr_configs {
lidarr_config.post_process_initialization();
}
}
}
}
#[derive(Redact, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ServarrConfig {
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub name: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub host: Option<String>,
#[serde(default, deserialize_with = "deserialize_u16_env_var")]
pub port: Option<u16>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub uri: Option<String>,
#[serde(default, deserialize_with = "deserialize_u16_env_var")]
pub weight: Option<u16>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
#[redact]
pub api_token: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub api_token_file: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub ssl_cert_path: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_env_var_header_map",
serialize_with = "serialize_header_map"
)]
pub custom_headers: Option<HeaderMap>,
#[serde(default, deserialize_with = "deserialize_optional_env_var_string_vec")]
pub monitored_storage_paths: Option<Vec<String>>,
}
impl ServarrConfig {
fn validate(&self) {
if self.host.is_none() && self.uri.is_none() {
log_and_print_error("'host' or 'uri' is required for configuration".to_owned());
process::exit(1);
}
if self.api_token_file.is_none() && self.api_token.is_none() {
log_and_print_error(
"'api_token' or 'api_token_path' is required for configuration".to_owned(),
);
process::exit(1);
}
}
pub fn post_process_initialization(&mut self) {
if let Some(api_token_file) = self.api_token_file.as_ref() {
if !PathBuf::from(api_token_file).exists() {
log_and_print_error(format!(
"The specified {api_token_file} API token file does not exist"
));
process::exit(1);
}
let api_token = fs::read_to_string(api_token_file)
.map_err(|e| anyhow!(e))
.unwrap();
self.api_token = Some(api_token.trim().to_owned());
}
}
}
impl Default for ServarrConfig {
fn default() -> Self {
ServarrConfig {
name: None,
host: Some("localhost".to_string()),
port: None,
uri: None,
weight: None,
api_token: Some(String::new()),
api_token_file: None,
ssl_cert_path: None,
custom_headers: None,
monitored_storage_paths: None,
}
}
}
pub fn log_and_print_error(error: String) {
error!("{error}");
eprintln!("error: {}", error.red());
}
fn serialize_header_map<S>(headers: &Option<HeaderMap>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if let Some(headers) = headers {
let mut map = HashMap::new();
for (name, value) in headers.iter() {
let name_str = name.as_str().to_string();
let value_str = value
.to_str()
.map_err(serde::ser::Error::custom)?
.to_string();
map.insert(name_str, value_str);
}
map.serialize(serializer)
} else {
serializer.serialize_none()
}
}
fn deserialize_optional_env_var<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
Ok(Some(interpolated))
}
None => Ok(None),
}
}
fn deserialize_optional_env_var_header_map<'de, D>(
deserializer: D,
) -> Result<Option<HeaderMap>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<HashMap<String, String>> = Option::deserialize(deserializer)?;
match opt {
Some(map) => {
let mut header_map = HeaderMap::new();
for (k, v) in map.iter() {
let name = HeaderName::from_bytes(k.as_bytes()).map_err(serde::de::Error::custom)?;
let value_str = interpolate_env_vars(v);
let value = HeaderValue::from_str(&value_str).map_err(serde::de::Error::custom)?;
header_map.insert(name, value);
}
Ok(Some(header_map))
}
None => Ok(None),
}
}
fn deserialize_optional_env_var_string_vec<'de, D>(
deserializer: D,
) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
match opt {
Some(vec) => Ok(Some(
vec
.into_iter()
.map(|it| interpolate_env_vars(&it))
.collect(),
)),
None => Ok(None),
}
}
fn deserialize_u16_env_var<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
interpolated
.parse::<u16>()
.map(Some)
.map_err(serde::de::Error::custom)
}
None => Ok(None),
}
}
fn interpolate_env_vars(s: &str) -> String {
let result = s.to_string();
let scrubbing_regex = Regex::new(r#"[\s{}!$^()\[\]\\|`'"]+"#).unwrap();
let var_regex = Regex::new(r"\$\{(.*?)}").unwrap();
var_regex
.replace_all(s, |caps: &regex::Captures<'_>| {
if let Some(mat) = caps.get(1)
&& let Ok(value) = std::env::var(mat.as_str())
{
return scrubbing_regex.replace_all(&value, "").to_string();
}
scrubbing_regex.replace_all(&result, "").to_string()
})
.to_string()
}
+88 -46
View File
@@ -8,7 +8,7 @@ pub mod radarr_context_clues;
#[path = "radarr_tests.rs"]
mod radarr_tests;
impl<'a> App<'a> {
impl App<'_> {
pub(super) async fn dispatch_by_radarr_block(&mut self, active_radarr_block: &ActiveRadarrBlock) {
match active_radarr_block {
ActiveRadarrBlock::Blocklist => {
@@ -17,18 +17,30 @@ impl<'a> App<'a> {
.await;
}
ActiveRadarrBlock::Collections => {
self
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetCollections.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetMovies.into())
.await;
}
ActiveRadarrBlock::CollectionDetails => {
self
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetTags.into())
.await;
self.is_loading = true;
self.populate_movie_collection_table().await;
self.is_loading = false;
}
ActiveRadarrBlock::Downloads => {
self
.dispatch_network_event(RadarrEvent::GetDownloads.into())
.dispatch_network_event(RadarrEvent::GetDownloads(500).into())
.await;
}
ActiveRadarrBlock::RootFolders => {
@@ -37,14 +49,28 @@ impl<'a> App<'a> {
.await;
}
ActiveRadarrBlock::Movies => {
self
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetMovies.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetDownloads.into())
.dispatch_network_event(RadarrEvent::GetDownloads(500).into())
.await;
}
ActiveRadarrBlock::History => {
self
.dispatch_network_event(RadarrEvent::GetHistory(500).into())
.await;
}
ActiveRadarrBlock::Indexers => {
self
.dispatch_network_event(RadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetIndexers.into())
.await;
@@ -56,7 +82,9 @@ impl<'a> App<'a> {
}
ActiveRadarrBlock::TestIndexer => {
self
.dispatch_network_event(RadarrEvent::TestIndexer(None).into())
.dispatch_network_event(
RadarrEvent::TestIndexer(self.extract_radarr_indexer_id().await).into(),
)
.await;
}
ActiveRadarrBlock::TestAllIndexers => {
@@ -72,7 +100,7 @@ impl<'a> App<'a> {
.dispatch_network_event(RadarrEvent::GetQueuedEvents.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetLogs(None).into())
.dispatch_network_event(RadarrEvent::GetLogs(500).into())
.await;
}
ActiveRadarrBlock::SystemUpdates => {
@@ -82,17 +110,23 @@ impl<'a> App<'a> {
}
ActiveRadarrBlock::AddMovieSearchResults => {
self
.dispatch_network_event(RadarrEvent::SearchNewMovie(None).into())
.dispatch_network_event(
RadarrEvent::SearchNewMovie(self.extract_movie_search_query().await).into(),
)
.await;
}
ActiveRadarrBlock::MovieDetails | ActiveRadarrBlock::FileInfo => {
self
.dispatch_network_event(RadarrEvent::GetMovieDetails(None).into())
.dispatch_network_event(
RadarrEvent::GetMovieDetails(self.extract_movie_id().await).into(),
)
.await;
}
ActiveRadarrBlock::MovieHistory => {
self
.dispatch_network_event(RadarrEvent::GetMovieHistory(None).into())
.dispatch_network_event(
RadarrEvent::GetMovieHistory(self.extract_movie_id().await).into(),
)
.await;
}
ActiveRadarrBlock::Cast | ActiveRadarrBlock::Crew => {
@@ -102,7 +136,9 @@ impl<'a> App<'a> {
|| movie_details_modal.movie_crew.items.is_empty() =>
{
self
.dispatch_network_event(RadarrEvent::GetMovieCredits(None).into())
.dispatch_network_event(
RadarrEvent::GetMovieCredits(self.extract_movie_id().await).into(),
)
.await;
}
_ => (),
@@ -111,7 +147,7 @@ impl<'a> App<'a> {
ActiveRadarrBlock::ManualSearch => match self.data.radarr_data.movie_details_modal.as_ref() {
Some(movie_details_modal) if movie_details_modal.movie_releases.items.is_empty() => {
self
.dispatch_network_event(RadarrEvent::GetReleases(None).into())
.dispatch_network_event(RadarrEvent::GetReleases(self.extract_movie_id().await).into())
.await;
}
_ => (),
@@ -119,66 +155,47 @@ impl<'a> App<'a> {
_ => (),
}
self.check_for_prompt_action().await;
self.check_for_radarr_prompt_action().await;
self.reset_tick_count();
}
async fn check_for_prompt_action(&mut self) {
async fn check_for_radarr_prompt_action(&mut self) {
if self.data.radarr_data.prompt_confirm {
self.data.radarr_data.prompt_confirm = false;
if let Some(radarr_event) = &self.data.radarr_data.prompt_confirm_action {
self
.dispatch_network_event(radarr_event.clone().into())
.await;
if let Some(radarr_event) = self.data.radarr_data.prompt_confirm_action.take() {
self.dispatch_network_event(radarr_event.into()).await;
self.should_refresh = true;
self.data.radarr_data.prompt_confirm_action = None;
}
}
}
pub(super) async fn radarr_on_tick(
&mut self,
active_radarr_block: ActiveRadarrBlock,
is_first_render: bool,
) {
if is_first_render {
self
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetOverview.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
pub(super) async fn radarr_on_tick(&mut self, active_radarr_block: ActiveRadarrBlock) {
if self.is_first_render {
self.refresh_radarr_metadata().await;
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.is_first_render = false;
return;
}
if self.should_refresh {
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_radarr_metadata().await;
}
if self.is_routing {
if self.is_loading && !self.should_refresh {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_radarr_block(&active_radarr_block).await;
}
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await;
}
if self.tick_count % self.tick_until_poll == 0 {
self.refresh_metadata().await;
if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_radarr_metadata().await;
}
}
async fn refresh_metadata(&mut self) {
async fn refresh_radarr_metadata(&mut self) {
self
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
@@ -189,7 +206,13 @@ impl<'a> App<'a> {
.dispatch_network_event(RadarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetDownloads.into())
.dispatch_network_event(RadarrEvent::GetDownloads(500).into())
.await;
self
.dispatch_network_event(RadarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
}
@@ -208,4 +231,23 @@ impl<'a> App<'a> {
.collection_movies
.set_items(collection_movies);
}
async fn extract_movie_id(&self) -> i64 {
self.data.radarr_data.movies.current_selection().id
}
async fn extract_movie_search_query(&self) -> String {
self
.data
.radarr_data
.add_movie_search
.as_ref()
.expect("Add movie search is empty")
.text
.clone()
}
async fn extract_radarr_indexer_id(&self) -> i64 {
self.data.radarr_data.indexers.current_selection().id
}
}
+78 -73
View File
@@ -1,13 +1,26 @@
use crate::app::context_clues::ContextClue;
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
use crate::models::servarr_data::radarr::radarr_data::{
ADD_MOVIE_BLOCKS, ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS, EDIT_INDEXER_BLOCKS,
EDIT_MOVIE_BLOCKS, INDEXER_SETTINGS_BLOCKS, MOVIE_DETAILS_BLOCKS,
};
#[cfg(test)]
#[path = "radarr_context_clues_tests.rs"]
mod radarr_context_clues_tests;
pub static LIBRARY_CONTEXT_CLUES: [ContextClue; 10] = [
pub static LIBRARY_CONTEXT_CLUES: [ContextClue; 11] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
@@ -35,60 +48,6 @@ pub static COLLECTIONS_CONTEXT_CLUES: [ContextClue; 8] = [
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static DOWNLOADS_CONTEXT_CLUES: [ContextClue; 2] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
];
pub static BLOCKLIST_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.clear, "clear blocklist"),
];
pub static ROOT_FOLDERS_CONTEXT_CLUES: [ContextClue; 3] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
];
pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.submit, "edit indexer"),
(
DEFAULT_KEYBINDINGS.settings,
DEFAULT_KEYBINDINGS.settings.desc,
),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.test, "test indexer"),
(DEFAULT_KEYBINDINGS.test_all, "test all indexers"),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
];
pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.tasks, "open tasks"),
(DEFAULT_KEYBINDINGS.events, "open events"),
(DEFAULT_KEYBINDINGS.logs, "open logs"),
(DEFAULT_KEYBINDINGS.update, "open updates"),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
];
pub static MOVIE_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
@@ -96,11 +55,14 @@ pub static MOVIE_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [
),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(DEFAULT_KEYBINDINGS.search, "auto search"),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 6] = [
pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
@@ -108,29 +70,72 @@ pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, "auto search"),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 1] =
[(DEFAULT_KEYBINDINGS.submit, "details")];
pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"),
];
pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.confirm, "submit"),
(DEFAULT_KEYBINDINGS.esc, "cancel"),
];
pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "start task"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static COLLECTION_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [
pub static COLLECTION_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [
(DEFAULT_KEYBINDINGS.submit, "show overview/add movie"),
(DEFAULT_KEYBINDINGS.edit, "edit collection"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub(in crate::app) struct RadarrContextClueProvider;
impl ContextClueProvider for RadarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() else {
panic!("RadarrContextClueProvider::get_context_clues called with non-Radarr route");
};
match active_radarr_block {
_ if MOVIE_DETAILS_BLOCKS.contains(&active_radarr_block) => app
.data
.radarr_data
.movie_info_tabs
.get_active_route_contextual_help(),
ActiveRadarrBlock::TestAllIndexers
| ActiveRadarrBlock::AddMovieSearchInput
| ActiveRadarrBlock::AddMovieEmptySearchResults
| ActiveRadarrBlock::SystemLogs
| ActiveRadarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if context_option.unwrap_or(active_radarr_block)
== ActiveRadarrBlock::ViewMovieOverview =>
{
Some(&BARE_POPUP_CONTEXT_CLUES)
}
ActiveRadarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ if EDIT_COLLECTION_BLOCKS.contains(&active_radarr_block)
|| EDIT_INDEXER_BLOCKS.contains(&active_radarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_radarr_block)
|| EDIT_MOVIE_BLOCKS.contains(&active_radarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveRadarrBlock::AddMoviePrompt
| ActiveRadarrBlock::AddMovieSelectMonitor
| ActiveRadarrBlock::AddMovieSelectMinimumAvailability
| ActiveRadarrBlock::AddMovieSelectQualityProfile
| ActiveRadarrBlock::AddMovieSelectRootFolder
| ActiveRadarrBlock::AddMovieTagsInput
| ActiveRadarrBlock::SystemTaskStartConfirmPrompt => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_MOVIE_BLOCKS.contains(&active_radarr_block) => {
Some(&ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveRadarrBlock::CollectionDetails => Some(&COLLECTION_DETAILS_CONTEXT_CLUES),
_ => app
.data
.radarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
+426 -344
View File
@@ -1,281 +1,152 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::radarr::radarr_context_clues::{
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES,
COLLECTION_DETAILS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES,
MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES,
SYSTEM_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES,
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, COLLECTION_DETAILS_CONTEXT_CLUES,
COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES,
MOVIE_DETAILS_CONTEXT_CLUES, RadarrContextClueProvider,
};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test]
fn test_library_context_clues() {
let mut library_context_clues_iter = LIBRARY_CONTEXT_CLUES.iter();
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update);
assert_str_eq!(*description, "update all");
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "details");
let (key_binding, description) = library_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, "cancel filter");
assert_eq!(library_context_clues_iter.next(), None);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(library_context_clues_iter.next());
}
#[test]
fn test_collections_context_clues() {
let mut collections_context_clues = COLLECTIONS_CONTEXT_CLUES.iter();
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc);
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc);
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc);
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc);
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update);
assert_str_eq!(*description, "update all");
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "details");
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, "cancel filter");
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
collections_context_clues.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_eq!(collections_context_clues.next(), None);
}
#[test]
fn test_downloads_context_clues() {
let mut downloads_context_clues_iter = DOWNLOADS_CONTEXT_CLUES.iter();
let (key_binding, description) = downloads_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
let (key_binding, description) = downloads_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc);
assert_eq!(downloads_context_clues_iter.next(), None);
}
#[test]
fn test_blocklist_context_clues() {
let mut blocklist_context_clues_iter = BLOCKLIST_CONTEXT_CLUES.iter();
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc);
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "details");
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc);
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.clear);
assert_str_eq!(*description, "clear blocklist");
assert_eq!(blocklist_context_clues_iter.next(), None);
}
#[test]
fn test_root_folders_context_clues() {
let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter();
let (key_binding, description) = root_folders_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc);
let (key_binding, description) = root_folders_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc);
let (key_binding, description) = root_folders_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
assert_eq!(root_folders_context_clues_iter.next(), None);
}
#[test]
fn test_indexers_context_clues() {
let mut indexers_context_clues_iter = INDEXERS_CONTEXT_CLUES.iter();
let (key_binding, description) = indexers_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "edit indexer");
let (key_binding, description) = indexers_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.settings);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.settings.desc);
let (key_binding, description) = indexers_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc);
let (key_binding, description) = indexers_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test);
assert_str_eq!(*description, "test indexer");
let (key_binding, description) = indexers_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test_all);
assert_str_eq!(*description, "test all indexers");
let (key_binding, description) = indexers_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
assert_eq!(indexers_context_clues_iter.next(), None);
}
#[test]
fn test_system_context_clues() {
let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter();
let (key_binding, description) = system_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.tasks);
assert_str_eq!(*description, "open tasks");
let (key_binding, description) = system_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.events);
assert_str_eq!(*description, "open events");
let (key_binding, description) = system_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.logs);
assert_str_eq!(*description, "open logs");
let (key_binding, description) = system_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update);
assert_str_eq!(*description, "open updates");
let (key_binding, description) = system_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
assert_eq!(system_context_clues_iter.next(), None);
}
#[test]
fn test_movie_details_context_clues() {
let mut movie_details_context_clues_iter = MOVIE_DETAILS_CONTEXT_CLUES.iter();
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc);
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc);
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search);
assert_str_eq!(*description, "auto search");
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_some_eq_x!(
movie_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
movie_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
movie_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
movie_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
movie_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(movie_details_context_clues_iter.next(), None);
}
@@ -283,53 +154,41 @@ mod tests {
fn test_manual_movie_search_context_clues() {
let mut manual_movie_search_context_clues_iter = MANUAL_MOVIE_SEARCH_CONTEXT_CLUES.iter();
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search);
assert_str_eq!(*description, "auto search");
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_eq!(manual_movie_search_context_clues_iter.next(), None);
}
#[test]
fn test_manual_movie_search_contextual_context_clues() {
let mut manual_movie_search_contextual_context_clues_iter =
MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES.iter();
let (key_binding, description) = manual_movie_search_contextual_context_clues_iter
.next()
.unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "details");
assert_eq!(
manual_movie_search_contextual_context_clues_iter.next(),
None
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(manual_movie_search_context_clues_iter.next(), None);
}
#[test]
@@ -337,47 +196,29 @@ mod tests {
let mut add_movie_search_results_context_clues_iter =
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES.iter();
let (key_binding, description) = add_movie_search_results_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "details");
let (key_binding, description) = add_movie_search_results_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, "edit search");
assert_some_eq_x!(
add_movie_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
add_movie_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
);
assert_eq!(add_movie_search_results_context_clues_iter.next(), None);
}
#[test]
fn test_confirmation_prompt_context_clues() {
let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter();
let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm);
assert_str_eq!(*description, "submit");
let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, "cancel");
assert_eq!(confirmation_prompt_context_clues_iter.next(), None);
}
#[test]
fn test_system_tasks_context_clues() {
let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter();
let (key_binding, description) = system_tasks_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "start task");
let (key_binding, description) = system_tasks_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "start task")
);
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(system_tasks_context_clues_iter.next(), None);
}
@@ -385,15 +226,256 @@ mod tests {
fn test_collection_details_context_clues() {
let mut collection_details_context_clues_iter = COLLECTION_DETAILS_CONTEXT_CLUES.iter();
let (key_binding, description) = collection_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "show overview/add movie");
let (key_binding, description) = collection_details_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_some_eq_x!(
collection_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "show overview/add movie")
);
assert_some_eq_x!(
collection_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit collection")
);
assert_some_eq_x!(
collection_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(collection_details_context_clues_iter.next(), None);
}
#[test]
#[should_panic(
expected = "RadarrContextClueProvider::get_context_clues called with non-Radarr route"
)]
fn test_radarr_context_clue_provider_get_context_clues_non_radarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveSonarrBlock::default().into());
// This should panic because the route is not a Radarr route
RadarrContextClueProvider::get_context_clues(&mut app);
}
#[rstest]
#[case(ActiveRadarrBlock::TestAllIndexers, None)]
#[case(ActiveRadarrBlock::AddMovieSearchInput, None)]
#[case(ActiveRadarrBlock::AddMovieEmptySearchResults, None)]
#[case(ActiveRadarrBlock::SystemLogs, None)]
#[case(ActiveRadarrBlock::SystemUpdates, None)]
#[case(ActiveRadarrBlock::ViewMovieOverview, None)]
#[case(
ActiveRadarrBlock::CollectionDetails,
Some(ActiveRadarrBlock::ViewMovieOverview)
)]
fn test_radarr_context_clue_provider_bare_popup_context_clues(
#[case] active_radarr_block: ActiveRadarrBlock,
#[case] context_option: Option<ActiveRadarrBlock>,
) {
let mut app = App::test_default();
app.push_navigation_stack((active_radarr_block, context_option).into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES);
}
#[rstest]
#[case(0, ActiveRadarrBlock::MovieDetails, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveRadarrBlock::MovieHistory, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(2, ActiveRadarrBlock::FileInfo, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(3, ActiveRadarrBlock::Cast, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(4, ActiveRadarrBlock::Crew, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(5, ActiveRadarrBlock::ManualSearch, &MANUAL_MOVIE_SEARCH_CONTEXT_CLUES)]
fn test_radarr_context_clue_provider_movie_details_block_context_clues(
#[case] index: usize,
#[case] active_radarr_block: ActiveRadarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.data.radarr_data.movie_info_tabs.set_index(index);
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues(
#[values(
ActiveRadarrBlock::AddMoviePrompt,
ActiveRadarrBlock::AddMovieSelectMonitor,
ActiveRadarrBlock::AddMovieSelectMinimumAvailability,
ActiveRadarrBlock::AddMovieSelectQualityProfile,
ActiveRadarrBlock::AddMovieSelectRootFolder,
ActiveRadarrBlock::AddMovieTagsInput,
ActiveRadarrBlock::SystemTaskStartConfirmPrompt
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_edit_collection_blocks(
#[values(
ActiveRadarrBlock::EditCollectionPrompt,
ActiveRadarrBlock::EditCollectionConfirmPrompt,
ActiveRadarrBlock::EditCollectionRootFolderPathInput,
ActiveRadarrBlock::EditCollectionSelectMinimumAvailability,
ActiveRadarrBlock::EditCollectionSelectQualityProfile,
ActiveRadarrBlock::EditCollectionToggleSearchOnAdd,
ActiveRadarrBlock::EditCollectionToggleMonitored
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_edit_indexer_blocks(
#[values(
ActiveRadarrBlock::EditIndexerPrompt,
ActiveRadarrBlock::EditIndexerConfirmPrompt,
ActiveRadarrBlock::EditIndexerApiKeyInput,
ActiveRadarrBlock::EditIndexerNameInput,
ActiveRadarrBlock::EditIndexerSeedRatioInput,
ActiveRadarrBlock::EditIndexerToggleEnableRss,
ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch,
ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch,
ActiveRadarrBlock::EditIndexerPriorityInput,
ActiveRadarrBlock::EditIndexerUrlInput,
ActiveRadarrBlock::EditIndexerTagsInput
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_indexer_settings_blocks(
#[values(
ActiveRadarrBlock::AllIndexerSettingsPrompt,
ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput,
ActiveRadarrBlock::IndexerSettingsConfirmPrompt,
ActiveRadarrBlock::IndexerSettingsMaximumSizeInput,
ActiveRadarrBlock::IndexerSettingsMinimumAgeInput,
ActiveRadarrBlock::IndexerSettingsRetentionInput,
ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput,
ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs,
ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags,
ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_edit_movie_blocks(
#[values(
ActiveRadarrBlock::EditMoviePrompt,
ActiveRadarrBlock::EditMovieConfirmPrompt,
ActiveRadarrBlock::EditMoviePathInput,
ActiveRadarrBlock::EditMovieSelectMinimumAvailability,
ActiveRadarrBlock::EditMovieSelectQualityProfile,
ActiveRadarrBlock::EditMovieTagsInput,
ActiveRadarrBlock::EditMovieToggleMonitored
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_add_movie_search_results_context_clues(
#[values(
ActiveRadarrBlock::AddMovieSearchResults,
ActiveRadarrBlock::AddMovieAlreadyInLibrary
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES);
}
#[test]
fn test_radarr_context_clue_provider_collection_details_context_clues() {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &COLLECTION_DETAILS_CONTEXT_CLUES);
}
#[test]
fn test_radarr_context_clue_provider_system_tasks_context_clues() {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES);
}
#[rstest]
#[case(0, ActiveRadarrBlock::Movies, &LIBRARY_CONTEXT_CLUES)]
#[case(1, ActiveRadarrBlock::Collections, &COLLECTIONS_CONTEXT_CLUES)]
#[case(2, ActiveRadarrBlock::Downloads, &DOWNLOADS_CONTEXT_CLUES)]
#[case(3, ActiveRadarrBlock::Blocklist, &BLOCKLIST_CONTEXT_CLUES)]
#[case(4, ActiveRadarrBlock::History, &HISTORY_CONTEXT_CLUES)]
#[case(5, ActiveRadarrBlock::RootFolders, &ROOT_FOLDERS_CONTEXT_CLUES)]
#[case(6, ActiveRadarrBlock::Indexers, &INDEXERS_CONTEXT_CLUES)]
#[case(7, ActiveRadarrBlock::System, &SYSTEM_CONTEXT_CLUES)]
fn test_radarr_context_clue_provider_radarr_blocks_context_clues(
#[case] index: usize,
#[case] active_radarr_block: ActiveRadarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.data.radarr_data.main_tabs.set_index(index);
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
}
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
use crate::{
models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock,
network::sonarr_network::SonarrEvent,
};
use super::App;
pub mod sonarr_context_clues;
#[cfg(test)]
#[path = "sonarr_tests.rs"]
mod sonarr_tests;
impl App<'_> {
pub(super) async fn dispatch_by_sonarr_block(&mut self, active_sonarr_block: &ActiveSonarrBlock) {
match active_sonarr_block {
ActiveSonarrBlock::Series => {
self
.dispatch_network_event(SonarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetLanguageProfiles.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(SonarrEvent::ListSeries.into())
.await;
}
ActiveSonarrBlock::SeriesDetails => {
self
.dispatch_network_event(SonarrEvent::ListSeries.into())
.await;
self.is_loading = true;
self.populate_seasons_table().await;
self.is_loading = false;
}
ActiveSonarrBlock::SeriesHistory => {
self
.dispatch_network_event(
SonarrEvent::GetSeriesHistory(self.extract_series_id().await).into(),
)
.await;
}
ActiveSonarrBlock::SeasonDetails => {
self
.dispatch_network_event(SonarrEvent::GetEpisodes(self.extract_series_id().await).into())
.await;
self
.dispatch_network_event(
SonarrEvent::GetEpisodeFiles(self.extract_series_id().await).into(),
)
.await;
self
.dispatch_network_event(SonarrEvent::GetDownloads(500).into())
.await;
}
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(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(series_id, season_number).into(),
)
.await;
}
_ => (),
}
}
ActiveSonarrBlock::EpisodeDetails | ActiveSonarrBlock::EpisodeFile => {
self
.dispatch_network_event(
SonarrEvent::GetEpisodeDetails(self.extract_episode_id().await).into(),
)
.await;
}
ActiveSonarrBlock::EpisodeHistory => {
self
.dispatch_network_event(
SonarrEvent::GetEpisodeHistory(self.extract_episode_id().await).into(),
)
.await;
}
ActiveSonarrBlock::ManualEpisodeSearch => {
if let Some(season_details_modal) = self.data.sonarr_data.season_details_modal.as_ref()
&& let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref()
&& episode_details_modal.episode_releases.is_empty()
{
self
.dispatch_network_event(
SonarrEvent::GetEpisodeReleases(self.extract_episode_id().await).into(),
)
.await;
}
}
ActiveSonarrBlock::Downloads => {
self
.dispatch_network_event(SonarrEvent::GetDownloads(500).into())
.await;
}
ActiveSonarrBlock::Blocklist => {
self
.dispatch_network_event(SonarrEvent::ListSeries.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetBlocklist.into())
.await;
}
ActiveSonarrBlock::History => {
self
.dispatch_network_event(SonarrEvent::GetHistory(500).into())
.await;
}
ActiveSonarrBlock::RootFolders => {
self
.dispatch_network_event(SonarrEvent::GetRootFolders.into())
.await;
}
ActiveSonarrBlock::Indexers => {
self
.dispatch_network_event(SonarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetIndexers.into())
.await;
}
ActiveSonarrBlock::AllIndexerSettingsPrompt => {
self
.dispatch_network_event(SonarrEvent::GetAllIndexerSettings.into())
.await;
}
ActiveSonarrBlock::TestIndexer => {
self
.dispatch_network_event(
SonarrEvent::TestIndexer(self.extract_sonarr_indexer_id().await).into(),
)
.await;
}
ActiveSonarrBlock::TestAllIndexers => {
self
.dispatch_network_event(SonarrEvent::TestAllIndexers.into())
.await;
}
ActiveSonarrBlock::System => {
self
.dispatch_network_event(SonarrEvent::GetTasks.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetQueuedEvents.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetLogs(500).into())
.await;
}
ActiveSonarrBlock::AddSeriesSearchResults => {
self
.dispatch_network_event(
SonarrEvent::SearchNewSeries(self.extract_add_new_series_search_query().await).into(),
)
.await;
}
ActiveSonarrBlock::SystemUpdates => {
self
.dispatch_network_event(SonarrEvent::GetUpdates.into())
.await;
}
_ => (),
}
self.check_for_sonarr_prompt_action().await;
self.reset_tick_count();
}
async fn check_for_sonarr_prompt_action(&mut self) {
if self.data.sonarr_data.prompt_confirm {
self.data.sonarr_data.prompt_confirm = false;
if let Some(sonarr_event) = self.data.sonarr_data.prompt_confirm_action.take() {
self.dispatch_network_event(sonarr_event.into()).await;
self.should_refresh = true;
}
}
}
pub(super) async fn sonarr_on_tick(&mut self, active_sonarr_block: ActiveSonarrBlock) {
if self.is_first_render {
self.refresh_sonarr_metadata().await;
self.dispatch_by_sonarr_block(&active_sonarr_block).await;
self.is_first_render = false;
return;
}
if self.should_refresh {
self.dispatch_by_sonarr_block(&active_sonarr_block).await;
self.refresh_sonarr_metadata().await;
}
if self.is_routing {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_sonarr_block(&active_sonarr_block).await;
}
}
if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_sonarr_metadata().await;
}
}
async fn refresh_sonarr_metadata(&mut self) {
self
.dispatch_network_event(SonarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetLanguageProfiles.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetDownloads(500).into())
.await;
self
.dispatch_network_event(SonarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(SonarrEvent::GetStatus.into())
.await;
}
async fn populate_seasons_table(&mut self) {
let seasons = self
.data
.sonarr_data
.series
.current_selection()
.clone()
.seasons
.unwrap_or_default()
.into_iter()
.map(|mut season| {
season.title = Some(format!("Season {}", season.season_number));
season
})
.collect();
self.data.sonarr_data.seasons.set_items(seasons);
}
async fn extract_episode_id(&self) -> i64 {
self
.data
.sonarr_data
.season_details_modal
.as_ref()
.expect("Season details have not been loaded")
.episodes
.current_selection()
.id
}
async fn extract_series_id(&self) -> i64 {
self.data.sonarr_data.series.current_selection().id
}
async fn extract_series_id_season_number_tuple(&self) -> (i64, i64) {
let series_id = self.data.sonarr_data.series.current_selection().id;
let season_number = self
.data
.sonarr_data
.seasons
.current_selection()
.season_number;
(series_id, season_number)
}
async fn extract_add_new_series_search_query(&self) -> String {
self
.data
.sonarr_data
.add_series_search
.as_ref()
.expect("Add series search is empty")
.text
.clone()
}
async fn extract_sonarr_indexer_id(&self) -> i64 {
self.data.sonarr_data.indexers.current_selection().id
}
}
+229
View File
@@ -0,0 +1,229 @@
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::{App, context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS};
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{
ADD_SERIES_BLOCKS, ActiveSonarrBlock, EDIT_INDEXER_BLOCKS, EDIT_SERIES_BLOCKS,
EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS,
};
#[cfg(test)]
#[path = "sonarr_context_clues_tests.rs"]
mod sonarr_context_clues_tests;
pub static ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"),
];
pub static SERIES_CONTEXT_CLUES: [ContextClue; 11] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.update, "update all"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.submit, "season details"),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
(DEFAULT_KEYBINDINGS.submit, "episode details"),
(DEFAULT_KEYBINDINGS.delete, "delete episode"),
];
pub static SEASON_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static MANUAL_EPISODE_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 4] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub(in crate::app) struct SonarrContextClueProvider;
impl ContextClueProvider for SonarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() else {
panic!("SonarrContextClueProvider::get_context_clues called with non-Sonarr route");
};
match active_sonarr_block {
_ if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) => app
.data
.sonarr_data
.series_info_tabs
.get_active_route_contextual_help(),
_ if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) => app
.data
.sonarr_data
.season_details_modal
.as_ref()
.unwrap()
.season_details_tabs
.get_active_route_contextual_help(),
_ if EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block) => app
.data
.sonarr_data
.season_details_modal
.as_ref()
.unwrap()
.episode_details_modal
.as_ref()
.unwrap()
.episode_details_tabs
.get_active_route_contextual_help(),
ActiveSonarrBlock::TestAllIndexers
| ActiveSonarrBlock::AddSeriesSearchInput
| ActiveSonarrBlock::AddSeriesEmptySearchResults
| ActiveSonarrBlock::SystemLogs
| ActiveSonarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block)
|| EDIT_SERIES_BLOCKS.contains(&active_sonarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveSonarrBlock::AddSeriesPrompt
| ActiveSonarrBlock::AddSeriesSelectMonitor
| ActiveSonarrBlock::AddSeriesSelectSeriesType
| ActiveSonarrBlock::AddSeriesSelectQualityProfile
| ActiveSonarrBlock::AddSeriesSelectLanguageProfile
| ActiveSonarrBlock::AddSeriesSelectRootFolder
| ActiveSonarrBlock::AddSeriesTagsInput
| ActiveSonarrBlock::SystemTaskStartConfirmPrompt => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) => {
Some(&ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveSonarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ => app
.data
.sonarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
@@ -0,0 +1,654 @@
#[cfg(test)]
mod tests {
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::sonarr::sonarr_context_clues::{
SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES, SonarrContextClueProvider,
};
use crate::app::{
App,
key_binding::DEFAULT_KEYBINDINGS,
sonarr::sonarr_context_clues::{
ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES,
MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES,
SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES,
SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES,
},
};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use rstest::rstest;
#[test]
fn test_add_series_search_results_context_clues() {
let mut add_series_search_results_context_clues_iter =
ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
add_series_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
add_series_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
);
assert_none!(add_series_search_results_context_clues_iter.next());
}
#[test]
fn test_series_context_clues() {
let mut series_context_clues_iter = SERIES_CONTEXT_CLUES.iter();
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
series_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(series_context_clues_iter.next());
}
#[test]
fn test_series_history_context_clues() {
let mut series_history_context_clues_iter = SERIES_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
series_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(series_history_context_clues_iter.next());
}
#[test]
fn test_series_details_context_clues() {
let mut series_details_context_clues_iter = SERIES_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "season details")
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
series_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(series_details_context_clues_iter.next());
}
#[test]
fn test_season_details_context_clues() {
let mut season_details_context_clues_iter = SEASON_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "episode details")
);
assert_some_eq_x!(
season_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete episode")
);
assert_none!(season_details_context_clues_iter.next());
}
#[test]
fn test_season_history_context_clues() {
let mut season_history_context_clues_iter = SEASON_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
season_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(season_history_context_clues_iter.next());
}
#[test]
fn test_manual_season_search_context_clues() {
let mut manual_season_search_context_clues_iter = MANUAL_SEASON_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_season_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_season_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_season_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_season_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_season_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_season_search_context_clues_iter.next());
}
#[test]
fn test_manual_episode_search_context_clues() {
let mut manual_episode_search_context_clues_iter = MANUAL_EPISODE_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_episode_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_episode_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_episode_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_episode_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_episode_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_episode_search_context_clues_iter.next());
}
#[test]
fn test_episode_details_context_clues() {
let mut episode_details_context_clues_iter = EPISODE_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(episode_details_context_clues_iter.next());
}
#[test]
fn test_selectable_episode_details_context_clues() {
let mut episode_details_context_clues_iter = SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
episode_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(episode_details_context_clues_iter.next());
}
#[test]
fn test_system_tasks_context_clues() {
let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "start task")
);
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(system_tasks_context_clues_iter.next());
}
#[test]
#[should_panic(
expected = "SonarrContextClueProvider::get_context_clues called with non-Sonarr route"
)]
fn test_sonarr_context_clue_provider_get_context_clues_non_sonarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
SonarrContextClueProvider::get_context_clues(&mut app);
}
#[rstest]
#[case(0, ActiveSonarrBlock::SeriesDetails, &SERIES_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveSonarrBlock::SeriesHistory, &SERIES_HISTORY_CONTEXT_CLUES)]
fn test_sonarr_context_clue_provider_series_info_tabs(
#[case] index: usize,
#[case] active_sonarr_block: ActiveSonarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.sonarr_data = SonarrData::default();
app.data.sonarr_data.series_info_tabs.set_index(index);
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveSonarrBlock::SeasonDetails, &SEASON_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveSonarrBlock::SeasonHistory, &SEASON_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveSonarrBlock::ManualSeasonSearch, &MANUAL_SEASON_SEARCH_CONTEXT_CLUES)]
fn test_sonarr_context_clue_provider_season_details_tabs(
#[case] index: usize,
#[case] active_sonarr_block: ActiveSonarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut season_details_modal = SeasonDetailsModal::default();
season_details_modal.season_details_tabs.set_index(index);
let sonarr_data = SonarrData {
season_details_modal: Some(season_details_modal),
..SonarrData::default()
};
app.data.sonarr_data = sonarr_data;
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveSonarrBlock::EpisodeDetails, &EPISODE_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveSonarrBlock::EpisodeHistory, &SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES)]
#[case(2, ActiveSonarrBlock::EpisodeFile, &EPISODE_DETAILS_CONTEXT_CLUES)]
#[case(3, ActiveSonarrBlock::ManualEpisodeSearch, &MANUAL_EPISODE_SEARCH_CONTEXT_CLUES)]
fn test_sonarr_context_clue_provider_episode_details_tabs(
#[case] index: usize,
#[case] active_sonarr_block: ActiveSonarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut episode_details_modal = EpisodeDetailsModal::default();
episode_details_modal.episode_details_tabs.set_index(index);
let sonarr_data = SonarrData {
season_details_modal: Some(SeasonDetailsModal {
episode_details_modal: Some(episode_details_modal),
..SeasonDetailsModal::default()
}),
..SonarrData::default()
};
app.data.sonarr_data = sonarr_data;
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
fn test_sonarr_context_clue_provider_bare_popup_context_clues(
#[values(
ActiveSonarrBlock::TestAllIndexers,
ActiveSonarrBlock::AddSeriesSearchInput,
ActiveSonarrBlock::AddSeriesEmptySearchResults,
ActiveSonarrBlock::SystemLogs,
ActiveSonarrBlock::SystemUpdates
)]
active_sonarr_block: ActiveSonarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES);
}
#[rstest]
fn test_sonarr_context_clue_provider_confirmation_prompt_context_clues(
#[values(
ActiveSonarrBlock::AddSeriesPrompt,
ActiveSonarrBlock::AddSeriesSelectMonitor,
ActiveSonarrBlock::AddSeriesSelectSeriesType,
ActiveSonarrBlock::AddSeriesSelectQualityProfile,
ActiveSonarrBlock::AddSeriesSelectLanguageProfile,
ActiveSonarrBlock::AddSeriesSelectRootFolder,
ActiveSonarrBlock::AddSeriesTagsInput,
ActiveSonarrBlock::SystemTaskStartConfirmPrompt
)]
active_sonarr_block: ActiveSonarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_sonarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks(
#[values(
ActiveSonarrBlock::EditIndexerPrompt,
ActiveSonarrBlock::EditIndexerConfirmPrompt,
ActiveSonarrBlock::EditIndexerApiKeyInput,
ActiveSonarrBlock::EditIndexerNameInput,
ActiveSonarrBlock::EditIndexerSeedRatioInput,
ActiveSonarrBlock::EditIndexerToggleEnableRss,
ActiveSonarrBlock::EditIndexerToggleEnableAutomaticSearch,
ActiveSonarrBlock::EditIndexerToggleEnableInteractiveSearch,
ActiveSonarrBlock::EditIndexerPriorityInput,
ActiveSonarrBlock::EditIndexerUrlInput,
ActiveSonarrBlock::EditIndexerTagsInput
)]
active_sonarr_block: ActiveSonarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_sonarr_context_clue_provider_confirmation_prompt_popup_clues_indexer_settings_blocks(
#[values(
ActiveSonarrBlock::AllIndexerSettingsPrompt,
ActiveSonarrBlock::IndexerSettingsConfirmPrompt,
ActiveSonarrBlock::IndexerSettingsMaximumSizeInput,
ActiveSonarrBlock::IndexerSettingsMinimumAgeInput,
ActiveSonarrBlock::IndexerSettingsRetentionInput,
ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput
)]
active_sonarr_block: ActiveSonarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_sonarr_context_clue_provider_confirmation_prompt_popup_clues_edit_series_blocks(
#[values(
ActiveSonarrBlock::EditSeriesPrompt,
ActiveSonarrBlock::EditSeriesConfirmPrompt,
ActiveSonarrBlock::EditSeriesPathInput,
ActiveSonarrBlock::EditSeriesSelectSeriesType,
ActiveSonarrBlock::EditSeriesSelectQualityProfile,
ActiveSonarrBlock::EditSeriesSelectLanguageProfile,
ActiveSonarrBlock::EditSeriesTagsInput,
ActiveSonarrBlock::EditSeriesToggleMonitored,
ActiveSonarrBlock::EditSeriesToggleSeasonFolder
)]
active_sonarr_block: ActiveSonarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_sonarr_context_clue_provider_add_series_search_results_clues(
#[values(
ActiveSonarrBlock::AddSeriesAlreadyInLibrary,
ActiveSonarrBlock::AddSeriesSearchResults
)]
active_sonarr_block: ActiveSonarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES);
}
#[test]
fn test_sonarr_context_clue_provider_system_tasks_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES);
}
#[rstest]
#[case(0, ActiveSonarrBlock::Series, &SERIES_CONTEXT_CLUES)]
#[case(1, ActiveSonarrBlock::Downloads, &DOWNLOADS_CONTEXT_CLUES)]
#[case(2, ActiveSonarrBlock::Blocklist, &BLOCKLIST_CONTEXT_CLUES)]
#[case(3, ActiveSonarrBlock::History, &HISTORY_CONTEXT_CLUES)]
#[case(4, ActiveSonarrBlock::RootFolders, &ROOT_FOLDERS_CONTEXT_CLUES)]
#[case(5, ActiveSonarrBlock::Indexers, &INDEXERS_CONTEXT_CLUES)]
#[case(6, ActiveSonarrBlock::System, &SYSTEM_CONTEXT_CLUES)]
fn test_sonarr_context_clue_provider_sonarr_tabs(
#[case] index: usize,
#[case] active_sonarr_block: ActiveSonarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.sonarr_data = SonarrData::default();
app.data.sonarr_data.main_tabs.set_index(index);
app.push_navigation_stack(active_sonarr_block.into());
let context_clues = SonarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
}
+881
View File
@@ -0,0 +1,881 @@
#[cfg(test)]
mod tests {
mod sonarr_tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc;
use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data;
use crate::models::servarr_models::Indexer;
use crate::models::sonarr_models::Episode;
use crate::{
app::App,
models::{
servarr_data::sonarr::{
modals::{EpisodeDetailsModal, SeasonDetailsModal},
sonarr_data::ActiveSonarrBlock,
},
sonarr_models::{Season, Series, SonarrRelease},
},
network::{NetworkEvent, sonarr_network::SonarrEvent},
};
#[tokio::test]
async fn test_dispatch_by_blocklist_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::Blocklist)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::ListSeries.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetBlocklist.into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_series_history_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::SeriesHistory)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeriesHistory(1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_series_details_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.series.set_items(vec![Series {
seasons: Some(vec![Season::default()]),
..Series::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::SeriesDetails)
.await;
assert!(!app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::ListSeries.into()
);
assert!(!app.data.sonarr_data.seasons.items.is_empty());
assert_eq!(app.tick_count, 0);
assert!(!app.data.sonarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_dispatch_by_season_details_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::SeasonDetails)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetEpisodes(1).into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetEpisodeFiles(1).into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_season_history_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
app.data.sonarr_data.seasons.set_items(vec![Season {
season_number: 1,
..Season::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::SeasonHistory)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeasonHistory(1, 1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_season_history_block_no_op_when_seasons_table_is_empty() {
let (mut app, _) = construct_app_unit();
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::SeasonHistory)
.await;
assert!(!app.is_loading);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_season_search_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default());
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
app.data.sonarr_data.seasons.set_items(vec![Season {
season_number: 1,
..Season::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeasonReleases(1, 1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_season_search_block_is_loading() {
let mut app = App {
is_loading: true,
..App::test_default()
};
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch)
.await;
assert!(app.is_loading);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_season_search_block_season_releases_non_empty() {
let mut app = App::test_default();
let mut season_details_modal = SeasonDetailsModal::default();
season_details_modal
.season_releases
.set_items(vec![SonarrRelease::default()]);
app.data.sonarr_data.season_details_modal = Some(season_details_modal);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualSeasonSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_episode_details_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data = create_test_sonarr_data();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::EpisodeDetails)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetEpisodeDetails(0).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_episode_file_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data = create_test_sonarr_data();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::EpisodeFile)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetEpisodeDetails(0).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_episode_history_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
let mut season_details_modal = SeasonDetailsModal::default();
season_details_modal.episodes.set_items(vec![Episode {
id: 1,
..Episode::default()
}]);
app.data.sonarr_data.season_details_modal = Some(season_details_modal);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::EpisodeHistory)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetEpisodeHistory(1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_episode_search_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
let mut season_details_modal = SeasonDetailsModal {
episode_details_modal: Some(EpisodeDetailsModal::default()),
..SeasonDetailsModal::default()
};
season_details_modal.episodes.set_items(vec![Episode {
id: 1,
..Episode::default()
}]);
app.data.sonarr_data.season_details_modal = Some(season_details_modal);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetEpisodeReleases(1).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_episode_search_block_is_loading() {
let mut app = App {
is_loading: true,
..App::test_default()
};
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch)
.await;
assert!(app.is_loading);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_episode_search_block_episode_releases_non_empty() {
let mut app = App::test_default();
let mut episode_details_modal = EpisodeDetailsModal::default();
episode_details_modal
.episode_releases
.set_items(vec![SonarrRelease::default()]);
let season_details_modal = SeasonDetailsModal {
episode_details_modal: Some(episode_details_modal),
..SeasonDetailsModal::default()
};
app.data.sonarr_data.season_details_modal = Some(season_details_modal);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::ManualEpisodeSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_history_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::History)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetHistory(500).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_downloads_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::Downloads)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_root_folders_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::RootFolders)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetRootFolders.into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_series_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::Series)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetLanguageProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::ListSeries.into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_indexers_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::Indexers)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetIndexers.into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_all_indexer_settings_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::AllIndexerSettingsPrompt)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetAllIndexerSettings.into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_indexer_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.indexers.set_items(vec![Indexer {
id: 1,
..Indexer::default()
}]);
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::TestIndexer)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::TestIndexer(1).into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_all_indexers_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::TestAllIndexers)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::TestAllIndexers.into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::System)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetTasks.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetQueuedEvents.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetLogs(500).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_updates_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::SystemUpdates)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetUpdates.into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_add_series_search_results_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.add_series_search = Some("test search".into());
app
.dispatch_by_sonarr_block(&ActiveSonarrBlock::AddSeriesSearchResults)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::SearchNewSeries("test search".into()).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_check_for_sonarr_prompt_action_no_prompt_confirm() {
let mut app = App::test_default();
app.data.sonarr_data.prompt_confirm = false;
app.check_for_sonarr_prompt_action().await;
assert!(!app.data.sonarr_data.prompt_confirm);
assert!(!app.should_refresh);
}
#[tokio::test]
async fn test_check_for_sonarr_prompt_action() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.data.sonarr_data.prompt_confirm_action = Some(SonarrEvent::GetStatus);
app.check_for_sonarr_prompt_action().await;
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetStatus.into()
);
assert!(app.should_refresh);
assert_eq!(app.data.sonarr_data.prompt_confirm_action, None);
}
#[tokio::test]
async fn test_sonarr_refresh_metadata() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_routing = true;
app.refresh_sonarr_metadata().await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetLanguageProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDiskSpace.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetStatus.into()
);
assert!(app.is_loading);
}
#[tokio::test]
async fn test_sonarr_on_tick_first_render() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_first_render = true;
app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetLanguageProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDiskSpace.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetStatus.into()
);
assert!(app.is_loading);
assert!(!app.data.sonarr_data.prompt_confirm);
assert!(!app.is_first_render);
}
#[tokio::test]
async fn test_sonarr_on_tick_routing() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_routing = true;
app.should_refresh = true;
app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert!(!app.data.sonarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_sonarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (mut app, _) = construct_app_unit();
app.is_routing = true;
app.should_refresh = false;
app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await;
assert!(app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_sonarr_on_tick_should_refresh() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.should_refresh = true;
app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert!(app.should_refresh);
assert!(!app.data.sonarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_sonarr_on_tick_should_refresh_does_not_cancel_prompt_requests() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_loading = true;
app.is_routing = true;
app.should_refresh = true;
app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
assert!(app.should_refresh);
assert!(!app.data.sonarr_data.prompt_confirm);
assert!(!app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_sonarr_on_tick_network_tick_frequency() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.tick_count = 2;
app.tick_until_poll = 2;
app.sonarr_on_tick(ActiveSonarrBlock::Downloads).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetLanguageProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
}
#[tokio::test]
async fn test_populate_seasons_table_unfiltered() {
let mut app = App::test_default();
app.data.sonarr_data.series.set_items(vec![Series {
seasons: Some(vec![Season::default()]),
..Series::default()
}]);
app.populate_seasons_table().await;
assert!(!app.data.sonarr_data.seasons.items.is_empty());
assert_str_eq!(
app.data.sonarr_data.seasons.items[0]
.title
.as_ref()
.unwrap(),
"Season 0"
);
}
#[tokio::test]
async fn test_populate_seasons_table_filtered() {
let mut app = App::test_default();
app.data.sonarr_data.series.set_filtered_items(vec![Series {
seasons: Some(vec![Season::default()]),
..Series::default()
}]);
app.populate_seasons_table().await;
assert!(!app.data.sonarr_data.seasons.items.is_empty());
assert_str_eq!(
app.data.sonarr_data.seasons.items[0]
.title
.as_ref()
.unwrap(),
"Season 0"
);
}
#[tokio::test]
async fn test_extract_episode_id() {
let mut app = App::test_default();
let mut season_details_modal = SeasonDetailsModal::default();
season_details_modal.episodes.set_items(vec![Episode {
id: 1,
..Episode::default()
}]);
app.data.sonarr_data.season_details_modal = Some(season_details_modal);
assert_eq!(app.extract_episode_id().await, 1);
}
#[tokio::test]
#[should_panic(expected = "Season details have not been loaded")]
async fn test_extract_episode_id_requires_season_details_modal_to_be_some() {
let app = App::test_default();
assert_eq!(app.extract_episode_id().await, 0);
}
#[tokio::test]
async fn test_extract_series_id() {
let mut app = App::test_default();
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
assert_eq!(app.extract_series_id().await, 1);
}
#[tokio::test]
async fn test_extract_series_id_season_number_tuple() {
let mut app = App::test_default();
app.data.sonarr_data.series.set_items(vec![Series {
id: 1,
..Series::default()
}]);
app.data.sonarr_data.seasons.set_items(vec![Season {
season_number: 1,
..Season::default()
}]);
assert_eq!(app.extract_series_id_season_number_tuple().await, (1, 1));
}
#[tokio::test]
async fn test_extract_add_new_series_search_query() {
let mut app = App::test_default();
app.data.sonarr_data.add_series_search = Some("test search".into());
assert_str_eq!(
app.extract_add_new_series_search_query().await,
"test search"
);
}
#[tokio::test]
#[should_panic(expected = "Add series search is empty")]
async fn test_extract_add_new_series_search_query_panics_when_the_query_is_not_set() {
let app = App::test_default();
app.extract_add_new_series_search_query().await;
}
#[tokio::test]
async fn test_extract_sonarr_indexer_id() {
let mut app = App::test_default();
app.data.sonarr_data.indexers.set_items(vec![Indexer {
id: 1,
..Indexer::default()
}]);
assert_eq!(app.extract_sonarr_indexer_id().await, 1);
}
fn construct_app_unit<'a>() -> (App<'a>, mpsc::Receiver<NetworkEvent>) {
let (sync_network_tx, sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App {
network_tx: Some(sync_network_tx),
tick_count: 1,
is_first_render: false,
..App::test_default()
};
app.data.sonarr_data.prompt_confirm = true;
(app, sync_network_rx)
}
}
}
+113 -22
View File
@@ -2,29 +2,44 @@
mod tests {
use std::sync::Arc;
use clap::{error::ErrorKind, CommandFactory};
use clap::{CommandFactory, error::ErrorKind};
use mockall::predicate::eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::LidarrCommand;
use crate::network::lidarr_network::LidarrEvent;
use crate::{
app::App,
cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand},
models::{
radarr_models::{BlocklistItem, BlocklistResponse, RadarrSerdeable},
Serdeable,
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
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,
},
sonarr_models::{
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable,
},
},
network::{
MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent, sonarr_network::SonarrEvent,
},
};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr"]);
#[rstest]
fn test_servarr_subcommand_requires_subcommand(#[values("radarr", "sonarr")] subcommand: &str) {
let result = Cli::command().try_get_matches_from(["managarr", subcommand]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
@@ -36,14 +51,28 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "all-indexer-settings"]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
fn test_sonarr_subcommand_delegates_to_sonarr() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]);
assert_ok!(&result);
}
#[test]
fn test_lidarr_subcommand_delegates_to_lidarr() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
@@ -54,7 +83,7 @@ mod tests {
fn test_completions_invalid_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions", "test"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -62,7 +91,7 @@ mod tests {
fn test_completions_satisfied_with_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions", "bash"]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[rstest]
@@ -106,8 +135,8 @@ mod tests {
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::BlocklistResponse(
BlocklistResponse {
records: vec![BlocklistItem::default()],
RadarrBlocklistResponse {
records: vec![RadarrBlocklistItem::default()],
},
)))
});
@@ -120,11 +149,73 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let claer_blocklist_command = RadarrCommand::ClearBlocklist.into();
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = RadarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, claer_blocklist_command, &mut mock_network).await;
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
async fn test_cli_handler_delegates_sonarr_commands_to_the_sonarr_cli_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(SonarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse(
SonarrBlocklistResponse {
records: vec![SonarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(SonarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = SonarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert_ok!(&result);
}
#[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);
}
}
+223
View File
@@ -0,0 +1,223 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, Subcommand, arg};
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::lidarr_models::{
AddArtistBody, AddArtistOptions, AddLidarrRootFolderBody, MonitorType, NewItemMonitorType,
},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "add_command_handler_tests.rs"]
mod add_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrAddCommand {
#[command(about = "Add a new artist to your Lidarr library")]
Artist {
#[arg(
long,
help = "The MusicBrainz foreign artist ID of the artist you wish to add to your library",
required = true
)]
foreign_artist_id: String,
#[arg(long, help = "The name of the artist", required = true)]
artist_name: String,
#[arg(
long,
help = "The root folder path where all artist data and metadata should live",
required = true
)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the quality profile to use for this artist",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the metadata profile to use for this artist",
required = true
)]
metadata_profile_id: i64,
#[arg(long, help = "Disable monitoring for this artist")]
disable_monitoring: bool,
#[arg(
long,
help = "Tag IDs to tag the artist with",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
#[arg(
long,
help = "What Lidarr should monitor for this artist",
value_enum,
default_value_t = MonitorType::default()
)]
monitor: MonitorType,
#[arg(
long,
help = "How Lidarr should monitor new items for this artist",
value_enum,
default_value_t = NewItemMonitorType::default()
)]
monitor_new_items: NewItemMonitorType,
#[arg(
long,
help = "Tell Lidarr to not start a search for missing albums once the artist is added to your library"
)]
no_search_for_missing_albums: bool,
},
#[command(about = "Add a new root folder")]
RootFolder {
#[arg(long, help = "The name of the root folder", required = true)]
name: String,
#[arg(long, help = "The path of the new root folder", required = true)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the default quality profile for artists in this root folder",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the default metadata profile for artists in this root folder",
required = true
)]
metadata_profile_id: i64,
#[arg(
long,
help = "The default monitor option for artists in this root folder",
value_enum,
default_value_t = MonitorType::default()
)]
monitor: MonitorType,
#[arg(
long,
help = "The default monitor new items option for artists in this root folder",
value_enum,
default_value_t = NewItemMonitorType::default()
)]
monitor_new_items: NewItemMonitorType,
#[arg(
long,
help = "Default tag IDs for artists in this root folder",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
},
#[command(about = "Add new tag")]
Tag {
#[arg(long, help = "The name of the tag to be added", required = true)]
name: String,
},
}
impl From<LidarrAddCommand> for Command {
fn from(value: LidarrAddCommand) -> Self {
Command::Lidarr(LidarrCommand::Add(value))
}
}
pub(super) struct LidarrAddCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrAddCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrAddCommand> for LidarrAddCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrAddCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrAddCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrAddCommand::Artist {
foreign_artist_id,
artist_name,
root_folder_path,
quality_profile_id,
metadata_profile_id,
disable_monitoring,
tag: tags,
monitor,
monitor_new_items,
no_search_for_missing_albums,
} => {
let body = AddArtistBody {
foreign_artist_id,
artist_name,
monitored: !disable_monitoring,
root_folder_path,
quality_profile_id,
metadata_profile_id,
tags,
tag_input_string: None,
add_options: AddArtistOptions {
monitor,
monitor_new_items,
search_for_missing_albums: !no_search_for_missing_albums,
},
};
let resp = self
.network
.handle_network_event(LidarrEvent::AddArtist(body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrAddCommand::RootFolder {
name,
root_folder_path,
quality_profile_id,
metadata_profile_id,
monitor,
monitor_new_items,
tag: tags,
} => {
let add_root_folder_body = AddLidarrRootFolderBody {
name,
path: root_folder_path,
default_quality_profile_id: quality_profile_id,
default_metadata_profile_id: metadata_profile_id,
default_monitor_option: monitor,
default_new_item_monitor_option: monitor_new_items,
default_tags: tags,
tag_input_string: None,
};
let resp = self
.network
.handle_network_event(LidarrEvent::AddRootFolder(add_root_folder_body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrAddCommand::Tag { name } => {
let resp = self
.network
.handle_network_event(LidarrEvent::AddTag(name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+561
View File
@@ -0,0 +1,561 @@
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser, error::ErrorKind};
use crate::{
Cli,
cli::{
Command,
lidarr::{LidarrCommand, add_command_handler::LidarrAddCommand},
},
models::lidarr_models::{MonitorType, NewItemMonitorType},
};
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_add_command_from() {
let command = LidarrAddCommand::Tag {
name: String::new(),
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Add(command)));
}
mod cli {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_add_root_folder_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "add", "root-folder"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_root_folder_success() {
let expected_args = LidarrAddCommand::RootFolder {
name: "Music".to_owned(),
root_folder_path: "/nfs/test".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
tag: vec![],
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"root-folder",
"--name",
"Music",
"--root-folder-path",
"/nfs/test",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add", "tag"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_tag_success() {
let expected_args = LidarrAddCommand::Tag {
name: "test".to_owned(),
};
let result = Cli::try_parse_from(["managarr", "lidarr", "add", "tag", "--name", "test"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_foreign_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--artist-name",
"Test",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_artist_name() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_root_folder_path() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_quality_profile_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test",
"--root-folder-path",
"/music",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_metadata_profile_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_success_with_required_args_only() {
let expected_args = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
disable_monitoring: false,
tag: vec![],
monitor: MonitorType::default(),
monitor_new_items: NewItemMonitorType::default(),
no_search_for_missing_albums: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_artist_success_with_all_args() {
let expected_args = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 2,
disable_monitoring: true,
tag: vec![1, 2],
monitor: MonitorType::Future,
monitor_new_items: NewItemMonitorType::New,
no_search_for_missing_albums: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--disable-monitoring",
"--tag",
"1",
"--tag",
"2",
"--monitor",
"future",
"--monitor-new-items",
"new",
"--no-search-for-missing-albums",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_artist_monitor_type_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--monitor",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_artist_new_item_monitor_type_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--monitor-new-items",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_artist_tags_is_repeatable() {
let expected_args = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 2,
disable_monitoring: false,
tag: vec![1, 2],
monitor: MonitorType::default(),
monitor_new_items: NewItemMonitorType::default(),
no_search_for_missing_albums: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::{
AddArtistBody, AddArtistOptions, AddLidarrRootFolderBody, LidarrSerdeable, MonitorType,
NewItemMonitorType,
};
use crate::network::lidarr_network::LidarrEvent;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_add_root_folder_command() {
let expected_root_folder_path = "/nfs/test".to_owned();
let expected_add_root_folder_body = AddLidarrRootFolderBody {
name: "Music".to_owned(),
path: expected_root_folder_path.clone(),
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_monitor_option: MonitorType::All,
default_new_item_monitor_option: NewItemMonitorType::All,
default_tags: vec![1, 2],
tag_input_string: None,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddRootFolder(expected_add_root_folder_body.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_root_folder_command = LidarrAddCommand::RootFolder {
name: "Music".to_owned(),
root_folder_path: expected_root_folder_path,
quality_profile_id: 1,
metadata_profile_id: 1,
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
tag: vec![1, 2],
};
let result =
LidarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_add_tag_command() {
let expected_tag_name = "test".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = LidarrAddCommand::Tag {
name: expected_tag_name,
};
let result = LidarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_add_artist_command() {
let expected_body = AddArtistBody {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
monitored: false,
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
tags: vec![1, 2],
tag_input_string: None,
add_options: AddArtistOptions {
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
search_for_missing_albums: false,
},
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddArtist(expected_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_artist_command = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
disable_monitoring: true,
tag: vec![1, 2],
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
no_search_for_missing_albums: true,
};
let result = LidarrAddCommandHandler::with(&app_arc, add_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+181
View File
@@ -0,0 +1,181 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::lidarr_models::DeleteParams,
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "delete_command_handler_tests.rs"]
mod delete_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrDeleteCommand {
#[command(about = "Delete an album from your Lidarr library")]
Album {
#[arg(long, help = "The ID of the album to delete", required = true)]
album_id: i64,
#[arg(long, help = "Delete the album files from disk as well")]
delete_files_from_disk: bool,
#[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)]
track_file_id: i64,
},
#[command(about = "Delete an artist from your Lidarr library")]
Artist {
#[arg(long, help = "The ID of the artist to delete", required = true)]
artist_id: i64,
#[arg(long, help = "Delete the artist files from disk as well")]
delete_files_from_disk: bool,
#[arg(long, help = "Add a list exclusion for this artist")]
add_list_exclusion: bool,
},
#[command(about = "Delete the specified download")]
Download {
#[arg(long, help = "The ID of the download to delete", required = true)]
download_id: i64,
},
#[command(about = "Delete the indexer with the given ID")]
Indexer {
#[arg(long, help = "The ID of the indexer to delete", required = true)]
indexer_id: i64,
},
#[command(about = "Delete the root folder with the given ID")]
RootFolder {
#[arg(long, help = "The ID of the root folder to delete", required = true)]
root_folder_id: i64,
},
#[command(about = "Delete the tag with the specified ID")]
Tag {
#[arg(long, help = "The ID of the tag to delete", required = true)]
tag_id: i64,
},
}
impl From<LidarrDeleteCommand> for Command {
fn from(value: LidarrDeleteCommand) -> Self {
Command::Lidarr(LidarrCommand::Delete(value))
}
}
pub(super) struct LidarrDeleteCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrDeleteCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrDeleteCommand::Album {
album_id,
delete_files_from_disk,
add_list_exclusion,
} => {
let delete_album_params = DeleteParams {
id: album_id,
delete_files: delete_files_from_disk,
add_import_list_exclusion: add_list_exclusion,
};
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteAlbum(delete_album_params).into())
.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
.handle_network_event(LidarrEvent::DeleteTrackFile(track_file_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Artist {
artist_id,
delete_files_from_disk,
add_list_exclusion,
} => {
let delete_artist_params = DeleteParams {
id: artist_id,
delete_files: delete_files_from_disk,
add_import_list_exclusion: add_list_exclusion,
};
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteArtist(delete_artist_params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Download { download_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteDownload(download_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Indexer { indexer_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteIndexer(indexer_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::RootFolder { root_folder_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteRootFolder(root_folder_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Tag { tag_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteTag(tag_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,595 @@
#[cfg(test)]
mod tests {
use crate::{
Cli,
cli::{
Command,
lidarr::{LidarrCommand, delete_command_handler::LidarrDeleteCommand},
},
};
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_delete_command_from() {
let command = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Delete(command)));
}
mod cli {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_delete_album_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "album"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_album_defaults() {
let expected_args = LidarrDeleteCommand::Album {
album_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result =
Cli::try_parse_from(["managarr", "lidarr", "delete", "album", "--album-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_album_all_args_defined() {
let expected_args = LidarrDeleteCommand::Album {
album_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"album",
"--album-id",
"1",
"--delete-files-from-disk",
"--add-list-exclusion",
]);
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_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 =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "track-file"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_track_file_success() {
let expected_args = LidarrDeleteCommand::TrackFile { track_file_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"track-file",
"--track-file-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_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_artist_defaults() {
let expected_args = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result =
Cli::try_parse_from(["managarr", "lidarr", "delete", "artist", "--artist-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_artist_all_args_defined() {
let expected_args = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"artist",
"--artist-id",
"1",
"--delete-files-from-disk",
"--add-list-exclusion",
]);
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_download_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "download"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_download_success() {
let expected_args = LidarrDeleteCommand::Download { download_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"download",
"--download-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_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "indexer"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_indexer_success() {
let expected_args = LidarrDeleteCommand::Indexer { indexer_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"indexer",
"--indexer-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_root_folder_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "root-folder"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_root_folder_success() {
let expected_args = LidarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"root-folder",
"--root-folder-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_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "tag"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_tag_success() {
let expected_args = LidarrDeleteCommand::Tag { tag_id: 1 };
let result = Cli::try_parse_from(["managarr", "lidarr", "delete", "tag", "--tag-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);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler},
},
models::{
Serdeable,
lidarr_models::{DeleteParams, LidarrSerdeable},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_delete_album_command() {
let expected_delete_album_params = DeleteParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteAlbum(expected_delete_album_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_album_command = LidarrDeleteCommand::Album {
album_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_album_command, &mut mock_network)
.handle()
.await;
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;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteTrackFile(expected_track_file_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_track_file_command = LidarrDeleteCommand::TrackFile { track_file_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_track_file_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_artist_command() {
let expected_delete_artist_params = DeleteParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteArtist(expected_delete_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_artist_command = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_download_command() {
let expected_download_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteDownload(expected_download_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_download_command = LidarrDeleteCommand::Download { download_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_indexer_command() {
let expected_indexer_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteIndexer(expected_indexer_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_indexer_command = LidarrDeleteCommand::Indexer { indexer_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_root_folder_command() {
let expected_root_folder_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteRootFolder(expected_root_folder_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_root_folder_command = LidarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_tag_command() {
let expected_tag_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteTag(expected_tag_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_tag_command = LidarrDeleteCommand::Tag { tag_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+343
View File
@@ -0,0 +1,343 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, ArgGroup, Subcommand};
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::models::Serdeable;
use crate::models::lidarr_models::LidarrSerdeable;
use crate::models::servarr_models::{EditIndexerParams, IndexerSettings};
use crate::{
app::App,
cli::{CliCommandHandler, Command, mutex_flags_or_option},
models::lidarr_models::{EditArtistParams, NewItemMonitorType},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "edit_command_handler_tests.rs"]
mod edit_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrEditCommand {
#[command(
about = "Edit and indexer settings that apply to all indexers",
group(
ArgGroup::new("edit_settings")
.args([
"maximum_size",
"minimum_age",
"retention",
"rss_sync_interval",
]).required(true)
.multiple(true))
)]
AllIndexerSettings {
#[arg(
long,
help = "The maximum size for a release to be grabbed in MB. Set to zero to set to unlimited"
)]
maximum_size: Option<i64>,
#[arg(
long,
help = "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider."
)]
minimum_age: Option<i64>,
#[arg(
long,
help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention"
)]
retention: Option<i64>,
#[arg(
long,
help = "The RSS sync interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)"
)]
rss_sync_interval: Option<i64>,
},
#[command(
about = "Edit preferences for the specified artist",
group(
ArgGroup::new("edit_artist")
.args([
"enable_monitoring",
"disable_monitoring",
"monitor_new_items",
"quality_profile_id",
"metadata_profile_id",
"root_folder_path",
"tag",
"clear_tags"
]).required(true)
.multiple(true))
)]
Artist {
#[arg(
long,
help = "The ID of the artist whose settings you want to edit",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "Enable monitoring of this artist in Lidarr so Lidarr will automatically download releases from this artist if they are available",
conflicts_with = "disable_monitoring"
)]
enable_monitoring: bool,
#[arg(
long,
help = "Disable monitoring of this artist so Lidarr does not automatically download releases from this artist if they are available",
conflicts_with = "enable_monitoring"
)]
disable_monitoring: bool,
#[arg(
long,
help = "How Lidarr should monitor new albums from this artist",
value_enum
)]
monitor_new_items: Option<NewItemMonitorType>,
#[arg(long, help = "The ID of the quality profile to use for this artist")]
quality_profile_id: Option<i64>,
#[arg(long, help = "The ID of the metadata profile to use for this artist")]
metadata_profile_id: Option<i64>,
#[arg(
long,
help = "The root folder path where all artist data and metadata should live"
)]
root_folder_path: Option<String>,
#[arg(
long,
help = "Tag IDs to tag this artist with",
value_parser,
action = ArgAction::Append,
conflicts_with = "clear_tags"
)]
tag: Option<Vec<i64>>,
#[arg(long, help = "Clear all tags on this artist", conflicts_with = "tag")]
clear_tags: bool,
},
#[command(
about = "Edit preferences for the specified indexer",
group(
ArgGroup::new("edit_indexer")
.args([
"name",
"enable_rss",
"disable_rss",
"enable_automatic_search",
"disable_automatic_search",
"enable_interactive_search",
"disable_automatic_search",
"url",
"api_key",
"seed_ratio",
"tag",
"priority",
"clear_tags"
]).required(true)
.multiple(true))
)]
Indexer {
#[arg(
long,
help = "The ID of the indexer whose settings you wish to edit",
required = true
)]
indexer_id: i64,
#[arg(long, help = "The name of the indexer")]
name: Option<String>,
#[arg(
long,
help = "Indicate to Lidarr that this indexer should be used when Lidarr periodically looks for releases via RSS Sync",
conflicts_with = "disable_rss"
)]
enable_rss: bool,
#[arg(
long,
help = "Disable using this indexer when Lidarr periodically looks for releases via RSS Sync",
conflicts_with = "enable_rss"
)]
disable_rss: bool,
#[arg(
long,
help = "Indicate to Lidarr that this indexer should be used when automatic searches are performed via the UI or by Lidarr",
conflicts_with = "disable_automatic_search"
)]
enable_automatic_search: bool,
#[arg(
long,
help = "Disable using this indexer whenever automatic searches are performed via the UI or by Lidarr",
conflicts_with = "enable_automatic_search"
)]
disable_automatic_search: bool,
#[arg(
long,
help = "Indicate to Lidarr that this indexer should be used when an interactive search is used",
conflicts_with = "disable_interactive_search"
)]
enable_interactive_search: bool,
#[arg(
long,
help = "Disable using this indexer whenever an interactive search is performed",
conflicts_with = "enable_interactive_search"
)]
disable_interactive_search: bool,
#[arg(long, help = "The URL of the indexer")]
url: Option<String>,
#[arg(long, help = "The API key used to access the indexer's API")]
api_key: Option<String>,
#[arg(
long,
help = "The ratio a torrent should reach before stopping; Empty uses the download client's default. Ratio should be at least 1.0 and follow the indexer's rules"
)]
seed_ratio: Option<String>,
#[arg(
long,
help = "Only use this indexer for series with at least one matching tag ID. Leave blank to use with all series.",
value_parser,
action = ArgAction::Append,
conflicts_with = "clear_tags"
)]
tag: Option<Vec<i64>>,
#[arg(
long,
help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Lidarr will still use all enabled indexers for RSS Sync and Searching"
)]
priority: Option<i64>,
#[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")]
clear_tags: bool,
},
}
impl From<LidarrEditCommand> for Command {
fn from(value: LidarrEditCommand) -> Self {
Command::Lidarr(LidarrCommand::Edit(value))
}
}
pub(super) struct LidarrEditCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrEditCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrEditCommand> for LidarrEditCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrEditCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrEditCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrEditCommand::AllIndexerSettings {
maximum_size,
minimum_age,
retention,
rss_sync_interval,
} => {
if let Serdeable::Lidarr(LidarrSerdeable::IndexerSettings(previous_indexer_settings)) = self
.network
.handle_network_event(LidarrEvent::GetAllIndexerSettings.into())
.await?
{
let params = IndexerSettings {
id: 1,
maximum_size: maximum_size.unwrap_or(previous_indexer_settings.maximum_size),
minimum_age: minimum_age.unwrap_or(previous_indexer_settings.minimum_age),
retention: retention.unwrap_or(previous_indexer_settings.retention),
rss_sync_interval: rss_sync_interval
.unwrap_or(previous_indexer_settings.rss_sync_interval),
};
self
.network
.handle_network_event(LidarrEvent::EditAllIndexerSettings(params).into())
.await?;
"All indexer settings updated".to_owned()
} else {
String::new()
}
}
LidarrEditCommand::Artist {
artist_id,
enable_monitoring,
disable_monitoring,
monitor_new_items,
quality_profile_id,
metadata_profile_id,
root_folder_path,
tag,
clear_tags,
} => {
let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring);
let edit_artist_params = EditArtistParams {
artist_id,
monitored: monitored_value,
monitor_new_items,
quality_profile_id,
metadata_profile_id,
root_folder_path,
tags: tag,
tag_input_string: None,
clear_tags,
};
self
.network
.handle_network_event(LidarrEvent::EditArtist(edit_artist_params).into())
.await?;
"Artist Updated".to_owned()
}
LidarrEditCommand::Indexer {
indexer_id,
name,
enable_rss,
disable_rss,
enable_automatic_search,
disable_automatic_search,
enable_interactive_search,
disable_interactive_search,
url,
api_key,
seed_ratio,
tag,
priority,
clear_tags,
} => {
let rss_value = mutex_flags_or_option(enable_rss, disable_rss);
let automatic_search_value =
mutex_flags_or_option(enable_automatic_search, disable_automatic_search);
let interactive_search_value =
mutex_flags_or_option(enable_interactive_search, disable_interactive_search);
let edit_indexer_params = EditIndexerParams {
indexer_id,
name,
enable_rss: rss_value,
enable_automatic_search: automatic_search_value,
enable_interactive_search: interactive_search_value,
url,
api_key,
seed_ratio,
tags: tag,
tag_input_string: None,
priority,
clear_tags,
};
self
.network
.handle_network_event(LidarrEvent::EditIndexer(edit_indexer_params).into())
.await?;
"Indexer updated".to_owned()
}
};
Ok(result)
}
}
@@ -0,0 +1,858 @@
#[cfg(test)]
mod tests {
use crate::cli::{
Command,
lidarr::{LidarrCommand, edit_command_handler::LidarrEditCommand},
};
#[test]
fn test_lidarr_edit_command_from() {
let command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: None,
quality_profile_id: None,
metadata_profile_id: None,
root_folder_path: None,
tag: None,
clear_tags: false,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Edit(command)));
}
mod cli {
use crate::{Cli, models::lidarr_models::NewItemMonitorType};
use super::*;
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test]
fn test_edit_all_indexer_settings_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "all-indexer-settings"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[rstest]
fn test_edit_all_indexer_settings_assert_argument_flags_require_args(
#[values(
"--maximum-size",
"--minimum-age",
"--retention",
"--rss-sync-interval"
)]
flag: &str,
) {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"all-indexer-settings",
flag,
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() {
let expected_args = LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: None,
retention: None,
rss_sync_interval: None,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"all-indexer-settings",
"--maximum-size",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_all_indexer_settings_all_arguments_defined() {
let expected_args = LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"all-indexer-settings",
"--maximum-size",
"1",
"--minimum-age",
"1",
"--retention",
"1",
"--rss-sync-interval",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_artist_with_artist_id_still_requires_arguments() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_artist_monitoring_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--enable-monitoring",
"--disable-monitoring",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_artist_tag_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[rstest]
fn test_edit_artist_assert_argument_flags_require_args(
#[values(
"--monitor-new-items",
"--quality-profile-id",
"--metadata-profile-id",
"--root-folder-path",
"--tag"
)]
flag: &str,
) {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
flag,
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_artist_monitor_new_items_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--monitor-new-items",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_artist_only_requires_at_least_one_argument_plus_artist_id() {
let expected_args = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: None,
quality_profile_id: None,
metadata_profile_id: None,
root_folder_path: Some("/nfs/test".to_owned()),
tag: None,
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--root-folder-path",
"/nfs/test",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_tag_argument_is_repeatable() {
let expected_args = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: None,
quality_profile_id: None,
metadata_profile_id: None,
root_folder_path: None,
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_all_arguments_defined() {
let expected_args = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: true,
disable_monitoring: false,
monitor_new_items: Some(NewItemMonitorType::New),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--enable-monitoring",
"--monitor-new-items",
"new",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
"--root-folder-path",
"/nfs/test",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "indexer"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_indexer_with_indexer_id_still_requires_arguments() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_indexer_rss_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-rss",
"--disable-rss",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_indexer_automatic_search_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-automatic-search",
"--disable-automatic-search",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_indexer_interactive_search_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-interactive-search",
"--disable-interactive-search",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_indexer_tag_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[rstest]
fn test_edit_indexer_assert_argument_flags_require_args(
#[values("--name", "--url", "--api-key", "--seed-ratio", "--tag", "--priority")] flag: &str,
) {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
flag,
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_indexer_only_requires_at_least_one_argument_plus_indexer_id() {
let expected_args = LidarrEditCommand::Indexer {
indexer_id: 1,
name: Some("Test".to_owned()),
enable_rss: false,
disable_rss: false,
enable_automatic_search: false,
disable_automatic_search: false,
enable_interactive_search: false,
disable_interactive_search: false,
url: None,
api_key: None,
seed_ratio: None,
tag: None,
priority: None,
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--name",
"Test",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_tag_argument_is_repeatable() {
let expected_args = LidarrEditCommand::Indexer {
indexer_id: 1,
name: None,
enable_rss: false,
disable_rss: false,
enable_automatic_search: false,
disable_automatic_search: false,
enable_interactive_search: false,
disable_interactive_search: false,
url: None,
api_key: None,
seed_ratio: None,
tag: Some(vec![1, 2]),
priority: None,
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_all_arguments_defined() {
let expected_args = LidarrEditCommand::Indexer {
indexer_id: 1,
name: Some("Test".to_owned()),
enable_rss: true,
disable_rss: false,
enable_automatic_search: true,
disable_automatic_search: false,
enable_interactive_search: true,
disable_interactive_search: false,
url: Some("http://test.com".to_owned()),
api_key: Some("testKey".to_owned()),
seed_ratio: Some("1.2".to_owned()),
tag: Some(vec![1, 2]),
priority: Some(25),
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--name",
"Test",
"--enable-rss",
"--enable-automatic-search",
"--enable-interactive-search",
"--url",
"http://test.com",
"--api-key",
"testKey",
"--seed-ratio",
"1.2",
"--tag",
"1",
"--tag",
"2",
"--priority",
"25",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::models::servarr_models::{EditIndexerParams, IndexerSettings};
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler},
},
models::{
Serdeable,
lidarr_models::{EditArtistParams, LidarrSerdeable, NewItemMonitorType},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_edit_all_indexer_settings_command() {
let expected_edit_all_indexer_settings = IndexerSettings {
id: 1,
maximum_size: 1,
minimum_age: 1,
retention: 1,
rss_sync_interval: 1,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::IndexerSettings(
IndexerSettings {
id: 1,
maximum_size: 2,
minimum_age: 2,
retention: 2,
rss_sync_interval: 2,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
};
let result = LidarrEditCommandHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command() {
let expected_edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: Some(true),
monitor_new_items: Some(NewItemMonitorType::New),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: true,
disable_monitoring: false,
monitor_new_items: Some(NewItemMonitorType::New),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command_handles_disable_monitoring_flag_properly() {
let expected_edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: Some(false),
monitor_new_items: Some(NewItemMonitorType::None),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: true,
monitor_new_items: Some(NewItemMonitorType::None),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command_no_monitoring_boolean_flags_returns_none_value() {
let expected_edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: None,
monitor_new_items: Some(NewItemMonitorType::All),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: Some(NewItemMonitorType::All),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_indexer_command() {
let expected_edit_indexer_params = EditIndexerParams {
indexer_id: 1,
name: Some("Test".to_owned()),
enable_rss: Some(true),
enable_automatic_search: Some(true),
enable_interactive_search: Some(true),
url: Some("http://test.com".to_owned()),
api_key: Some("testKey".to_owned()),
seed_ratio: Some("1.2".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
priority: Some(25),
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditIndexer(expected_edit_indexer_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = LidarrEditCommand::Indexer {
indexer_id: 1,
name: Some("Test".to_owned()),
enable_rss: true,
disable_rss: false,
enable_automatic_search: true,
disable_automatic_search: false,
enable_interactive_search: true,
disable_interactive_search: false,
url: Some("http://test.com".to_owned()),
api_key: Some("testKey".to_owned()),
seed_ratio: Some("1.2".to_owned()),
tag: Some(vec![1, 2]),
priority: Some(25),
clear_tags: false,
};
let result =
LidarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "get_command_handler_tests.rs"]
mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrGetCommand {
#[command(about = "Get detailed information for the album with the given ID")]
AlbumDetails {
#[arg(
long,
help = "The Lidarr ID of the album whose details you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "Get the shared settings for all indexers")]
AllIndexerSettings,
#[command(about = "Get detailed information for the artist with the given ID")]
ArtistDetails {
#[arg(
long,
help = "The Lidarr ID of the artist whose details you wish to fetch",
required = true
)]
artist_id: i64,
},
#[command(about = "Fetch the host config for your Lidarr instance")]
HostConfig,
#[command(about = "Fetch the security config for your Lidarr instance")]
SecurityConfig,
#[command(about = "Get the system status")]
SystemStatus,
#[command(about = "Get detailed information for the track with the given ID")]
TrackDetails {
#[arg(
long,
help = "The Lidarr ID of the track whose details you wish to fetch",
required = true
)]
track_id: i64,
},
}
impl From<LidarrGetCommand> for Command {
fn from(value: LidarrGetCommand) -> Self {
Command::Lidarr(LidarrCommand::Get(value))
}
}
pub(super) struct LidarrGetCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrGetCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrGetCommand> for LidarrGetCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrGetCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrGetCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrGetCommand::AlbumDetails { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbumDetails(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::AllIndexerSettings => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAllIndexerSettings.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::ArtistDetails { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetArtistDetails(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::HostConfig => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetHostConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::SecurityConfig => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetSecurityConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::SystemStatus => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetStatus.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::TrackDetails { track_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTrackDetails(track_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+329
View File
@@ -0,0 +1,329 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, get_command_handler::LidarrGetCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_get_command_from() {
let command = LidarrGetCommand::SystemStatus;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Get(command)));
}
mod cli {
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_album_details_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "album-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"album-details",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_all_indexer_settings_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "all-indexer-settings"]);
assert_ok!(&result);
}
#[test]
fn test_artist_details_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "artist-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_artist_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"artist-details",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_host_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "host-config"]);
assert_ok!(&result);
}
#[test]
fn test_security_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "security-config"]);
assert_ok!(&result);
}
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "system-status"]);
assert_ok!(&result);
}
#[test]
fn test_track_details_requires_track_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "track-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_track_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"track-details",
"--track-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler},
},
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_get_album_details_command() {
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumDetails(expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_album_details_command = LidarrGetCommand::AlbumDetails { album_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_album_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_all_indexer_settings_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_all_indexer_settings_command = LidarrGetCommand::AllIndexerSettings;
let result = LidarrGetCommandHandler::with(
&app_arc,
get_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_artist_details_command() {
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetArtistDetails(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_artist_details_command = LidarrGetCommand::ArtistDetails { artist_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_artist_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_host_config_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetHostConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_host_config_command = LidarrGetCommand::HostConfig;
let result =
LidarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_security_config_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetSecurityConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_security_config_command = LidarrGetCommand::SecurityConfig;
let result =
LidarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_system_status_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = LidarrGetCommand::SystemStatus;
let result =
LidarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_track_details_command() {
let expected_track_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTrackDetails(expected_track_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_track_details_command = LidarrGetCommand::TrackDetails { track_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_track_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+775
View File
@@ -0,0 +1,775 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_command_from() {
let command = LidarrCommand::List(LidarrListCommand::Artists);
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(command));
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_commands_that_have_no_arg_requirements(
#[values("clear-blocklist", "test-all-indexers")] subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", subcommand]);
assert_ok!(&result);
}
#[test]
fn test_list_artists_has_no_arg_requirements() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test]
fn test_lidarr_list_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list"]);
assert_err!(&result);
}
#[test]
fn test_lidarr_add_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add"]);
assert_err!(&result);
}
#[test]
fn test_lidarr_delete_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete"]);
assert_err!(&result);
}
#[test]
fn test_download_release_requires_guid() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"download-release",
"--indexer-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_release_requires_indexer_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"download-release",
"--guid",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_release_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"download-release",
"--guid",
"1",
"--indexer-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_toggle_artist_monitoring_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-artist-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_artist_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"toggle-artist-monitoring",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_toggle_album_monitoring_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-album-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_album_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"toggle-album-monitoring",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_search_new_artist_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_search_new_artist_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"search-new-artist",
"--query",
"test query",
]);
assert_ok!(&result);
}
#[test]
fn test_start_task_requires_task_name() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "start-task"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_start_task_task_name_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"start-task",
"--task-name",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_start_task_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"start-task",
"--task-name",
"application-update-check",
]);
assert_ok!(&result);
}
#[test]
fn test_mark_history_item_as_failed_requires_history_item_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "mark-history-item-as-failed"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_mark_history_item_as_failed_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"mark-history-item-as-failed",
"--history-item-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_test_indexer_requires_indexer_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "test-indexer"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_test_indexer_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"test-indexer",
"--indexer-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::add_command_handler::LidarrAddCommand;
use crate::cli::lidarr::edit_command_handler::LidarrEditCommand;
use crate::cli::lidarr::get_command_handler::LidarrGetCommand;
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::{
BlocklistItem, BlocklistResponse, LidarrReleaseDownloadBody, LidarrTaskName,
};
use crate::models::servarr_models::IndexerSettings;
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::{
LidarrCliHandler, LidarrCommand, delete_command_handler::LidarrDeleteCommand,
list_command_handler::LidarrListCommand,
},
},
models::{
Serdeable,
lidarr_models::{Artist, DeleteParams, LidarrSerdeable},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_add_commands_to_the_add_command_handler() {
let expected_tag_name = "test".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = LidarrCommand::Add(LidarrAddCommand::Tag {
name: expected_tag_name,
});
let result = LidarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_get_commands_to_the_get_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = LidarrCommand::Get(LidarrGetCommand::SystemStatus);
let result = LidarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_delete_artist_params = DeleteParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteArtist(expected_delete_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_artist_command = LidarrCommand::Delete(LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
});
let result = LidarrCliHandler::with(&app_arc, delete_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() {
let expected_edit_all_indexer_settings = IndexerSettings {
id: 1,
maximum_size: 1,
minimum_age: 1,
retention: 1,
rss_sync_interval: 1,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::IndexerSettings(
IndexerSettings {
id: 1,
maximum_size: 2,
minimum_age: 2,
retention: 2,
rss_sync_interval: 2,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command =
LidarrCommand::Edit(LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
});
let result = LidarrCliHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_list_commands_to_the_list_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ListArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Artists(vec![
Artist::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_artists_command = LidarrCommand::List(LidarrListCommand::Artists);
let result = LidarrCliHandler::with(&app_arc, list_artists_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::UpdateAllArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_artist_command = LidarrCommand::Refresh(LidarrRefreshCommand::AllArtists);
let result = LidarrCliHandler::with(&app_arc, refresh_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_manual_search_commands_to_the_manual_search_command_handler()
{
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetDiscographyReleases(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_episode_search_command =
LidarrCommand::ManualSearch(LidarrManualSearchCommand::Discography { artist_id: 1 });
let result =
LidarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler()
{
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticArtistSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrCommand::TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand::Artist {
artist_id: 1,
});
let result = LidarrCliHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
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 {
guid: "guid".to_owned(),
indexer_id: 1,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DownloadRelease(expected_release_download_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let download_release_command = LidarrCommand::DownloadRelease {
guid: "guid".to_owned(),
indexer_id: 1,
};
let result = LidarrCliHandler::with(&app_arc, download_release_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_toggle_artist_monitoring_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::ToggleArtistMonitoring(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let toggle_artist_monitoring_command = LidarrCommand::ToggleArtistMonitoring { artist_id: 1 };
let result = LidarrCliHandler::with(
&app_arc,
toggle_artist_monitoring_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_search_new_artist_command() {
let expected_query = "test artist".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::SearchNewArtist(expected_query.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let search_new_artist_command = LidarrCommand::SearchNewArtist {
query: expected_query,
};
let result = LidarrCliHandler::with(&app_arc, search_new_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_start_task_command() {
let expected_task_name = LidarrTaskName::ApplicationUpdateCheck;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::StartTask(expected_task_name).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let start_task_command = LidarrCommand::StartTask {
task_name: LidarrTaskName::ApplicationUpdateCheck,
};
let result = LidarrCliHandler::with(&app_arc, start_task_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_test_indexer_command() {
let expected_indexer_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TestIndexer(expected_indexer_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let test_indexer_command = LidarrCommand::TestIndexer { indexer_id: 1 };
let result = LidarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_test_all_indexers_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::TestAllIndexers.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let test_all_indexers_command = LidarrCommand::TestAllIndexers;
let result = LidarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_mark_history_item_as_failed_command() {
let expected_history_item_id = 1i64;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::MarkHistoryItemAsFailed(expected_history_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 mark_history_item_as_failed_command = LidarrCommand::MarkHistoryItemAsFailed {
history_item_id: expected_history_item_id,
};
let result = LidarrCliHandler::with(
&app_arc,
mark_history_item_as_failed_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
}
}
+350
View File
@@ -0,0 +1,350 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{Subcommand, arg};
use serde_json::json;
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrSerdeable};
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "list_command_handler_tests.rs"]
mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrListCommand {
#[command(about = "List all albums for the artist with the given ID")]
Albums {
#[arg(
long,
help = "The Lidarr ID of the artist whose albums you want to list",
required = true
)]
artist_id: i64,
},
#[command(
about = "Fetch all history events for the given album corresponding to the artist with the given ID."
)]
AlbumHistory {
#[arg(
long,
help = "The Lidarr artist ID of the artist whose history you wish to fetch and list",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "The Lidarr album ID to fetch history events for",
required = true
)]
album_id: i64,
},
#[command(about = "Fetch all history events for the artist with the given ID")]
ArtistHistory {
#[arg(
long,
help = "The Lidarr ID of the artist whose history you wish to fetch",
required = true
)]
artist_id: i64,
},
#[command(about = "List all artists in your Lidarr library")]
Artists,
#[command(about = "List all items in the Lidarr blocklist")]
Blocklist,
#[command(about = "List disk space details for all provisioned root folders in Lidarr")]
DiskSpace,
#[command(about = "List all active downloads in Lidarr")]
Downloads {
#[arg(long, help = "How many downloads to fetch", default_value_t = 500)]
count: u64,
},
#[command(about = "Fetch all Lidarr history events")]
History {
#[arg(long, help = "How many history events to fetch", default_value_t = 500)]
events: u64,
},
#[command(about = "List all Lidarr indexers")]
Indexers,
#[command(about = "Fetch Lidarr logs")]
Logs {
#[arg(long, help = "How many log events to fetch", default_value_t = 500)]
events: u64,
#[arg(
long,
help = "Output the logs in the same format as they appear in the log files"
)]
output_in_log_format: bool,
},
#[command(about = "List all Lidarr metadata profiles")]
MetadataProfiles,
#[command(about = "List all Lidarr quality profiles")]
QualityProfiles,
#[command(about = "List all queued events")]
QueuedEvents,
#[command(about = "List all root folders in Lidarr")]
RootFolders,
#[command(about = "List all Lidarr tags")]
Tags,
#[command(about = "List all Lidarr tasks")]
Tasks,
#[command(about = "Fetch all history events for the track with the given ID")]
TrackHistory {
#[arg(
long,
help = "The artist ID that the track belongs to",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "The album ID that the track is a part of",
required = true
)]
album_id: i64,
#[arg(
long,
help = "The Lidarr ID of the track whose history you wish to fetch",
required = true
)]
track_id: i64,
},
#[command(
about = "List the tracks for the album that corresponds to the artist with the given ID"
)]
Tracks {
#[arg(
long,
help = "The Lidarr artist ID of the artist whose tracks you wish to fetch",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "The Lidarr album ID whose tracks you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "List the track files for the album with the given ID")]
TrackFiles {
#[arg(
long,
help = "The Lidarr ID of the album whose track files you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "List all Lidarr updates")]
Updates,
}
impl From<LidarrListCommand> for Command {
fn from(value: LidarrListCommand) -> Self {
Command::Lidarr(LidarrCommand::List(value))
}
}
pub(super) struct LidarrListCommandHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrListCommandHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrListCommand::Albums { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbums(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::AlbumHistory {
artist_id,
album_id,
} => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbumHistory(artist_id, album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::ArtistHistory { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetArtistHistory(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Artists => {
let resp = self
.network
.handle_network_event(LidarrEvent::ListArtists.into())
.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::DiskSpace => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetDiskSpace.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Downloads { count } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetDownloads(count).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::History { events: items } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetHistory(items).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Indexers => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Logs {
events,
output_in_log_format,
} => {
let logs = self
.network
.handle_network_event(LidarrEvent::GetLogs(events).into())
.await?;
if output_in_log_format {
let log_lines = &self.app.lock().await.data.sonarr_data.logs.items;
serde_json::to_string_pretty(log_lines)?
} else {
serde_json::to_string_pretty(&logs)?
}
}
LidarrListCommand::MetadataProfiles => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetMetadataProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::QualityProfiles => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetQualityProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::QueuedEvents => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetQueuedEvents.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::RootFolders => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetRootFolders.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Tags => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTags.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Tasks => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTasks.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::TrackHistory {
artist_id,
album_id,
track_id,
} => {
match self
.network
.handle_network_event(LidarrEvent::GetTrackHistory(artist_id, album_id, track_id).into())
.await
{
Ok(Serdeable::Lidarr(LidarrSerdeable::LidarrHistoryItems(history_vec))) => {
let history_items_vec: Vec<LidarrHistoryItem> = history_vec
.into_iter()
.filter(|it| it.track_id == track_id)
.collect();
serde_json::to_string_pretty(&history_items_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
}
}
LidarrListCommand::Tracks {
artist_id,
album_id,
} => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTracks(artist_id, album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::TrackFiles { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTrackFiles(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Updates => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetUpdates.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,728 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_list_command_from() {
let command = LidarrListCommand::Artists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::List(command)));
}
mod cli {
use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_list_commands_have_no_arg_requirements(
#[values(
"artists",
"blocklist",
"disk-space",
"indexers",
"metadata-profiles",
"quality-profiles",
"queued-events",
"tags",
"tasks",
"updates",
"root-folders"
)]
subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", subcommand]);
assert_ok!(&result);
}
#[test]
fn test_list_albums_requires_artist_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "albums"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_albums_with_artist_id() {
let expected_args = LidarrListCommand::Albums { artist_id: 1 };
let result =
Cli::try_parse_from(["managarr", "lidarr", "list", "albums", "--artist-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(album_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(album_command, expected_args);
}
#[test]
fn test_album_history_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"album-history",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_history_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"album-history",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_history_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"album-history",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_list_artist_history_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artist-history"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_artist_history_success() {
let expected_args = LidarrListCommand::ArtistHistory { artist_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"artist-history",
"--artist-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(artist_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(artist_command, expected_args);
}
#[test]
fn test_list_downloads_count_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "downloads", "--count"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_downloads_default_values() {
let expected_args = LidarrListCommand::Downloads { count: 500 };
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "downloads"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(downloads_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(downloads_command, expected_args);
}
#[test]
fn test_list_history_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "history", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_history_default_values() {
let expected_args = LidarrListCommand::History { events: 500 };
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "history"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(history_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(history_command, expected_args);
}
#[test]
fn test_list_logs_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "logs", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_logs_default_values() {
let expected_args = LidarrListCommand::Logs {
events: 500,
output_in_log_format: false,
};
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "logs"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(logs_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(logs_command, expected_args);
}
#[test]
fn test_list_track_history_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"track-history",
"--album-id",
"1",
"--track-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_history_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"track-history",
"--artist-id",
"1",
"--track-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_history_requires_track_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"track-history",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_history_success() {
let expected_args = LidarrListCommand::TrackHistory {
artist_id: 1,
album_id: 1,
track_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"track-history",
"--artist-id",
"1",
"--album-id",
"1",
"--track-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(track_history_command))) =
result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(track_history_command, expected_args);
}
#[test]
fn test_list_tracks_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"tracks",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_tracks_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"tracks",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_tracks_success() {
let expected_args = LidarrListCommand::Tracks {
artist_id: 1,
album_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"tracks",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(tracks_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(tracks_command, expected_args);
}
#[test]
fn test_list_track_files_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "track-files"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_files_success() {
let expected_args = LidarrListCommand::TrackFiles { album_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"track-files",
"--album-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(track_files_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(track_files_command, expected_args);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use pretty_assertions::assert_str_eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrSerdeable};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::lidarr_history_item;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)]
#[case(LidarrListCommand::Blocklist, LidarrEvent::GetBlocklist)]
#[case(LidarrListCommand::DiskSpace, LidarrEvent::GetDiskSpace)]
#[case(LidarrListCommand::Indexers, LidarrEvent::GetIndexers)]
#[case(LidarrListCommand::MetadataProfiles, LidarrEvent::GetMetadataProfiles)]
#[case(LidarrListCommand::QualityProfiles, LidarrEvent::GetQualityProfiles)]
#[case(LidarrListCommand::QueuedEvents, LidarrEvent::GetQueuedEvents)]
#[case(LidarrListCommand::RootFolders, LidarrEvent::GetRootFolders)]
#[case(LidarrListCommand::Tags, LidarrEvent::GetTags)]
#[case(LidarrListCommand::Tasks, LidarrEvent::GetTasks)]
#[case(LidarrListCommand::Updates, LidarrEvent::GetUpdates)]
#[tokio::test]
async fn test_handle_list_command(
#[case] list_command: LidarrListCommand,
#[case] expected_lidarr_event: LidarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_lidarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_albums_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetAlbums(1).into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_command = LidarrListCommand::Albums { artist_id: 1 };
let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_list_album_history_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumHistory(expected_artist_id, expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_album_history_command = LidarrListCommand::AlbumHistory {
artist_id: 1,
album_id: 1,
};
let result =
LidarrListCommandHandler::with(&app_arc, list_album_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_artist_history_command() {
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetArtistHistory(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_artist_history_command = LidarrListCommand::ArtistHistory { artist_id: 1 };
let result =
LidarrListCommandHandler::with(&app_arc, list_artist_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_downloads_command() {
let expected_count = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetDownloads(expected_count).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_downloads_command = LidarrListCommand::Downloads { count: 1000 };
let result =
LidarrListCommandHandler::with(&app_arc, list_downloads_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_history_command() {
let expected_events = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetHistory(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_history_command = LidarrListCommand::History { events: 1000 };
let result =
LidarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_logs_command() {
let expected_events = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetLogs(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_logs_command = LidarrListCommand::Logs {
events: 1000,
output_in_log_format: false,
};
let result = LidarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_track_history_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let expected_track_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTrackHistory(expected_artist_id, expected_album_id, expected_track_id)
.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::LidarrHistoryItems(
vec![
lidarr_history_item(),
LidarrHistoryItem {
track_id: 2,
..lidarr_history_item()
},
],
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_track_history_command = LidarrListCommand::TrackHistory {
artist_id: expected_artist_id,
album_id: expected_album_id,
track_id: expected_track_id,
};
let result =
LidarrListCommandHandler::with(&app_arc, list_track_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(
result.unwrap(),
serde_json::to_string_pretty(&[lidarr_history_item()]).unwrap()
);
}
#[tokio::test]
async fn test_handle_list_tracks_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTracks(expected_artist_id, expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_tracks_command = LidarrListCommand::Tracks {
artist_id: 1,
album_id: 1,
};
let result = LidarrListCommandHandler::with(&app_arc, list_tracks_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_track_files_command() {
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTrackFiles(expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_track_files_command = LidarrListCommand::TrackFiles { album_id: 1 };
let result =
LidarrListCommandHandler::with(&app_arc, list_track_files_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
@@ -0,0 +1,118 @@
use crate::app::App;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::{CliCommandHandler, Command};
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrRelease, LidarrSerdeable};
use crate::network::NetworkTrait;
use crate::network::lidarr_network::LidarrEvent;
use anyhow::Result;
use clap::Subcommand;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
#[cfg(test)]
#[path = "manual_search_command_handler_tests.rs"]
mod manual_search_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrManualSearchCommand {
#[command(
about = "Trigger a manual search of releases for the given album corresponding to the artist with the given ID"
)]
Album {
#[arg(
long,
help = "The Lidarr ID of the artist whose releases you wish to fetch and list",
required = true
)]
artist_id: i64,
#[arg(long, help = "The Lidarr album ID to search for", required = true)]
album_id: i64,
},
#[command(
about = "Trigger a manual search of discography releases for the given artist corresponding to the artist with the given ID."
)]
Discography {
#[arg(
long,
help = "The Lidarr ID of the artist whose discography releases you wish to fetch and list",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrManualSearchCommand> for Command {
fn from(value: LidarrManualSearchCommand) -> Self {
Command::Lidarr(LidarrCommand::ManualSearch(value))
}
}
pub(super) struct LidarrManualSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrManualSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrManualSearchCommand>
for LidarrManualSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrManualSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrManualSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrManualSearchCommand::Album {
artist_id,
album_id,
} => {
println!("Searching for album releases. This may take a minute...");
match self
.network
.handle_network_event(LidarrEvent::GetAlbumReleases(artist_id, album_id).into())
.await
{
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(releases_vec))) => {
let albums_vec: Vec<LidarrRelease> = releases_vec
.into_iter()
.filter(|release| !release.discography)
.collect();
serde_json::to_string_pretty(&albums_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
}
}
LidarrManualSearchCommand::Discography { artist_id } => {
println!("Searching for artist discography releases. This may take a minute...");
match self
.network
.handle_network_event(LidarrEvent::GetDiscographyReleases(artist_id).into())
.await
{
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(releases_vec))) => {
let discography_vec: Vec<LidarrRelease> = releases_vec
.into_iter()
.filter(|release| release.discography)
.collect();
serde_json::to_string_pretty(&discography_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
}
}
};
Ok(result)
}
}
@@ -0,0 +1,207 @@
#[cfg(test)]
mod tests {
use crate::cli::Command;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_manual_search_command_from() {
let command = LidarrManualSearchCommand::Discography { artist_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Lidarr(LidarrCommand::ManualSearch(command))
);
}
mod cli {
use crate::Cli;
use clap::CommandFactory;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_manual_album_search_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"album",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_album_search_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"album",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_album_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"album",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_manual_discography_search_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "manual-search", "discography"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_discography_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"discography",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use crate::app::App;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::manual_search_command_handler::{
LidarrManualSearchCommand, LidarrManualSearchCommandHandler,
};
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrRelease, LidarrSerdeable};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
torrent_release, usenet_release,
};
use crate::network::{MockNetworkTrait, NetworkEvent};
use mockall::predicate::eq;
use pretty_assertions::assert_str_eq;
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::test]
async fn test_manual_album_search_command() {
let expected_releases = [torrent_release()];
let expected_artist_id = 1;
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumReleases(expected_artist_id, expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(vec![
torrent_release(),
LidarrRelease {
discography: true,
..usenet_release()
},
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_album_search_command = LidarrManualSearchCommand::Album {
artist_id: 1,
album_id: 1,
};
let result = LidarrManualSearchCommandHandler::with(
&app_arc,
manual_album_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(
result.unwrap(),
serde_json::to_string_pretty(&expected_releases).unwrap()
);
}
#[tokio::test]
async fn test_manual_discography_search_command() {
let expected_releases = [LidarrRelease {
discography: true,
..usenet_release()
}];
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetDiscographyReleases(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(vec![
torrent_release(),
LidarrRelease {
discography: true,
..usenet_release()
},
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_discography_search_command =
LidarrManualSearchCommand::Discography { artist_id: 1 };
let result = LidarrManualSearchCommandHandler::with(
&app_arc,
manual_discography_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(
result.unwrap(),
serde_json::to_string_pretty(&expected_releases).unwrap()
);
}
}
}
+295
View File
@@ -0,0 +1,295 @@
use std::sync::Arc;
use add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use anyhow::Result;
use clap::{Subcommand, arg};
use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler};
use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler};
use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler};
use list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler};
use serde_json::json;
use tokio::sync::Mutex;
use trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
};
use super::{CliCommandHandler, Command};
use crate::cli::lidarr::manual_search_command_handler::{
LidarrManualSearchCommand, LidarrManualSearchCommandHandler,
};
use crate::models::lidarr_models::{LidarrReleaseDownloadBody, LidarrTaskName};
use crate::network::lidarr_network::LidarrEvent;
use crate::{app::App, network::NetworkTrait};
mod add_command_handler;
mod delete_command_handler;
mod edit_command_handler;
mod get_command_handler;
mod list_command_handler;
mod manual_search_command_handler;
mod refresh_command_handler;
mod trigger_automatic_search_command_handler;
#[cfg(test)]
#[path = "lidarr_command_tests.rs"]
mod lidarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrCommand {
#[command(
subcommand,
about = "Commands to add or create new resources within your Lidarr instance"
)]
Add(LidarrAddCommand),
#[command(
subcommand,
about = "Commands to delete resources from your Lidarr instance"
)]
Delete(LidarrDeleteCommand),
#[command(
subcommand,
about = "Commands to edit resources in your Lidarr instance"
)]
Edit(LidarrEditCommand),
#[command(
subcommand,
about = "Commands to fetch details of the resources in your Lidarr instance"
)]
Get(LidarrGetCommand),
#[command(
subcommand,
about = "Commands to list attributes from your Lidarr instance"
)]
List(LidarrListCommand),
#[command(
subcommand,
about = "Commands to refresh the data in your Lidarr instance"
)]
Refresh(LidarrRefreshCommand),
#[command(subcommand, about = "Commands to manually search for releases")]
ManualSearch(LidarrManualSearchCommand),
#[command(
subcommand,
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)]
guid: String,
#[arg(
long,
help = "The indexer ID to download the release from",
required = true
)]
indexer_id: i64,
},
#[command(about = "Mark the Lidarr history item with the given ID as 'failed'")]
MarkHistoryItemAsFailed {
#[arg(
long,
help = "The Lidarr ID of the history item you wish to mark as 'failed'",
required = true
)]
history_item_id: i64,
},
#[command(about = "Search for a new artist to add to Lidarr")]
SearchNewArtist {
#[arg(
long,
help = "The name of the artist you want to search for",
required = true
)]
query: String,
},
#[command(about = "Start the specified Lidarr task")]
StartTask {
#[arg(
long,
help = "The name of the task to trigger",
value_enum,
required = true
)]
task_name: LidarrTaskName,
},
#[command(
about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'"
)]
TestIndexer {
#[arg(long, help = "The ID of the indexer to test", required = true)]
indexer_id: i64,
},
#[command(about = "Test all Lidarr indexers")]
TestAllIndexers,
#[command(
about = "Toggle monitoring for the specified album corresponding to the given album ID"
)]
ToggleAlbumMonitoring {
#[arg(
long,
help = "The Lidarr ID of the album to toggle monitoring on",
required = true
)]
album_id: i64,
},
#[command(
about = "Toggle monitoring for the specified artist corresponding to the given artist ID"
)]
ToggleArtistMonitoring {
#[arg(
long,
help = "The Lidarr ID of the artist to toggle monitoring on",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrCommand> for Command {
fn from(lidarr_command: LidarrCommand) -> Command {
Command::Lidarr(lidarr_command)
}
}
pub(super) struct LidarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrCommand::Add(add_command) => {
LidarrAddCommandHandler::with(self.app, add_command, self.network)
.handle()
.await?
}
LidarrCommand::Delete(delete_command) => {
LidarrDeleteCommandHandler::with(self.app, delete_command, self.network)
.handle()
.await?
}
LidarrCommand::Edit(edit_command) => {
LidarrEditCommandHandler::with(self.app, edit_command, self.network)
.handle()
.await?
}
LidarrCommand::Get(get_command) => {
LidarrGetCommandHandler::with(self.app, get_command, self.network)
.handle()
.await?
}
LidarrCommand::List(list_command) => {
LidarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
.await?
}
LidarrCommand::Refresh(refresh_command) => {
LidarrRefreshCommandHandler::with(self.app, refresh_command, self.network)
.handle()
.await?
}
LidarrCommand::ManualSearch(manual_search_command) => {
LidarrManualSearchCommandHandler::with(self.app, manual_search_command, self.network)
.handle()
.await?
}
LidarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => {
LidarrTriggerAutomaticSearchCommandHandler::with(
self.app,
trigger_automatic_search_command,
self.network,
)
.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
.network
.handle_network_event(LidarrEvent::DownloadRelease(params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::MarkHistoryItemAsFailed { history_item_id } => {
let _ = self
.network
.handle_network_event(LidarrEvent::MarkHistoryItemAsFailed(history_item_id).into())
.await?;
serde_json::to_string_pretty(&json!({"message": "Lidarr history item marked as 'failed'"}))?
}
LidarrCommand::SearchNewArtist { query } => {
let resp = self
.network
.handle_network_event(LidarrEvent::SearchNewArtist(query).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::StartTask { task_name } => {
let resp = self
.network
.handle_network_event(LidarrEvent::StartTask(task_name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::TestIndexer { indexer_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TestIndexer(indexer_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::TestAllIndexers => {
println!("Testing all Lidarr indexers. This may take a minute...");
let resp = self
.network
.handle_network_event(LidarrEvent::TestAllIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::ToggleAlbumMonitoring { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::ToggleAlbumMonitoring(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::ToggleArtistMonitoring { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::ToggleArtistMonitoring(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::sync::Arc;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "refresh_command_handler_tests.rs"]
mod refresh_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrRefreshCommand {
#[command(about = "Refresh all artist data for all artists in your Lidarr library")]
AllArtists,
#[command(about = "Refresh artist data and scan disk for the artist with the given ID")]
Artist {
#[arg(
long,
help = "The ID of the artist to refresh information on and to scan the disk for",
required = true
)]
artist_id: i64,
},
#[command(about = "Refresh all downloads in Lidarr")]
Downloads,
}
impl From<LidarrRefreshCommand> for Command {
fn from(value: LidarrRefreshCommand) -> Self {
Command::Lidarr(LidarrCommand::Refresh(value))
}
}
pub(super) struct LidarrRefreshCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrRefreshCommand>
for LidarrRefreshCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrRefreshCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> anyhow::Result<String> {
let result = match self.command {
LidarrRefreshCommand::AllArtists => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateAllArtists.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrRefreshCommand::Artist { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateAndScanArtist(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrRefreshCommand::Downloads => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateDownloads.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,138 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, refresh_command_handler::LidarrRefreshCommand},
};
use clap::CommandFactory;
#[test]
fn test_lidarr_refresh_command_from() {
let command = LidarrRefreshCommand::AllArtists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Refresh(command)));
}
mod cli {
use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_refresh_commands_have_no_arg_requirements(
#[values("all-artists", "downloads")] subcommand: &str,
) {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", subcommand]);
assert_ok!(&result);
}
#[test]
fn test_refresh_artist_requires_artist_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_refresh_artist_with_artist_id() {
let expected_args = LidarrRefreshCommand::Artist { artist_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"refresh",
"artist",
"--artist-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Refresh(refresh_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(refresh_command, expected_args);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{app::App, cli::lidarr::refresh_command_handler::LidarrRefreshCommandHandler};
use crate::{
cli::{CliCommandHandler, lidarr::refresh_command_handler::LidarrRefreshCommand},
network::lidarr_network::LidarrEvent,
};
use crate::{
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(LidarrRefreshCommand::AllArtists, LidarrEvent::UpdateAllArtists)]
#[case(LidarrRefreshCommand::Downloads, LidarrEvent::UpdateDownloads)]
#[tokio::test]
async fn test_handle_refresh_command(
#[case] refresh_command: LidarrRefreshCommand,
#[case] expected_sonarr_event: LidarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_sonarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_refresh_artist_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::UpdateAndScanArtist(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_command = LidarrRefreshCommand::Artist { artist_id: 1 };
let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
@@ -0,0 +1,88 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "trigger_automatic_search_command_handler_tests.rs"]
mod trigger_automatic_search_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrTriggerAutomaticSearchCommand {
#[command(about = "Trigger an automatic search for the album with the specified ID")]
Album {
#[arg(
long,
help = "The Lidarr ID of the album you want to trigger an automatic search for",
required = true
)]
album_id: i64,
},
#[command(about = "Trigger an automatic search for the artist with the specified ID")]
Artist {
#[arg(
long,
help = "The ID of the artist you want to trigger an automatic search for",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrTriggerAutomaticSearchCommand> for Command {
fn from(value: LidarrTriggerAutomaticSearchCommand) -> Self {
Command::Lidarr(LidarrCommand::TriggerAutomaticSearch(value))
}
}
pub(super) struct LidarrTriggerAutomaticSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrTriggerAutomaticSearchCommand>
for LidarrTriggerAutomaticSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrTriggerAutomaticSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrTriggerAutomaticSearchCommand::Album { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TriggerAutomaticAlbumSearch(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrTriggerAutomaticSearchCommand::Artist { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TriggerAutomaticArtistSearch(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,166 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::Cli;
use crate::cli::{
Command,
lidarr::{
LidarrCommand, trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand,
},
};
use clap::CommandFactory;
#[test]
fn test_lidarr_trigger_automatic_search_command_from() {
let command = LidarrTriggerAutomaticSearchCommand::Artist { artist_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Lidarr(LidarrCommand::TriggerAutomaticSearch(command))
);
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_trigger_automatic_album_search_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"album",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_album_search_with_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"album",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_trigger_automatic_artist_search_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"artist",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_artist_search_with_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"artist",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
};
use crate::{app::App, cli::CliCommandHandler};
use crate::{
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_trigger_automatic_album_search_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticAlbumSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrTriggerAutomaticSearchCommand::Album { album_id: 1 };
let result = LidarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_trigger_automatic_artist_search_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticArtistSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrTriggerAutomaticSearchCommand::Artist { artist_id: 1 };
let result = LidarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
}
}
+46 -22
View File
@@ -1,14 +1,19 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{command, Subcommand};
use clap::{Subcommand, command};
use clap_complete::Shell;
use indoc::indoc;
use lidarr::{LidarrCliHandler, LidarrCommand};
use radarr::{RadarrCliHandler, RadarrCommand};
use sonarr::{SonarrCliHandler, SonarrCommand};
use tokio::sync::Mutex;
use crate::{app::App, network::NetworkTrait};
pub mod lidarr;
pub mod radarr;
pub mod sonarr;
#[cfg(test)]
#[path = "cli_tests.rs"]
@@ -19,6 +24,12 @@ pub enum Command {
#[command(subcommand, about = "Commands for manging your Radarr instance")]
Radarr(RadarrCommand),
#[command(subcommand, about = "Commands for manging your Sonarr instance")]
Sonarr(SonarrCommand),
#[command(subcommand, about = "Commands for manging your Lidarr instance")]
Lidarr(LidarrCommand),
#[command(
arg_required_else_help = true,
about = "Generate shell completions for the Managarr CLI"
@@ -27,24 +38,50 @@ pub enum Command {
#[arg(value_enum)]
shell: Shell,
},
#[command(about = "Tail Managarr logs")]
TailLogs {
#[arg(long, help = "Disable colored log output")]
no_color: bool,
},
#[command(about = indoc!{"
Print the full path to the default configuration file.
This file can be changed to another location using the '--config-file' flag
"})]
ConfigPath,
}
pub trait CliCommandHandler<'a, 'b, T: Into<Command>> {
fn with(app: &'a Arc<Mutex<App<'b>>>, command: T, network: &'a mut dyn NetworkTrait) -> Self;
async fn handle(self) -> Result<()>;
async fn handle(self) -> Result<String>;
}
pub(crate) async fn handle_command(
app: &Arc<Mutex<App<'_>>>,
command: Command,
network: &mut dyn NetworkTrait,
) -> Result<()> {
if let Command::Radarr(radarr_command) = command {
RadarrCliHandler::with(app, radarr_command, network)
.handle()
.await?
}
Ok(())
) -> Result<String> {
let result = match command {
Command::Radarr(radarr_command) => {
RadarrCliHandler::with(app, radarr_command, network)
.handle()
.await?
}
Command::Sonarr(sonarr_command) => {
SonarrCliHandler::with(app, sonarr_command, network)
.handle()
.await?
}
Command::Lidarr(lidarr_command) => {
LidarrCliHandler::with(app, lidarr_command, network)
.handle()
.await?
}
_ => String::new(),
};
Ok(result)
}
#[inline]
@@ -68,16 +105,3 @@ pub fn mutex_flags_or_default(positive: bool, negative: bool, default_value: boo
default_value
}
}
#[macro_export]
macro_rules! execute_network_event {
($self:ident, $event:expr) => {
let resp = $self.network.handle_network_event($event.into()).await?;
let json = serde_json::to_string_pretty(&resp)?;
println!("{}", json);
};
($self:ident, $event:expr, $happy_output:expr) => {
$self.network.handle_network_event($event.into()).await?;
println!("{}", $happy_output);
};
}
+34 -22
View File
@@ -1,19 +1,18 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{arg, command, ArgAction, Subcommand};
use clap::{ArgAction, Subcommand, arg, command};
use tokio::sync::Mutex;
use super::RadarrCommand;
use crate::models::servarr_models::AddRootFolderBody;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
models::radarr_models::{AddMovieBody, AddOptions, MinimumAvailability, Monitor},
network::{radarr_network::RadarrEvent, NetworkTrait},
models::radarr_models::{AddMovieBody, AddMovieOptions, MinimumAvailability, MovieMonitor},
network::{NetworkTrait, radarr_network::RadarrEvent},
};
use super::RadarrCommand;
#[cfg(test)]
#[path = "add_command_handler_tests.rs"]
mod add_command_handler_tests;
@@ -47,7 +46,7 @@ pub enum RadarrAddCommand {
default_value_t = MinimumAvailability::default()
)]
minimum_availability: MinimumAvailability,
#[arg(long, help = "Should Radarr monitor this film")]
#[arg(long, help = "Disable monitoring for this film")]
disable_monitoring: bool,
#[arg(
long,
@@ -60,9 +59,9 @@ pub enum RadarrAddCommand {
long,
help = "What Radarr should monitor",
value_enum,
default_value_t = Monitor::default()
default_value_t = MovieMonitor::default()
)]
monitor: Monitor,
monitor: MovieMonitor,
#[arg(
long,
help = "Tell Radarr to not start a search for this film once it's added to your library"
@@ -106,8 +105,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrAddCommand::Movie {
tmdb_id,
root_folder_path,
@@ -123,27 +122,40 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan
title: String::new(),
root_folder_path,
quality_profile_id,
minimum_availability: minimum_availability.to_string(),
minimum_availability,
monitored: !disable_monitoring,
tags,
add_options: AddOptions {
monitor: monitor.to_string(),
tag_input_string: None,
add_options: AddMovieOptions {
monitor,
search_for_movie: !no_search_for_movie,
},
};
execute_network_event!(self, RadarrEvent::AddMovie(Some(body)));
let resp = self
.network
.handle_network_event(RadarrEvent::AddMovie(body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrAddCommand::RootFolder { root_folder_path } => {
execute_network_event!(
self,
RadarrEvent::AddRootFolder(Some(root_folder_path.clone()))
);
let add_root_folder_body = AddRootFolderBody {
path: root_folder_path,
};
let resp = self
.network
.handle_network_event(RadarrEvent::AddRootFolder(add_root_folder_body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrAddCommand::Tag { name } => {
execute_network_event!(self, RadarrEvent::AddTag(name.clone()));
let resp = self
.network
.handle_network_event(RadarrEvent::AddTag(name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}
};
Ok(())
Ok(result)
}
}
+67 -54
View File
@@ -1,15 +1,16 @@
#[cfg(test)]
mod tests {
use clap::{error::ErrorKind, CommandFactory, Parser};
use clap::{CommandFactory, Parser, error::ErrorKind};
use crate::{
cli::{
radarr::{add_command_handler::RadarrAddCommand, RadarrCommand},
Command,
},
models::radarr_models::{MinimumAvailability, Monitor},
Cli,
cli::{
Command,
radarr::{RadarrCommand, add_command_handler::RadarrAddCommand},
},
models::radarr_models::{MinimumAvailability, MovieMonitor},
};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_add_command_from() {
@@ -31,7 +32,7 @@ mod tests {
fn test_add_movie_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "add", "movie"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -51,7 +52,7 @@ mod tests {
"1",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -71,7 +72,7 @@ mod tests {
"/test",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -91,7 +92,7 @@ mod tests {
"1",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -111,10 +112,12 @@ mod tests {
"/test",
"--quality-profile-id",
"1",
"--tmdb-id",
"1",
flag,
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -133,7 +136,7 @@ mod tests {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
@@ -153,7 +156,7 @@ mod tests {
"test",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -174,7 +177,7 @@ mod tests {
"test",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -187,7 +190,7 @@ mod tests {
minimum_availability: MinimumAvailability::default(),
disable_monitoring: false,
tag: vec![],
monitor: Monitor::default(),
monitor: MovieMonitor::default(),
no_search_for_movie: false,
};
@@ -204,10 +207,11 @@ mod tests {
"1",
]);
assert!(result.is_ok());
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
assert_ok!(&result);
let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
@@ -219,7 +223,7 @@ mod tests {
minimum_availability: MinimumAvailability::default(),
disable_monitoring: false,
tag: vec![1, 2],
monitor: Monitor::default(),
monitor: MovieMonitor::default(),
no_search_for_movie: false,
};
@@ -240,10 +244,11 @@ mod tests {
"2",
]);
assert!(result.is_ok());
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
assert_ok!(&result);
let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
@@ -255,7 +260,7 @@ mod tests {
minimum_availability: MinimumAvailability::Released,
disable_monitoring: true,
tag: vec![1, 2],
monitor: Monitor::MovieAndCollection,
monitor: MovieMonitor::MovieAndCollection,
no_search_for_movie: true,
};
@@ -282,10 +287,11 @@ mod tests {
"--no-search-for-movie",
]);
assert!(result.is_ok());
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
assert_ok!(&result);
let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
@@ -293,7 +299,7 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "add", "root-folder"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -315,18 +321,19 @@ mod tests {
"/nfs/test",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "add", "tag"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -341,11 +348,12 @@ mod tests {
let result = Cli::try_parse_from(["managarr", "radarr", "add", "tag", "--name", "test"]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
}
@@ -354,17 +362,18 @@ mod tests {
use crate::{
app::App,
cli::{radarr::add_command_handler::RadarrAddCommandHandler, CliCommandHandler},
cli::{CliCommandHandler, radarr::add_command_handler::RadarrAddCommandHandler},
models::{
radarr_models::{AddMovieBody, AddOptions, RadarrSerdeable},
Serdeable,
radarr_models::{AddMovieBody, AddMovieOptions, RadarrSerdeable},
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
};
use super::*;
use mockall::predicate::eq;
use crate::models::servarr_models::AddRootFolderBody;
use serde_json::json;
use tokio::sync::Mutex;
@@ -375,11 +384,12 @@ mod tests {
title: String::new(),
root_folder_path: "/test".to_owned(),
quality_profile_id: 1,
minimum_availability: "released".to_owned(),
minimum_availability: MinimumAvailability::Released,
monitored: false,
tags: vec![1, 2],
add_options: AddOptions {
monitor: "movieAndCollection".to_owned(),
tag_input_string: None,
add_options: AddMovieOptions {
monitor: MovieMonitor::MovieAndCollection,
search_for_movie: false,
},
};
@@ -387,7 +397,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::AddMovie(Some(expected_add_movie_body)).into(),
RadarrEvent::AddMovie(expected_add_movie_body).into(),
))
.times(1)
.returning(|_| {
@@ -395,7 +405,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_movie_command = RadarrAddCommand::Movie {
tmdb_id: 1,
root_folder_path: "/test".to_owned(),
@@ -403,7 +413,7 @@ mod tests {
minimum_availability: MinimumAvailability::Released,
disable_monitoring: true,
tag: vec![1, 2],
monitor: Monitor::MovieAndCollection,
monitor: MovieMonitor::MovieAndCollection,
no_search_for_movie: true,
};
@@ -411,17 +421,20 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_add_root_folder_command() {
let expected_root_folder_path = "/nfs/test".to_owned();
let expected_add_root_folder_body = AddRootFolderBody {
path: expected_root_folder_path.clone(),
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::AddRootFolder(Some(expected_root_folder_path.clone())).into(),
RadarrEvent::AddRootFolder(expected_add_root_folder_body).into(),
))
.times(1)
.returning(|_| {
@@ -429,7 +442,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_root_folder_command = RadarrAddCommand::RootFolder {
root_folder_path: expected_root_folder_path,
};
@@ -439,7 +452,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -457,7 +470,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = RadarrAddCommand::Tag {
name: expected_tag_name,
};
@@ -466,7 +479,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
}
}
+35 -15
View File
@@ -7,9 +7,8 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
models::radarr_models::DeleteMovieParams,
network::{radarr_network::RadarrEvent, NetworkTrait},
network::{NetworkTrait, radarr_network::RadarrEvent},
};
use super::RadarrCommand;
@@ -85,19 +84,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrDeleteCommand::BlocklistItem { blocklist_item_id } => {
execute_network_event!(
self,
RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id))
);
let resp = self
.network
.handle_network_event(RadarrEvent::DeleteBlocklistItem(blocklist_item_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrDeleteCommand::Download { download_id } => {
execute_network_event!(self, RadarrEvent::DeleteDownload(Some(download_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::DeleteDownload(download_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrDeleteCommand::Indexer { indexer_id } => {
execute_network_event!(self, RadarrEvent::DeleteIndexer(Some(indexer_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::DeleteIndexer(indexer_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrDeleteCommand::Movie {
movie_id,
@@ -109,16 +117,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm
delete_movie_files: delete_files_from_disk,
add_list_exclusion,
};
execute_network_event!(self, RadarrEvent::DeleteMovie(Some(delete_movie_params)));
let resp = self
.network
.handle_network_event(RadarrEvent::DeleteMovie(delete_movie_params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrDeleteCommand::RootFolder { root_folder_id } => {
execute_network_event!(self, RadarrEvent::DeleteRootFolder(Some(root_folder_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::DeleteRootFolder(root_folder_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrDeleteCommand::Tag { tag_id } => {
execute_network_event!(self, RadarrEvent::DeleteTag(tag_id));
let resp = self
.network
.handle_network_event(RadarrEvent::DeleteTag(tag_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}
};
Ok(())
Ok(result)
}
}
+74 -66
View File
@@ -1,13 +1,14 @@
#[cfg(test)]
mod tests {
use crate::{
cli::{
radarr::{delete_command_handler::RadarrDeleteCommand, RadarrCommand},
Command,
},
Cli,
cli::{
Command,
radarr::{RadarrCommand, delete_command_handler::RadarrDeleteCommand},
},
};
use clap::{error::ErrorKind, CommandFactory, Parser};
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_delete_command_from() {
@@ -29,7 +30,7 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "blocklist-item"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -51,12 +52,13 @@ mod tests {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
@@ -64,7 +66,7 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "download"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -84,19 +86,20 @@ mod tests {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "indexer"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -116,19 +119,20 @@ mod tests {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_movie_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "movie"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -146,12 +150,13 @@ mod tests {
let result =
Cli::try_parse_from(["managarr", "radarr", "delete", "movie", "--movie-id", "1"]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
@@ -173,12 +178,13 @@ mod tests {
"--add-list-exclusion",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
@@ -186,7 +192,7 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "root-folder"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -206,19 +212,20 @@ mod tests {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "tag"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -231,12 +238,13 @@ mod tests {
let result = Cli::try_parse_from(["managarr", "radarr", "delete", "tag", "--tag-id", "1"]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
}
@@ -250,14 +258,14 @@ mod tests {
use crate::{
app::App,
cli::{
radarr::delete_command_handler::{RadarrDeleteCommand, RadarrDeleteCommandHandler},
CliCommandHandler,
radarr::delete_command_handler::{RadarrDeleteCommand, RadarrDeleteCommandHandler},
},
models::{
radarr_models::{DeleteMovieParams, RadarrSerdeable},
Serdeable,
radarr_models::{DeleteMovieParams, RadarrSerdeable},
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
};
#[tokio::test]
@@ -267,7 +275,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(),
RadarrEvent::DeleteBlocklistItem(expected_blocklist_item_id).into(),
))
.times(1)
.returning(|_| {
@@ -275,7 +283,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_blocklist_item_command = RadarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
@@ -288,7 +296,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -298,7 +306,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::DeleteDownload(Some(expected_download_id)).into(),
RadarrEvent::DeleteDownload(expected_download_id).into(),
))
.times(1)
.returning(|_| {
@@ -306,7 +314,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_download_command = RadarrDeleteCommand::Download { download_id: 1 };
let result =
@@ -314,7 +322,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -324,7 +332,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::DeleteIndexer(Some(expected_indexer_id)).into(),
RadarrEvent::DeleteIndexer(expected_indexer_id).into(),
))
.times(1)
.returning(|_| {
@@ -332,7 +340,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_indexer_command = RadarrDeleteCommand::Indexer { indexer_id: 1 };
let result =
@@ -340,7 +348,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -354,7 +362,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::DeleteMovie(Some(expected_delete_movie_params)).into(),
RadarrEvent::DeleteMovie(expected_delete_movie_params).into(),
))
.times(1)
.returning(|_| {
@@ -362,7 +370,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_movie_command = RadarrDeleteCommand::Movie {
movie_id: 1,
delete_files_from_disk: true,
@@ -374,7 +382,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -384,7 +392,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::DeleteRootFolder(Some(expected_root_folder_id)).into(),
RadarrEvent::DeleteRootFolder(expected_root_folder_id).into(),
))
.times(1)
.returning(|_| {
@@ -392,7 +400,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_root_folder_command = RadarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result =
@@ -400,7 +408,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -418,7 +426,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_tag_command = RadarrDeleteCommand::Tag { tag_id: 1 };
let result =
@@ -426,7 +434,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
}
}
+35 -38
View File
@@ -6,16 +6,15 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{mutex_flags_or_default, mutex_flags_or_option, CliCommandHandler, Command},
execute_network_event,
cli::{CliCommandHandler, Command, mutex_flags_or_default, mutex_flags_or_option},
models::{
radarr_models::{
EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings,
MinimumAvailability, RadarrSerdeable,
},
Serdeable,
radarr_models::{
EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, RadarrSerdeable,
},
servarr_models::EditIndexerParams,
},
network::{radarr_network::RadarrEvent, NetworkTrait},
network::{NetworkTrait, radarr_network::RadarrEvent},
};
use super::RadarrCommand;
@@ -339,8 +338,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs,
disable_allow_hardcoded_subs,
@@ -380,20 +379,16 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
rss_sync_interval: rss_sync_interval
.unwrap_or(previous_indexer_settings.rss_sync_interval),
whitelisted_hardcoded_subs: whitelisted_subtitle_tags
.clone()
.unwrap_or_else(|| {
previous_indexer_settings
.whitelisted_hardcoded_subs
.text
.clone()
})
.unwrap_or(previous_indexer_settings.whitelisted_hardcoded_subs.text)
.into(),
};
execute_network_event!(
self,
RadarrEvent::EditAllIndexerSettings(Some(params)),
"All indexer settings updated"
);
self
.network
.handle_network_event(RadarrEvent::EditAllIndexerSettings(params).into())
.await?;
"All indexer settings updated".to_owned()
} else {
String::new()
}
}
RadarrEditCommand::Collection {
@@ -417,11 +412,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
root_folder_path,
search_on_add: search_on_add_value,
};
execute_network_event!(
self,
RadarrEvent::EditCollection(Some(edit_collection_params)),
"Collection Updated"
);
self
.network
.handle_network_event(RadarrEvent::EditCollection(edit_collection_params).into())
.await?;
"Collection updated".to_owned()
}
RadarrEditCommand::Indexer {
indexer_id,
@@ -454,15 +449,16 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
api_key,
seed_ratio,
tags: tag,
tag_input_string: None,
priority,
clear_tags,
};
execute_network_event!(
self,
RadarrEvent::EditIndexer(Some(edit_indexer_params)),
"Indexer updated"
);
self
.network
.handle_network_event(RadarrEvent::EditIndexer(edit_indexer_params).into())
.await?;
"Indexer updated".to_owned()
}
RadarrEditCommand::Movie {
movie_id,
@@ -482,17 +478,18 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
quality_profile_id,
root_folder_path,
tags: tag,
tag_input_string: None,
clear_tags,
};
execute_network_event!(
self,
RadarrEvent::EditMovie(Some(edit_movie_params)),
"Movie updated"
);
self
.network
.handle_network_event(RadarrEvent::EditMovie(edit_movie_params).into())
.await?;
"Movie Updated".to_owned()
}
}
};
Ok(())
Ok(result)
}
}
+130 -112
View File
@@ -1,13 +1,14 @@
#[cfg(test)]
mod tests {
use crate::{
cli::{
radarr::{edit_command_handler::RadarrEditCommand, RadarrCommand},
Command,
},
Cli,
cli::{
Command,
radarr::{RadarrCommand, edit_command_handler::RadarrEditCommand},
},
};
use clap::{error::ErrorKind, CommandFactory, Parser};
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_edit_command_from() {
@@ -41,7 +42,7 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "all-indexer-settings"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -59,7 +60,7 @@ mod tests {
"--disable-allow-hardcoded-subs",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -74,7 +75,7 @@ mod tests {
"--disable-prefer-indexer-flags",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -98,7 +99,7 @@ mod tests {
flag,
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -125,11 +126,12 @@ mod tests {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -167,11 +169,12 @@ mod tests {
"test",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -179,7 +182,7 @@ mod tests {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "collection"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -197,7 +200,7 @@ mod tests {
"1",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -217,7 +220,7 @@ mod tests {
"--disable-monitoring",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -234,7 +237,7 @@ mod tests {
"--disable-search-on-add",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -251,7 +254,7 @@ mod tests {
"test",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -269,7 +272,7 @@ mod tests {
flag,
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -297,11 +300,12 @@ mod tests {
"/test",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -333,18 +337,19 @@ mod tests {
"--search-on-add",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "indexer"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -362,7 +367,7 @@ mod tests {
"1",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -382,7 +387,7 @@ mod tests {
"--disable-rss",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -399,7 +404,7 @@ mod tests {
"--disable-automatic-search",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -416,7 +421,7 @@ mod tests {
"--disable-interactive-search",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -434,7 +439,7 @@ mod tests {
"--clear-tags",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -452,7 +457,7 @@ mod tests {
flag,
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -486,11 +491,12 @@ mod tests {
"Test",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -525,11 +531,12 @@ mod tests {
"2",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -577,18 +584,19 @@ mod tests {
"25",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_movie_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "movie"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -606,7 +614,7 @@ mod tests {
"1",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -626,7 +634,7 @@ mod tests {
"--disable-monitoring",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -644,7 +652,7 @@ mod tests {
"--clear-tags",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
@@ -668,7 +676,7 @@ mod tests {
flag,
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -685,7 +693,7 @@ mod tests {
"test",
]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
@@ -713,11 +721,12 @@ mod tests {
"/nfs/test",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -746,11 +755,12 @@ mod tests {
"2",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
@@ -786,11 +796,12 @@ mod tests {
"2",
]);
assert!(result.is_ok());
assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
}
@@ -804,17 +815,18 @@ mod tests {
use crate::{
app::App,
cli::{
radarr::edit_command_handler::{RadarrEditCommand, RadarrEditCommandHandler},
CliCommandHandler,
radarr::edit_command_handler::{RadarrEditCommand, RadarrEditCommandHandler},
},
models::{
radarr_models::{
EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings,
MinimumAvailability, RadarrSerdeable,
},
Serdeable,
radarr_models::{
EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability,
RadarrSerdeable,
},
servarr_models::EditIndexerParams,
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
};
#[tokio::test]
@@ -855,7 +867,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(),
RadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
@@ -863,7 +875,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: true,
disable_allow_hardcoded_subs: false,
@@ -885,7 +897,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -926,7 +938,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(),
RadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
@@ -934,7 +946,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: false,
disable_allow_hardcoded_subs: true,
@@ -956,12 +968,12 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_all_indexer_settings_command_unprovided_values_default_to_previous_values(
) {
async fn test_handle_edit_all_indexer_settings_command_unprovided_values_default_to_previous_values()
{
let expected_edit_all_indexer_settings = IndexerSettings {
allow_hardcoded_subs: true,
availability_delay: 2,
@@ -998,7 +1010,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(),
RadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
@@ -1006,7 +1018,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: false,
disable_allow_hardcoded_subs: false,
@@ -1028,7 +1040,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1045,7 +1057,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditCollection(Some(expected_edit_collection_params)).into(),
RadarrEvent::EditCollection(expected_edit_collection_params).into(),
))
.times(1)
.returning(|_| {
@@ -1053,7 +1065,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_collection_command = RadarrEditCommand::Collection {
collection_id: 1,
enable_monitoring: true,
@@ -1070,7 +1082,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1087,7 +1099,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditCollection(Some(expected_edit_collection_params)).into(),
RadarrEvent::EditCollection(expected_edit_collection_params).into(),
))
.times(1)
.returning(|_| {
@@ -1095,7 +1107,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_collection_command = RadarrEditCommand::Collection {
collection_id: 1,
enable_monitoring: false,
@@ -1112,7 +1124,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1129,7 +1141,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditCollection(Some(expected_edit_collection_params)).into(),
RadarrEvent::EditCollection(expected_edit_collection_params).into(),
))
.times(1)
.returning(|_| {
@@ -1137,7 +1149,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_collection_command = RadarrEditCommand::Collection {
collection_id: 1,
enable_monitoring: false,
@@ -1154,7 +1166,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1169,6 +1181,7 @@ mod tests {
api_key: Some("testKey".to_owned()),
seed_ratio: Some("1.2".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
priority: Some(25),
clear_tags: false,
};
@@ -1176,7 +1189,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditIndexer(Some(expected_edit_indexer_params)).into(),
RadarrEvent::EditIndexer(expected_edit_indexer_params).into(),
))
.times(1)
.returning(|_| {
@@ -1184,7 +1197,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = RadarrEditCommand::Indexer {
indexer_id: 1,
name: Some("Test".to_owned()),
@@ -1207,7 +1220,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1222,6 +1235,7 @@ mod tests {
api_key: Some("testKey".to_owned()),
seed_ratio: Some("1.2".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
priority: Some(25),
clear_tags: false,
};
@@ -1229,7 +1243,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditIndexer(Some(expected_edit_indexer_params)).into(),
RadarrEvent::EditIndexer(expected_edit_indexer_params).into(),
))
.times(1)
.returning(|_| {
@@ -1237,7 +1251,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = RadarrEditCommand::Indexer {
indexer_id: 1,
name: Some("Test".to_owned()),
@@ -1260,7 +1274,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1275,6 +1289,7 @@ mod tests {
api_key: Some("testKey".to_owned()),
seed_ratio: Some("1.2".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
priority: Some(25),
clear_tags: false,
};
@@ -1282,7 +1297,7 @@ mod tests {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditIndexer(Some(expected_edit_indexer_params)).into(),
RadarrEvent::EditIndexer(expected_edit_indexer_params).into(),
))
.times(1)
.returning(|_| {
@@ -1290,7 +1305,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = RadarrEditCommand::Indexer {
indexer_id: 1,
name: Some("Test".to_owned()),
@@ -1313,7 +1328,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1325,13 +1340,14 @@ mod tests {
quality_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditMovie(Some(expected_edit_movie_params)).into(),
RadarrEvent::EditMovie(expected_edit_movie_params).into(),
))
.times(1)
.returning(|_| {
@@ -1339,7 +1355,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_movie_command = RadarrEditCommand::Movie {
movie_id: 1,
enable_monitoring: true,
@@ -1355,7 +1371,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1367,13 +1383,14 @@ mod tests {
quality_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditMovie(Some(expected_edit_movie_params)).into(),
RadarrEvent::EditMovie(expected_edit_movie_params).into(),
))
.times(1)
.returning(|_| {
@@ -1381,7 +1398,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_movie_command = RadarrEditCommand::Movie {
movie_id: 1,
enable_monitoring: false,
@@ -1397,7 +1414,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -1409,13 +1426,14 @@ mod tests {
quality_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::EditMovie(Some(expected_edit_movie_params)).into(),
RadarrEvent::EditMovie(expected_edit_movie_params).into(),
))
.times(1)
.returning(|_| {
@@ -1423,7 +1441,7 @@ mod tests {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_movie_command = RadarrEditCommand::Movie {
movie_id: 1,
enable_monitoring: false,
@@ -1439,7 +1457,7 @@ mod tests {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
}
}
+36 -13
View File
@@ -1,14 +1,13 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{command, Subcommand};
use clap::{Subcommand, command};
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{radarr_network::RadarrEvent, NetworkTrait},
network::{NetworkTrait, radarr_network::RadarrEvent},
};
use super::RadarrCommand;
@@ -72,28 +71,52 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrGetCommand> for RadarrGetCommandHan
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrGetCommand::AllIndexerSettings => {
execute_network_event!(self, RadarrEvent::GetAllIndexerSettings);
let resp = self
.network
.handle_network_event(RadarrEvent::GetAllIndexerSettings.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrGetCommand::HostConfig => {
execute_network_event!(self, RadarrEvent::GetHostConfig);
let resp = self
.network
.handle_network_event(RadarrEvent::GetHostConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrGetCommand::MovieDetails { movie_id } => {
execute_network_event!(self, RadarrEvent::GetMovieDetails(Some(movie_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::GetMovieDetails(movie_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrGetCommand::MovieHistory { movie_id } => {
execute_network_event!(self, RadarrEvent::GetMovieHistory(Some(movie_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::GetMovieHistory(movie_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrGetCommand::SecurityConfig => {
execute_network_event!(self, RadarrEvent::GetSecurityConfig);
let resp = self
.network
.handle_network_event(RadarrEvent::GetSecurityConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrGetCommand::SystemStatus => {
execute_network_event!(self, RadarrEvent::GetStatus);
let resp = self
.network
.handle_network_event(RadarrEvent::GetStatus.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}
};
Ok(())
Ok(result)
}
}
+31 -30
View File
@@ -1,12 +1,13 @@
#[cfg(test)]
mod test {
use clap::error::ErrorKind;
mod tests {
use clap::CommandFactory;
use clap::error::ErrorKind;
use crate::cli::radarr::get_command_handler::RadarrGetCommand;
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli;
use crate::cli::Command;
use crate::cli::radarr::RadarrCommand;
use crate::cli::radarr::get_command_handler::RadarrGetCommand;
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_get_command_from() {
@@ -26,7 +27,7 @@ mod test {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "all-indexer-settings"]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
@@ -34,7 +35,7 @@ mod test {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "host-config"]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
@@ -42,7 +43,7 @@ mod test {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "movie-details"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -60,7 +61,7 @@ mod test {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
@@ -68,7 +69,7 @@ mod test {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "movie-history"]);
assert!(result.is_err());
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
@@ -86,7 +87,7 @@ mod test {
"1",
]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
@@ -94,7 +95,7 @@ mod test {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "security-config"]);
assert!(result.is_ok());
assert_ok!(&result);
}
#[test]
@@ -102,7 +103,7 @@ mod test {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]);
assert!(result.is_ok());
assert_ok!(&result);
}
}
@@ -116,11 +117,11 @@ mod test {
use crate::{
app::App,
cli::{
radarr::get_command_handler::{RadarrGetCommand, RadarrGetCommandHandler},
CliCommandHandler,
radarr::get_command_handler::{RadarrGetCommand, RadarrGetCommandHandler},
},
models::{radarr_models::RadarrSerdeable, Serdeable},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
models::{Serdeable, radarr_models::RadarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
};
#[tokio::test]
@@ -137,7 +138,7 @@ mod test {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_all_indexer_settings_command = RadarrGetCommand::AllIndexerSettings;
let result = RadarrGetCommandHandler::with(
@@ -148,7 +149,7 @@ mod test {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -163,7 +164,7 @@ mod test {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_host_config_command = RadarrGetCommand::HostConfig;
let result =
@@ -171,7 +172,7 @@ mod test {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -181,7 +182,7 @@ mod test {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::GetMovieDetails(Some(expected_movie_id)).into(),
RadarrEvent::GetMovieDetails(expected_movie_id).into(),
))
.times(1)
.returning(|_| {
@@ -189,7 +190,7 @@ mod test {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_movie_details_command = RadarrGetCommand::MovieDetails { movie_id: 1 };
let result =
@@ -197,7 +198,7 @@ mod test {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -207,7 +208,7 @@ mod test {
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::GetMovieHistory(Some(expected_movie_id)).into(),
RadarrEvent::GetMovieHistory(expected_movie_id).into(),
))
.times(1)
.returning(|_| {
@@ -215,7 +216,7 @@ mod test {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_movie_history_command = RadarrGetCommand::MovieHistory { movie_id: 1 };
let result =
@@ -223,7 +224,7 @@ mod test {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -238,7 +239,7 @@ mod test {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_security_config_command = RadarrGetCommand::SecurityConfig;
let result =
@@ -246,7 +247,7 @@ mod test {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
#[tokio::test]
@@ -261,7 +262,7 @@ mod test {
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = RadarrGetCommand::SystemStatus;
let result =
@@ -269,7 +270,7 @@ mod test {
.handle()
.await;
assert!(result.is_ok());
assert_ok!(&result);
}
}
}
+97 -28
View File
@@ -1,14 +1,13 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{command, Subcommand};
use clap::{Subcommand, command};
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{radarr_network::RadarrEvent, NetworkTrait},
network::{NetworkTrait, radarr_network::RadarrEvent},
};
use super::RadarrCommand;
@@ -24,7 +23,17 @@ pub enum RadarrListCommand {
#[command(about = "List all Radarr collections")]
Collections,
#[command(about = "List all active downloads in Radarr")]
Downloads,
Downloads {
#[arg(long, help = "How many downloads to fetch", default_value_t = 500)]
count: u64,
},
#[command(about = "List disk space details for all provisioned root folders in Radarr")]
DiskSpace,
#[command(about = "Fetch all Radarr history events")]
History {
#[arg(long, help = "How many history events to fetch", default_value_t = 500)]
events: u64,
},
#[command(about = "List all Radarr indexers")]
Indexers,
#[command(about = "Fetch Radarr logs")]
@@ -56,7 +65,7 @@ pub enum RadarrListCommand {
RootFolders,
#[command(about = "List all Radarr tags")]
Tags,
#[command(about = "List tasks")]
#[command(about = "List all Radarr tasks")]
Tasks,
#[command(about = "List all Radarr updates")]
Updates,
@@ -87,19 +96,49 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrListCommand::Blocklist => {
execute_network_event!(self, RadarrEvent::GetBlocklist);
let resp = self
.network
.handle_network_event(RadarrEvent::GetBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Collections => {
execute_network_event!(self, RadarrEvent::GetCollections);
let resp = self
.network
.handle_network_event(RadarrEvent::GetCollections.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Downloads => {
execute_network_event!(self, RadarrEvent::GetDownloads);
RadarrListCommand::Downloads { count } => {
let resp = self
.network
.handle_network_event(RadarrEvent::GetDownloads(count).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::DiskSpace => {
let resp = self
.network
.handle_network_event(RadarrEvent::GetDiskSpace.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::History { events: items } => {
let resp = self
.network
.handle_network_event(RadarrEvent::GetHistory(items).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Indexers => {
execute_network_event!(self, RadarrEvent::GetIndexers);
let resp = self
.network
.handle_network_event(RadarrEvent::GetIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Logs {
events,
@@ -107,45 +146,75 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH
} => {
let logs = self
.network
.handle_network_event(RadarrEvent::GetLogs(Some(events)).into())
.handle_network_event(RadarrEvent::GetLogs(events).into())
.await?;
if output_in_log_format {
let log_lines = self.app.lock().await.data.radarr_data.logs.items.clone();
let log_lines = &self.app.lock().await.data.radarr_data.logs.items;
let json = serde_json::to_string_pretty(&log_lines)?;
println!("{}", json);
serde_json::to_string_pretty(log_lines)?
} else {
let json = serde_json::to_string_pretty(&logs)?;
println!("{}", json);
serde_json::to_string_pretty(&logs)?
}
}
RadarrListCommand::Movies => {
execute_network_event!(self, RadarrEvent::GetMovies);
let resp = self
.network
.handle_network_event(RadarrEvent::GetMovies.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::MovieCredits { movie_id } => {
execute_network_event!(self, RadarrEvent::GetMovieCredits(Some(movie_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::GetMovieCredits(movie_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::QualityProfiles => {
execute_network_event!(self, RadarrEvent::GetQualityProfiles);
let resp = self
.network
.handle_network_event(RadarrEvent::GetQualityProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::QueuedEvents => {
execute_network_event!(self, RadarrEvent::GetQueuedEvents);
let resp = self
.network
.handle_network_event(RadarrEvent::GetQueuedEvents.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::RootFolders => {
execute_network_event!(self, RadarrEvent::GetRootFolders);
let resp = self
.network
.handle_network_event(RadarrEvent::GetRootFolders.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Tags => {
execute_network_event!(self, RadarrEvent::GetTags);
let resp = self
.network
.handle_network_event(RadarrEvent::GetTags.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Tasks => {
execute_network_event!(self, RadarrEvent::GetTasks);
let resp = self
.network
.handle_network_event(RadarrEvent::GetTasks.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Updates => {
execute_network_event!(self, RadarrEvent::GetUpdates);
let resp = self
.network
.handle_network_event(RadarrEvent::GetUpdates.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}
};
Ok(())
Ok(result)
}
}

Some files were not shown because too many files have changed in this diff Show More