Compare commits

...

202 Commits

Author SHA1 Message Date
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
1ff31b1bd9 Updated codecov config to also ignore the ui directory entirely, since it's not very conducive to tests as it is 2024-11-03 16:05:17 -07:00
ce4cbd8652 Updated codecov config to ignore test files 2024-11-03 16:01:49 -07:00
346d95f8ec Added Codecov config 2024-11-03 15:57:18 -07:00
85ea05e3c8 Incremented minor release since removing the --disable-terminal-size-checks flag is a breaking change, but the application is not yet ready for a 1.0 release. 2024-11-03 15:42:59 -07:00
93d78701ce fix:fixed divide by zero panic when download size is unknown 2024-11-03 15:36:26 -07:00
8d7cb63c7a Remove the terminal size checks since they've caused so many issues since their introduction 2024-11-03 15:33:08 -07:00
c8c7d00517 Added environment variables section to the README for added visibility into the feature 2024-11-03 15:06:24 -07:00
Alex Clarke
9402ad3f3b Merge pull request #9 from tangowithfoxtrot/add-env-var-config-options
Add environment variables for --config and terminal size check args
2024-11-03 14:43:50 -07:00
tangowithfoxtrot
ea9a9070ce Merge branch 'rc/v0.1.6-address-community-comments' into add-env-var-config-options 2024-11-03 13:40:53 -08:00
a0fe51c57b Added help that's always visible for modals with new shortcuts for accepting all modals, or closing all modals without the need of seeing the UI 2024-11-03 14:25:33 -07:00
tangowithfoxtrot
9326428141 feat: allow configuration via env vars 2024-11-03 11:20:15 -08:00
Alex Clarke
c1da8592b4 Merge pull request #7 from Dark-Alex-17/release-plz-2024-11-03T00-33-10Z
chore: release v0.1.5
2024-11-02 18:37:24 -06:00
github-actions[bot]
aa43219c29 chore: release v0.1.5 2024-11-03 00:33:11 +00:00
f6f477b124 Added HTTPS support for all Servarrs 2024-11-02 18:32:44 -06:00
Alex Clarke
76f22e7434 Merge pull request #5 from Dark-Alex-17/release-plz-2024-11-01T19-06-52Z
chore: release v0.1.4
2024-11-01 13:13:03 -06:00
github-actions[bot]
28aad8cd14 chore: release v0.1.4 2024-11-01 19:06:53 +00:00
97c8f8fc49 Added the ability to fetch host configs and security configs to the CLI 2024-11-01 13:02:39 -06:00
9da4ebfe11 Updated README to be more clear about what features are supported [skip ci] 2024-10-31 15:53:08 -06:00
139 changed files with 23005 additions and 2251 deletions
+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"
+101 -4
View File
@@ -1,24 +1,118 @@
# 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 to crates.io
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:
bump:
runs-on: ubuntu-latest
steps:
- name: Configure SSH for Git
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
uses: actions/checkout@v3
with:
ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Commitizen
run: |
python -m pip install --upgrade pip
pip install commitizen
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: Get the new version tag
id: version
run: |
NEW_TAG=$(cz version --project)
echo "New version: $NEW_TAG"
echo "version=$NEW_TAG" >> $GITHUB_ENV
- 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: Generate changelog for the version bump
id: changelog
run: |
changelog=$(conventional-changelog -p angular -i CHANGELOG.md -s --from ${{ env.prev_version }} --to ${{ env.version }})
echo "$changelog" > changelog.md
echo "changelog_body=$(cat changelog.md)" >> $GITHUB_ENV
- name: Create a GitHub Release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.version }}
name: "Release ${{ env.version }}"
body: ${{ env.changelog_body }}
draft: false
prerelease: false
- name: Push changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git tag v${{ env.version }}
git push origin --follow-tags
release-plz:
# see https://release-plz.ieni.dev/docs/github
# for more information
needs: bump
name: Release-plz
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout
uses: actions/checkout@v4
with:
@@ -27,6 +121,9 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: MarcoIeni/release-plz-action@v0.5
with:
command: release
tag_name: ${{ env.VERSION }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+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
+160 -1
View File
@@ -5,7 +5,166 @@ 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.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
### Other
- Added HTTPS support for all Servarrs
## [0.1.4](https://github.com/Dark-Alex-17/managarr/compare/v0.1.3...v0.1.4) - 2024-11-01
### Other
- Added the ability to fetch host configs and security configs to the CLI
- Updated README to be more clear about what features are supported [skip ci]
## [0.1.2](https://github.com/Dark-Alex-17/managarr/compare/v0.1.1...v0.1.2) - 2024-10-30
+32
View File
@@ -1,6 +1,7 @@
# Contributing
Contributors are very welcome! **No contribution is too small and all contributions are valued.**
## 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,6 +12,37 @@ 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
Generated
+741 -320
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -1,9 +1,9 @@
[package]
name = "managarr"
version = "0.1.3"
version = "0.3.0"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "tui-rs", "dashboard", "servarr", "tui"]
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr"
@@ -15,20 +15,20 @@ exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"]
[dependencies]
anyhow = "1.0.68"
backtrace = "0.3.67"
backtrace = "0.3.74"
bimap = { version = "0.6.3", features = ["serde"] }
chrono = { version = "0.4.38", features = ["serde"] }
confy = { version = "0.6.0", default-features = false, features = [
"yaml_conf",
] }
crossterm = "0.27.0"
crossterm = "0.28.1"
derivative = "2.2.0"
human-panic = "1.1.3"
human-panic = "2.0.2"
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"] }
reqwest = { version = "0.12.9", features = ["json"] }
serde_yaml = "0.9.16"
serde_json = "1.0.91"
serde = { version = "1.0.214", features = ["derive"] }
@@ -36,22 +36,24 @@ 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"] }
ratatui = { version = "0.29.0", features = ["all-widgets"] }
urlencoding = "2.1.2"
clap = { version = "4.5.20", features = ["derive", "cargo"] }
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"
dirs-next = "2.0.0"
managarr-tree-widget = "0.24.0"
indicatif = "0.17.9"
[dev-dependencies]
assert_cmd = "2.0.16"
mockall = "0.13.0"
mockito = "1.0.0"
pretty_assertions = "1.3.0"
rstest = "0.18.2"
rstest = "0.23.0"
[dev-dependencies.cargo-husky]
version = "1"
+1 -1
View File
@@ -23,4 +23,4 @@ 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" ]
+111 -64
View File
@@ -2,13 +2,11 @@
![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)
![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)
Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust!
@@ -17,14 +15,14 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built
## What Servarrs are supported?
- ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr)
- ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr)
- ![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/)
- [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr)
- [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr)
- [ ] ![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
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.
@@ -48,33 +46,64 @@ 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 ~/.config/managarr/config.yml:/root/.config/managarr/config.yml darkalex17/managarr
```
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.
## Features
Key:
| Symbol | Status |
|--------------------|-----------|
| :white_check_mark: | Supported |
| :x: | Missing |
| :clock3: | Planned |
| :no_entry_sign: | 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] 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 |
|--------------------|--------------------|----------------------------------------------------------------------------------------------------------------|
| :white_check_mark: | :white_check_mark: | View your library, downloads, collections, and blocklist |
| :white_check_mark: | :white_check_mark: | View details of a specific movie including description, history, downloaded file info, or the credits |
| :white_check_mark: | :white_check_mark: | View details of any collection and the movies in them |
| :no_entry_sign: | :white_check_mark: | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| :white_check_mark: | :white_check_mark: | Search your library or collections |
| :white_check_mark: | :white_check_mark: | Add movies to your library |
| :white_check_mark: | :white_check_mark: | Delete movies, downloads, and indexers |
| :white_check_mark: | :white_check_mark: | Trigger automatic searches for movies |
| :white_check_mark: | :white_check_mark: | Trigger refresh and disk scan for movies, downloads, and collections |
| :white_check_mark: | :white_check_mark: | Manually search for movies |
| :white_check_mark: | :white_check_mark: | Edit your movies, collections, and indexers |
| :white_check_mark: | :white_check_mark: | Manage your tags |
| :white_check_mark: | :white_check_mark: | Manage your root folders |
| :white_check_mark: | :white_check_mark: | Manage your blocklist |
| :white_check_mark: | :white_check_mark: | View and browse logs, tasks, events queues, and updates |
| :white_check_mark: | :white_check_mark: | Manually trigger scheduled tasks |
### Sonarr
- [ ] Support for Sonarr
| TUI | CLI | Feature |
|----------|--------------------|--------------------------------------------------------------------------------------------------------------------|
| :clock3: | :white_check_mark: | View your library, downloads, blocklist, episodes |
| :clock3: | :white_check_mark: | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| :clock3: | :white_check_mark: | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| :clock3: | :white_check_mark: | Search your library |
| :clock3: | :white_check_mark: | Add series to your library |
| :clock3: | :white_check_mark: | Delete series, downloads, indexers, root folders, and episode files |
| :clock3: | :white_check_mark: | Mark history events as failed |
| :clock3: | :white_check_mark: | Trigger automatic searches for series, seasons, or episodes |
| :clock3: | :white_check_mark: | Trigger refresh and disk scan for series and downloads |
| :clock3: | :white_check_mark: | Manually search for series, seasons, or episodes |
| :clock3: | :white_check_mark: | Edit your series and indexers |
| :clock3: | :white_check_mark: | Manage your tags |
| :clock3: | :white_check_mark: | Manage your root folders |
| :clock3: | :white_check_mark: | Manage your blocklist |
| :clock3: | :white_check_mark: | View and browse logs, tasks, events queues, and updates |
| :clock3: | :white_check_mark: | Manually trigger scheduled tasks |
### Readarr
@@ -103,58 +132,66 @@ You can also clone this repo and run `make docker` to build a docker image local
### 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.
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.0.36
managarr 0.3.0
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
Usage: managarr [COMMAND]
Usage: managarr [OPTIONS] [COMMAND]
Commands:
radarr Commands for manging your Radarr instance
sonarr Commands for manging your Sonarr instance
completions Generate shell completions for the Managarr CLI
tail-logs Tail Managarr logs
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config <CONFIG> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
-h, --help Print help
-V, --version Print version
```
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 <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
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config <CONFIG> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
-h, --help Print help
```
**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:
@@ -168,7 +205,7 @@ $ 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.
### Linux
```
@@ -185,7 +222,7 @@ $HOME/Library/Application Support/managarr/config.yml
%APPDATA%/Roaming/managarr/config.yml
```
## Specify Configuration File
## Specify Which Configuration File to Use
It can sometimes be useful to specify the configuration file you wish to use. This is useful in cases
where you may have more than one instance of a given Servarr running. Thus, you can specify the
config file using the `--config` flag:
@@ -197,39 +234,49 @@ managarr --config /path/to/config.yml
### Example Configuration:
```yaml
radarr:
host: 127.0.0.1
host: 192.168.0.78
port: 7878
api_token: someApiToken1234567890
ssl_cert_path: /path/to/radarr.crt # Required to enable SSL
sonarr:
host: 127.0.0.1
port: 8989
uri: http://htpc.local/sonarr # Example of using the 'uri' key instead of 'host' and 'port'
api_token: someApiToken1234567890
readarr:
host: 127.0.0.1
host: 192.168.0.87
port: 8787
api_token: someApiToken1234567890
lidarr:
host: 127.0.0.1
host: 192.168.0.86
port: 8686
api_token: someApiToken1234567890
whisparr:
host: 127.0.0.1
host: 192.168.0.69
port: 6969
api_token: someApiToken1234567890
ssl_cert_path: /path/to/whisparr.crt
bazarr:
host: 127.0.0.1
host: 192.168.0.67
port: 6767
api_token: someApiToken1234567890
prowlarr:
host: 127.0.0.1
host: 192.168.0.96
port: 9696
api_token: someApiToken1234567890
tautulli:
host: 127.0.0.1
host: 192.168.0.81
port: 8181
api_token: someApiToken1234567890
```
## 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` |
| `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` |
|-----------------------------------------|--------------------------------------------------------------------------------|----------------------------------|
## 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`.
+6
View File
@@ -0,0 +1,6 @@
coverage:
range: "80..100"
ignore:
- "**/*_tests.rs"
- "src/ui"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "managarr",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1
View File
@@ -0,0 +1 @@
{}
+62 -15
View File
@@ -1,13 +1,14 @@
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use pretty_assertions::{assert_eq, assert_str_eq};
use pretty_assertions::assert_eq;
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, DEFAULT_ROUTE};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::{HorizontallyScrollableText, Route, TabRoute};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, TabRoute};
use crate::network::radarr_network::RadarrEvent;
use crate::network::NetworkEvent;
@@ -34,7 +35,7 @@ mod tests {
},
TabRoute {
title: "Sonarr",
route: Route::Sonarr,
route: ActiveSonarrBlock::Series.into(),
help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)),
contextual_help: None,
},
@@ -47,6 +48,7 @@ mod tests {
assert!(!app.is_routing);
assert!(!app.should_refresh);
assert!(!app.should_ignore_quit_key);
assert!(!app.cli_mode);
}
#[test]
@@ -87,7 +89,11 @@ mod tests {
#[test]
fn test_reset_cancellation_token() {
let mut app = App::default();
let mut app = App {
is_loading: true,
should_refresh: false,
..App::default()
};
app.cancellation_token.cancel();
assert!(app.cancellation_token.is_cancelled());
@@ -96,6 +102,8 @@ mod tests {
assert!(!app.cancellation_token.is_cancelled());
assert!(!new_token.is_cancelled());
assert!(!app.is_loading);
assert!(app.should_refresh);
}
#[test]
@@ -120,6 +128,10 @@ mod tests {
version: "test".to_owned(),
..RadarrData::default()
},
sonarr_data: SonarrData {
version: "test".to_owned(),
..SonarrData::default()
},
},
..App::default()
};
@@ -129,6 +141,7 @@ mod tests {
assert_eq!(app.tick_count, 0);
assert_eq!(app.error, HorizontallyScrollableText::default());
assert!(app.data.radarr_data.version.is_empty());
assert!(app.data.sonarr_data.version.is_empty());
}
#[test]
@@ -145,6 +158,29 @@ 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::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);
}
#[tokio::test]
async fn test_on_tick_first_render() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
@@ -158,6 +194,7 @@ mod tests {
assert_eq!(app.tick_count, 0);
app.on_tick(true).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into()
@@ -172,7 +209,11 @@ mod tests {
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDiskSpace.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
@@ -182,10 +223,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetMovies.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(!app.is_routing);
assert!(!app.should_refresh);
assert_eq!(app.tick_count, 1);
@@ -218,11 +255,21 @@ mod tests {
}
#[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!(app_config.radarr.is_none());
assert!(app_config.sonarr.is_none());
}
#[test]
fn test_servarr_config_default() {
let servarr_config = ServarrConfig::default();
assert_eq!(servarr_config.host, Some("localhost".to_string()));
assert_eq!(servarr_config.port, None);
assert_eq!(servarr_config.uri, None);
assert!(servarr_config.api_token.is_empty());
assert_eq!(servarr_config.ssl_cert_path, None);
}
}
+5
View File
@@ -33,6 +33,7 @@ generate_keybindings! {
tab,
delete,
submit,
confirm,
quit,
esc
}
@@ -140,6 +141,10 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
key: Key::Enter,
desc: "submit",
},
confirm: KeyBinding {
key: Key::Ctrl('s'),
desc: "submit",
},
quit: KeyBinding {
key: Key::Char('q'),
desc: "quit",
+1
View File
@@ -31,6 +31,7 @@ mod test {
#[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")]
fn test_default_key_bindings_and_descriptions(
+80 -16
View File
@@ -1,11 +1,16 @@
use std::process;
use anyhow::anyhow;
use colored::Colorize;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
use crate::cli::Command;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState};
use crate::network::NetworkEvent;
@@ -32,6 +37,7 @@ pub struct App<'a> {
pub is_loading: bool,
pub should_refresh: bool,
pub should_ignore_quit_key: bool,
pub cli_mode: bool,
pub config: AppConfig,
pub data: Data<'a>,
}
@@ -53,7 +59,10 @@ impl<'a> App<'a> {
pub async fn dispatch_network_event(&mut self, action: NetworkEvent) {
debug!("Dispatching network event: {action:?}");
self.is_loading = true;
if !self.should_refresh {
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;
@@ -110,6 +119,8 @@ impl<'a> App<'a> {
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()
}
@@ -143,7 +154,7 @@ impl<'a> Default for App<'a> {
},
TabRoute {
title: "Sonarr",
route: Route::Sonarr,
route: ActiveSonarrBlock::Series.into(),
help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)),
contextual_help: None,
},
@@ -155,6 +166,7 @@ impl<'a> Default for App<'a> {
is_routing: false,
should_refresh: false,
should_ignore_quit_key: false,
cli_mode: false,
config: AppConfig::default(),
data: Data::default(),
}
@@ -164,26 +176,78 @@ impl<'a> Default for App<'a> {
#[derive(Default)]
pub struct Data<'a> {
pub radarr_data: RadarrData<'a>,
pub sonarr_data: SonarrData,
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppConfig {
pub radarr: RadarrConfig,
pub radarr: Option<ServarrConfig>,
pub sonarr: Option<ServarrConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RadarrConfig {
pub host: String,
pub port: Option<u16>,
pub api_token: String,
}
impl AppConfig {
pub fn validate(&self) {
if let Some(radarr_config) = &self.radarr {
radarr_config.validate();
}
impl Default for RadarrConfig {
fn default() -> Self {
RadarrConfig {
host: "localhost".to_string(),
port: Some(7878),
api_token: "".to_string(),
if let Some(sonarr_config) = &self.sonarr {
sonarr_config.validate();
}
}
pub fn verify_config_present_for_cli(&self, command: &Command) {
let msg = |servarr: &str| {
log_and_print_error(format!(
"{} configuration missing; Unable to run any {} commands.",
servarr, servarr
))
};
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);
}
_ => (),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServarrConfig {
pub host: Option<String>,
pub port: Option<u16>,
pub uri: Option<String>,
pub api_token: String,
pub ssl_cert_path: Option<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);
}
}
}
impl Default for ServarrConfig {
fn default() -> Self {
ServarrConfig {
host: Some("localhost".to_string()),
port: None,
uri: None,
api_token: "".to_string(),
ssl_cert_path: None,
}
}
}
pub fn log_and_print_error(error: String) {
error!("{}", error);
eprintln!("error: {}", error.red());
}
+12 -19
View File
@@ -142,35 +142,22 @@ impl<'a> App<'a> {
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;
self.refresh_metadata().await;
self.dispatch_by_radarr_block(&active_radarr_block).await;
}
if self.should_refresh {
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_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.refresh_metadata().await;
}
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await;
}
if self.tick_count % self.tick_until_poll == 0 {
@@ -191,6 +178,12 @@ impl<'a> App<'a> {
self
.dispatch_network_event(RadarrEvent::GetDownloads.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
}
async fn populate_movie_collection_table(&mut self) {
+5
View File
@@ -120,6 +120,11 @@ pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(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),
+18 -2
View File
@@ -5,8 +5,8 @@ mod tests {
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, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_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,
};
@@ -349,6 +349,22 @@ mod tests {
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();
+17 -33
View File
@@ -6,7 +6,7 @@ mod tests {
use crate::app::radarr::ActiveRadarrBlock;
use crate::app::App;
use crate::models::radarr_models::{Collection, CollectionMovie, Credit, Release};
use crate::models::radarr_models::{Collection, CollectionMovie, Credit, RadarrRelease};
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
use crate::network::radarr_network::RadarrEvent;
@@ -430,7 +430,7 @@ mod tests {
let mut movie_details_modal = MovieDetailsModal::default();
movie_details_modal
.movie_releases
.set_items(vec![Release::default()]);
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(movie_details_modal);
app
@@ -508,6 +508,14 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDiskSpace.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert!(app.is_loading);
}
@@ -531,16 +539,16 @@ mod tests {
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDiskSpace.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
}
@@ -549,6 +557,7 @@ mod tests {
async fn test_radarr_on_tick_routing() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_routing = true;
app.should_refresh = true;
app
.radarr_on_tick(ActiveRadarrBlock::Downloads, false)
@@ -574,43 +583,19 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_radarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (mut app, mut sync_network_rx) = construct_app_unit();
let (mut app, _) = construct_app_unit();
app.is_routing = true;
app.is_loading = true;
app.should_refresh = false;
app
.radarr_on_tick(ActiveRadarrBlock::Downloads, false)
.await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
assert!(app.cancellation_token.is_cancelled());
}
@@ -627,7 +612,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(app.should_refresh);
assert!(!app.data.radarr_data.prompt_confirm);
}
+57 -10
View File
@@ -10,19 +10,28 @@ mod tests {
use crate::{
app::App,
cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand},
cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand},
models::{
radarr_models::{BlocklistItem, BlocklistResponse, RadarrSerdeable},
radarr_models::{
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable,
},
sonarr_models::{
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable,
},
Serdeable,
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
network::{
radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent,
},
Cli,
};
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_eq!(
@@ -39,6 +48,13 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn test_sonarr_subcommand_delegates_to_sonarr() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]);
assert!(result.is_ok());
}
#[test]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -106,8 +122,8 @@ mod tests {
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::BlocklistResponse(
BlocklistResponse {
records: vec![BlocklistItem::default()],
RadarrBlocklistResponse {
records: vec![RadarrBlocklistItem::default()],
},
)))
});
@@ -121,9 +137,40 @@ mod tests {
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let claer_blocklist_command = RadarrCommand::ClearBlocklist.into();
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());
}
#[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::default()));
let clear_blocklist_command = SonarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert!(result.is_ok());
}
+28 -21
View File
@@ -4,11 +4,13 @@ use anyhow::Result;
use clap::{command, Subcommand};
use clap_complete::Shell;
use radarr::{RadarrCliHandler, RadarrCommand};
use sonarr::{SonarrCliHandler, SonarrCommand};
use tokio::sync::Mutex;
use crate::{app::App, network::NetworkTrait};
pub mod radarr;
pub mod sonarr;
#[cfg(test)]
#[path = "cli_tests.rs"]
@@ -19,6 +21,9 @@ 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(
arg_required_else_help = true,
about = "Generate shell completions for the Managarr CLI"
@@ -27,24 +32,39 @@ pub enum Command {
#[arg(value_enum)]
shell: Shell,
},
#[command(about = "Tail Managarr logs")]
TailLogs {
#[arg(long, help = "Disable colored log output")]
no_color: bool,
},
}
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?
}
_ => String::new(),
};
Ok(result)
}
#[inline]
@@ -68,16 +88,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);
};
}
+24 -16
View File
@@ -7,8 +7,7 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
models::radarr_models::{AddMovieBody, AddOptions, MinimumAvailability, Monitor},
models::radarr_models::{AddMovieBody, AddMovieOptions, MinimumAvailability, MovieMonitor},
network::{radarr_network::RadarrEvent, NetworkTrait},
};
@@ -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,
@@ -126,24 +125,33 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan
minimum_availability: minimum_availability.to_string(),
monitored: !disable_monitoring,
tags,
add_options: AddOptions {
add_options: AddMovieOptions {
monitor: monitor.to_string(),
search_for_movie: !no_search_for_movie,
},
};
execute_network_event!(self, RadarrEvent::AddMovie(Some(body)));
let resp = self
.network
.handle_network_event(RadarrEvent::AddMovie(Some(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 resp = self
.network
.handle_network_event(RadarrEvent::AddRootFolder(Some(root_folder_path)).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)
}
}
+10 -7
View File
@@ -7,9 +7,10 @@ mod tests {
radarr::{add_command_handler::RadarrAddCommand, RadarrCommand},
Command,
},
models::radarr_models::{MinimumAvailability, Monitor},
models::radarr_models::{MinimumAvailability, MovieMonitor},
Cli,
};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_add_command_from() {
@@ -111,6 +112,8 @@ mod tests {
"/test",
"--quality-profile-id",
"1",
"--tmdb-id",
"1",
flag,
]);
@@ -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,
};
@@ -219,7 +222,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,
};
@@ -255,7 +258,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,
};
@@ -356,7 +359,7 @@ mod tests {
app::App,
cli::{radarr::add_command_handler::RadarrAddCommandHandler, CliCommandHandler},
models::{
radarr_models::{AddMovieBody, AddOptions, RadarrSerdeable},
radarr_models::{AddMovieBody, AddMovieOptions, RadarrSerdeable},
Serdeable,
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
@@ -378,7 +381,7 @@ mod tests {
minimum_availability: "released".to_owned(),
monitored: false,
tags: vec![1, 2],
add_options: AddOptions {
add_options: AddMovieOptions {
monitor: "movieAndCollection".to_owned(),
search_for_movie: false,
},
@@ -403,7 +406,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,
};
+34 -14
View File
@@ -7,7 +7,6 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
models::radarr_models::DeleteMovieParams,
network::{radarr_network::RadarrEvent, NetworkTrait},
};
@@ -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(Some(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(Some(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(Some(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(Some(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(Some(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)
}
}
@@ -8,6 +8,7 @@ mod tests {
Cli,
};
use clap::{error::ErrorKind, CommandFactory, Parser};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_delete_command_from() {
+28 -27
View File
@@ -7,12 +7,11 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{mutex_flags_or_default, mutex_flags_or_option, CliCommandHandler, Command},
execute_network_event,
models::{
radarr_models::{
EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings,
MinimumAvailability, RadarrSerdeable,
EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, RadarrSerdeable,
},
servarr_models::EditIndexerParams,
Serdeable,
},
network::{radarr_network::RadarrEvent, NetworkTrait},
@@ -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,
@@ -389,11 +388,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
})
.into(),
};
execute_network_event!(
self,
RadarrEvent::EditAllIndexerSettings(Some(params)),
"All indexer settings updated"
);
self
.network
.handle_network_event(RadarrEvent::EditAllIndexerSettings(Some(params)).into())
.await?;
"All indexer settings updated".to_owned()
} else {
String::new()
}
}
RadarrEditCommand::Collection {
@@ -417,11 +418,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(Some(edit_collection_params)).into())
.await?;
"Collection updated".to_owned()
}
RadarrEditCommand::Indexer {
indexer_id,
@@ -458,11 +459,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
clear_tags,
};
execute_network_event!(
self,
RadarrEvent::EditIndexer(Some(edit_indexer_params)),
"Indexer updated"
);
self
.network
.handle_network_event(RadarrEvent::EditIndexer(Some(edit_indexer_params)).into())
.await?;
"Indexer updated".to_owned()
}
RadarrEditCommand::Movie {
movie_id,
@@ -485,14 +486,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
clear_tags,
};
execute_network_event!(
self,
RadarrEvent::EditMovie(Some(edit_movie_params)),
"Movie updated"
);
self
.network
.handle_network_event(RadarrEvent::EditMovie(Some(edit_movie_params)).into())
.await?;
"Movie Updated".to_owned()
}
}
};
Ok(())
Ok(result)
}
}
+4 -2
View File
@@ -8,6 +8,7 @@ mod tests {
Cli,
};
use clap::{error::ErrorKind, CommandFactory, Parser};
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_edit_command_from() {
@@ -809,9 +810,10 @@ mod tests {
},
models::{
radarr_models::{
EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings,
MinimumAvailability, RadarrSerdeable,
EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability,
RadarrSerdeable,
},
servarr_models::EditIndexerParams,
Serdeable,
},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent},
+42 -9
View File
@@ -7,7 +7,6 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{radarr_network::RadarrEvent, NetworkTrait},
};
@@ -21,6 +20,8 @@ mod get_command_handler_tests;
pub enum RadarrGetCommand {
#[command(about = "Get the shared settings for all indexers")]
AllIndexerSettings,
#[command(about = "Fetch the host config for your Radarr instance")]
HostConfig,
#[command(about = "Get detailed information for the movie with the given ID")]
MovieDetails {
#[arg(
@@ -39,6 +40,8 @@ pub enum RadarrGetCommand {
)]
movie_id: i64,
},
#[command(about = "Fetch the security config for your Radarr instance")]
SecurityConfig,
#[command(about = "Get the system status")]
SystemStatus,
}
@@ -68,22 +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 => {
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(Some(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(Some(movie_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrGetCommand::SecurityConfig => {
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)
}
}
+64 -1
View File
@@ -1,5 +1,5 @@
#[cfg(test)]
mod test {
mod tests {
use clap::error::ErrorKind;
use clap::CommandFactory;
@@ -7,6 +7,7 @@ mod test {
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli;
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_get_command_from() {
@@ -29,6 +30,14 @@ mod test {
assert!(result.is_ok());
}
#[test]
fn test_get_host_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "host-config"]);
assert!(result.is_ok());
}
#[test]
fn test_movie_details_requires_movie_id() {
let result =
@@ -81,6 +90,14 @@ mod test {
assert!(result.is_ok());
}
#[test]
fn test_get_security_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "security-config"]);
assert!(result.is_ok());
}
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
@@ -135,6 +152,29 @@ mod test {
assert!(result.is_ok());
}
#[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>(RadarrEvent::GetHostConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_host_config_command = RadarrGetCommand::HostConfig;
let result =
RadarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_get_movie_details_command() {
let expected_movie_id = 1;
@@ -187,6 +227,29 @@ mod test {
assert!(result.is_ok());
}
#[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>(RadarrEvent::GetSecurityConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_security_config_command = RadarrGetCommand::SecurityConfig;
let result =
RadarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_get_system_status_command() {
let mut mock_network = MockNetworkTrait::new();
+76 -22
View File
@@ -7,7 +7,6 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{radarr_network::RadarrEvent, NetworkTrait},
};
@@ -25,6 +24,8 @@ pub enum RadarrListCommand {
Collections,
#[command(about = "List all active downloads in Radarr")]
Downloads,
#[command(about = "List disk space details for all provisioned root folders in Radarr")]
DiskSpace,
#[command(about = "List all Radarr indexers")]
Indexers,
#[command(about = "Fetch Radarr logs")]
@@ -56,7 +57,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 +88,42 @@ 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);
let resp = self
.network
.handle_network_event(RadarrEvent::GetDownloads.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::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,
@@ -113,39 +137,69 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH
if output_in_log_format {
let log_lines = self.app.lock().await.data.radarr_data.logs.items.clone();
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(Some(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)
}
}
+6 -3
View File
@@ -7,6 +7,7 @@ mod tests {
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli;
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_list_command_from() {
@@ -29,6 +30,7 @@ mod tests {
"blocklist",
"collections",
"downloads",
"disk-space",
"indexers",
"movies",
"quality-profiles",
@@ -80,8 +82,8 @@ mod tests {
assert!(result.is_ok());
if let Some(Command::Radarr(RadarrCommand::List(refresh_command))) = result.unwrap().command {
assert_eq!(refresh_command, expected_args);
if let Some(Command::Radarr(RadarrCommand::List(credits_command))) = result.unwrap().command {
assert_eq!(credits_command, expected_args);
}
}
@@ -121,6 +123,7 @@ mod tests {
#[case(RadarrListCommand::Blocklist, RadarrEvent::GetBlocklist)]
#[case(RadarrListCommand::Collections, RadarrEvent::GetCollections)]
#[case(RadarrListCommand::Downloads, RadarrEvent::GetDownloads)]
#[case(RadarrListCommand::DiskSpace, RadarrEvent::GetDiskSpace)]
#[case(RadarrListCommand::Indexers, RadarrEvent::GetIndexers)]
#[case(RadarrListCommand::Movies, RadarrEvent::GetMovies)]
#[case(RadarrListCommand::QualityProfiles, RadarrEvent::GetQualityProfiles)]
@@ -130,7 +133,7 @@ mod tests {
#[case(RadarrListCommand::Tasks, RadarrEvent::GetTasks)]
#[case(RadarrListCommand::Updates, RadarrEvent::GetUpdates)]
#[tokio::test]
async fn test_handle_list_blocklist_command(
async fn test_handle_list_command(
#[case] list_command: RadarrListCommand,
#[case] expected_radarr_event: RadarrEvent,
) {
+50 -18
View File
@@ -12,8 +12,7 @@ use tokio::sync::Mutex;
use crate::app::App;
use crate::cli::CliCommandHandler;
use crate::execute_network_event;
use crate::models::radarr_models::{ReleaseDownloadBody, TaskName};
use crate::models::radarr_models::{RadarrReleaseDownloadBody, RadarrTaskName};
use crate::network::radarr_network::RadarrEvent;
use crate::network::NetworkTrait;
use anyhow::Result;
@@ -86,7 +85,7 @@ pub enum RadarrCommand {
ManualSearch {
#[arg(
long,
help = "The Radarr ID of the movie whose releases you wish to fetch and list",
help = "The Radarr ID of the movie whose releases you wish to fetch",
required = true
)]
movie_id: i64,
@@ -108,7 +107,7 @@ pub enum RadarrCommand {
value_enum,
required = true
)]
task_name: TaskName,
task_name: RadarrTaskName,
},
#[command(
about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'"
@@ -117,7 +116,7 @@ pub enum RadarrCommand {
#[arg(long, help = "The ID of the indexer to test", required = true)]
indexer_id: i64,
},
#[command(about = "Test all indexers")]
#[command(about = "Test all Radarr indexers")]
TestAllIndexers,
#[command(about = "Trigger an automatic search for the movie with the specified ID")]
TriggerAutomaticSearch {
@@ -155,8 +154,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, '
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrCommand::Add(add_command) => {
RadarrAddCommandHandler::with(self.app, add_command, self.network)
.handle()
@@ -192,41 +191,74 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, '
.network
.handle_network_event(RadarrEvent::GetBlocklist.into())
.await?;
execute_network_event!(self, RadarrEvent::ClearBlocklist);
let resp = self
.network
.handle_network_event(RadarrEvent::ClearBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::DownloadRelease {
guid,
indexer_id,
movie_id,
} => {
let params = ReleaseDownloadBody {
let params = RadarrReleaseDownloadBody {
guid,
indexer_id,
movie_id,
};
execute_network_event!(self, RadarrEvent::DownloadRelease(Some(params)));
let resp = self
.network
.handle_network_event(RadarrEvent::DownloadRelease(Some(params)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::ManualSearch { movie_id } => {
println!("Searching for releases. This may take a minute...");
execute_network_event!(self, RadarrEvent::GetReleases(Some(movie_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::GetReleases(Some(movie_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::SearchNewMovie { query } => {
execute_network_event!(self, RadarrEvent::SearchNewMovie(Some(query)));
let resp = self
.network
.handle_network_event(RadarrEvent::SearchNewMovie(Some(query)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::StartTask { task_name } => {
execute_network_event!(self, RadarrEvent::StartTask(Some(task_name)));
let resp = self
.network
.handle_network_event(RadarrEvent::StartTask(Some(task_name)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::TestIndexer { indexer_id } => {
execute_network_event!(self, RadarrEvent::TestIndexer(Some(indexer_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::TestIndexer(Some(indexer_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::TestAllIndexers => {
execute_network_event!(self, RadarrEvent::TestAllIndexers);
println!("Testing all Radarr indexers. This may take a minute...");
let resp = self
.network
.handle_network_event(RadarrEvent::TestAllIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::TriggerAutomaticSearch { movie_id } => {
execute_network_event!(self, RadarrEvent::TriggerAutomaticSearch(Some(movie_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::TriggerAutomaticSearch(Some(movie_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}
};
Ok(())
Ok(result)
}
}
+14 -14
View File
@@ -31,7 +31,7 @@ mod tests {
assert!(result.is_ok());
}
#[rstest]
#[test]
fn test_download_release_requires_movie_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
@@ -50,7 +50,7 @@ mod tests {
);
}
#[rstest]
#[test]
fn test_download_release_requires_guid() {
let result = Cli::command().try_get_matches_from([
"managarr",
@@ -69,7 +69,7 @@ mod tests {
);
}
#[rstest]
#[test]
fn test_download_release_requires_indexer_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
@@ -105,7 +105,7 @@ mod tests {
assert!(result.is_ok());
}
#[rstest]
#[test]
fn test_manual_search_requires_movie_id() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "manual-search"]);
@@ -129,7 +129,7 @@ mod tests {
assert!(result.is_ok());
}
#[rstest]
#[test]
fn test_search_new_movie_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "search-new-movie"]);
@@ -153,7 +153,7 @@ mod tests {
assert!(result.is_ok());
}
#[rstest]
#[test]
fn test_start_task_requires_task_name() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "start-task"]);
@@ -164,7 +164,7 @@ mod tests {
);
}
#[rstest]
#[test]
fn test_start_task_task_name_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
@@ -191,7 +191,7 @@ mod tests {
assert!(result.is_ok());
}
#[rstest]
#[test]
fn test_test_indexer_requires_indexer_id() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "test-indexer"]);
@@ -215,7 +215,7 @@ mod tests {
assert!(result.is_ok());
}
#[rstest]
#[test]
fn test_trigger_automatic_search_requires_movie_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "trigger-automatic-search"]);
@@ -261,8 +261,8 @@ mod tests {
},
models::{
radarr_models::{
BlocklistItem, BlocklistResponse, IndexerSettings, RadarrSerdeable, ReleaseDownloadBody,
TaskName,
BlocklistItem, BlocklistResponse, IndexerSettings, RadarrReleaseDownloadBody,
RadarrSerdeable, RadarrTaskName,
},
Serdeable,
},
@@ -304,7 +304,7 @@ mod tests {
#[tokio::test]
async fn test_download_release_command() {
let expected_release_download_body = ReleaseDownloadBody {
let expected_release_download_body = RadarrReleaseDownloadBody {
guid: "guid".to_owned(),
indexer_id: 1,
movie_id: 1,
@@ -389,7 +389,7 @@ mod tests {
#[tokio::test]
async fn test_start_task_command() {
let expected_task_name = TaskName::ApplicationCheckUpdate;
let expected_task_name = RadarrTaskName::ApplicationCheckUpdate;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
@@ -404,7 +404,7 @@ mod tests {
});
let app_arc = Arc::new(Mutex::new(App::default()));
let start_task_command = RadarrCommand::StartTask {
task_name: TaskName::ApplicationCheckUpdate,
task_name: RadarrTaskName::ApplicationCheckUpdate,
};
let result = RadarrCliHandler::with(&app_arc, start_task_command, &mut mock_network)
+25 -10
View File
@@ -7,7 +7,6 @@ use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
execute_network_event,
network::{radarr_network::RadarrEvent, NetworkTrait},
};
@@ -19,7 +18,7 @@ mod refresh_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum RadarrRefreshCommand {
#[command(about = "Refresh all movie data for all movies in your library")]
#[command(about = "Refresh all movie data for all movies in your Radarr library")]
AllMovies,
#[command(about = "Refresh movie data and scan disk for the movie with the given ID")]
Movie {
@@ -63,22 +62,38 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrRefreshCommand>
}
}
async fn handle(self) -> Result<()> {
match self.command {
async fn handle(self) -> Result<String> {
let result = match self.command {
RadarrRefreshCommand::AllMovies => {
execute_network_event!(self, RadarrEvent::UpdateAllMovies);
let resp = self
.network
.handle_network_event(RadarrEvent::UpdateAllMovies.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrRefreshCommand::Collections => {
execute_network_event!(self, RadarrEvent::UpdateCollections);
let resp = self
.network
.handle_network_event(RadarrEvent::UpdateCollections.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrRefreshCommand::Downloads => {
execute_network_event!(self, RadarrEvent::UpdateDownloads);
let resp = self
.network
.handle_network_event(RadarrEvent::UpdateDownloads.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrRefreshCommand::Movie { movie_id } => {
execute_network_event!(self, RadarrEvent::UpdateAndScan(Some(movie_id)));
let resp = self
.network
.handle_network_event(RadarrEvent::UpdateAndScan(Some(movie_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}
};
Ok(())
Ok(result)
}
}
@@ -7,6 +7,7 @@ mod tests {
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli;
use pretty_assertions::assert_eq;
#[test]
fn test_radarr_refresh_command_from() {
@@ -81,7 +82,7 @@ mod tests {
#[case(RadarrRefreshCommand::Collections, RadarrEvent::UpdateCollections)]
#[case(RadarrRefreshCommand::Downloads, RadarrEvent::UpdateDownloads)]
#[tokio::test]
async fn test_handle_list_blocklist_command(
async fn test_handle_refresh_command(
#[case] refresh_command: RadarrRefreshCommand,
#[case] expected_radarr_event: RadarrEvent,
) {
+172
View File
@@ -0,0 +1,172 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, Subcommand};
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::sonarr_models::{AddSeriesBody, AddSeriesOptions, SeriesMonitor, SeriesType},
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "add_command_handler_tests.rs"]
mod add_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrAddCommand {
#[command(about = "Add a new series to your Sonarr library")]
Series {
#[arg(
long,
help = "The TVDB ID of the series you wish to add to your library",
required = true
)]
tvdb_id: i64,
#[arg(long, help = "The title of the series", required = true)]
title: String,
#[arg(
long,
help = "The root folder path where all series data and metadata should live",
required = true
)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the quality profile to use for this series",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the language profile to use for this series",
required = true
)]
language_profile_id: i64,
#[arg(
long,
help = "The type of series",
value_enum,
default_value_t = SeriesType::default()
)]
series_type: SeriesType,
#[arg(long, help = "Disable monitoring for this series")]
disable_monitoring: bool,
#[arg(long, help = "Don't use season folders for this series")]
disable_season_folders: bool,
#[arg(
long,
help = "Tag IDs to tag the series with",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
#[arg(
long,
help = "What Sonarr should monitor",
value_enum,
default_value_t = SeriesMonitor::default()
)]
monitor: SeriesMonitor,
#[arg(
long,
help = "Tell Sonarr to not start a search for this series once it's added to your library"
)]
no_search_for_series: bool,
},
#[command(about = "Add a new root folder")]
RootFolder {
#[arg(long, help = "The path of the new root folder", required = true)]
root_folder_path: String,
},
#[command(about = "Add new tag")]
Tag {
#[arg(long, help = "The name of the tag to be added", required = true)]
name: String,
},
}
impl From<SonarrAddCommand> for Command {
fn from(value: SonarrAddCommand) -> Self {
Command::Sonarr(SonarrCommand::Add(value))
}
}
pub(super) struct SonarrAddCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrAddCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrAddCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrAddCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrAddCommand::Series {
tvdb_id,
title,
root_folder_path,
quality_profile_id,
language_profile_id,
series_type,
disable_monitoring,
disable_season_folders,
tag: tags,
monitor,
no_search_for_series,
} => {
let body = AddSeriesBody {
tvdb_id,
title,
monitored: !disable_monitoring,
root_folder_path,
quality_profile_id,
language_profile_id,
series_type: series_type.to_string(),
season_folder: !disable_season_folders,
tags,
add_options: AddSeriesOptions {
monitor: monitor.to_string(),
search_for_cutoff_unmet_episodes: !no_search_for_series,
search_for_missing_episodes: !no_search_for_series,
},
};
let resp = self
.network
.handle_network_event(SonarrEvent::AddSeries(Some(body)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrAddCommand::RootFolder { root_folder_path } => {
let resp = self
.network
.handle_network_event(SonarrEvent::AddRootFolder(Some(root_folder_path)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrAddCommand::Tag { name } => {
let resp = self
.network
.handle_network_event(SonarrEvent::AddTag(name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+582
View File
@@ -0,0 +1,582 @@
#[cfg(test)]
mod tests {
use clap::{error::ErrorKind, CommandFactory, Parser};
use pretty_assertions::assert_eq;
use crate::{
cli::{
sonarr::{add_command_handler::SonarrAddCommand, SonarrCommand},
Command,
},
Cli,
};
#[test]
fn test_sonarr_add_command_from() {
let command = SonarrAddCommand::Tag {
name: String::new(),
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Add(command)));
}
mod cli {
use crate::models::sonarr_models::{SeriesMonitor, SeriesType};
use super::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test]
fn test_add_root_folder_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "root-folder"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_root_folder_success() {
let expected_args = SonarrAddCommand::RootFolder {
root_folder_path: "/nfs/test".to_owned(),
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"add",
"root-folder",
"--root-folder-path",
"/nfs/test",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
}
#[test]
fn test_add_series_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "series"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_series_requires_tvdb_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--root-folder-path",
"test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--title",
"test",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_series_requires_title() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--tvdb-id",
"1",
"--root-folder-path",
"test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_series_requires_root_folder_path() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--tvdb-id",
"1",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--title",
"test",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_series_requires_quality_profile_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--tvdb-id",
"1",
"--root-folder-path",
"test",
"--language-profile-id",
"1",
"--title",
"test",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_series_requires_language_profile_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--tvdb-id",
"1",
"--root-folder-path",
"test",
"--quality-profile-id",
"1",
"--title",
"test",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[rstest]
fn test_add_series_assert_argument_flags_require_args(
#[values("--series-type", "--tag", "--monitor")] flag: &str,
) {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--tvdb-id",
"1",
"--title",
"test",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
flag,
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_series_all_arguments_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--title",
"test",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--tvdb-id",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_add_series_series_type_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--tvdb-id",
"1",
"--title",
"test",
"--series-type",
"test",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_series_monitor_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"add",
"series",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--tvdb-id",
"--title",
"test",
"1",
"--monitor",
"test",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_series_defaults() {
let expected_args = SonarrAddCommand::Series {
tvdb_id: 1,
title: "test".to_owned(),
root_folder_path: "/test".to_owned(),
quality_profile_id: 1,
language_profile_id: 1,
series_type: SeriesType::default(),
disable_monitoring: false,
disable_season_folders: false,
tag: vec![],
monitor: SeriesMonitor::default(),
no_search_for_series: false,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"add",
"series",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--title",
"test",
"--tvdb-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
}
#[test]
fn test_add_series_tags_is_repeatable() {
let expected_args = SonarrAddCommand::Series {
tvdb_id: 1,
title: "test".to_owned(),
root_folder_path: "/test".to_owned(),
quality_profile_id: 1,
language_profile_id: 1,
series_type: SeriesType::default(),
disable_monitoring: false,
disable_season_folders: false,
tag: vec![1, 2],
monitor: SeriesMonitor::default(),
no_search_for_series: false,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"add",
"series",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--tvdb-id",
"1",
"--title",
"test",
"--tag",
"1",
"--tag",
"2",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
}
#[test]
fn test_add_series_all_args_defined() {
let expected_args = SonarrAddCommand::Series {
tvdb_id: 1,
title: "test".to_owned(),
root_folder_path: "/test".to_owned(),
quality_profile_id: 1,
language_profile_id: 1,
series_type: SeriesType::Anime,
disable_monitoring: true,
disable_season_folders: true,
tag: vec![1, 2],
monitor: SeriesMonitor::Future,
no_search_for_series: true,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"add",
"series",
"--root-folder-path",
"/test",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--series-type",
"anime",
"--disable-monitoring",
"--disable-season-folders",
"--tvdb-id",
"1",
"--title",
"test",
"--tag",
"1",
"--tag",
"2",
"--monitor",
"future",
"--no-search-for-series",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
}
#[test]
fn test_add_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "tag"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_tag_success() {
let expected_args = SonarrAddCommand::Tag {
name: "test".to_owned(),
};
let result = Cli::try_parse_from(["managarr", "sonarr", "add", "tag", "--name", "test"]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command {
assert_eq!(add_command, expected_args);
}
}
}
mod handler {
use std::sync::Arc;
use crate::{
app::App,
cli::{sonarr::add_command_handler::SonarrAddCommandHandler, CliCommandHandler},
models::{
sonarr_models::{
AddSeriesBody, AddSeriesOptions, SeriesMonitor, SeriesType, SonarrSerdeable,
},
Serdeable,
},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
use super::*;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
#[tokio::test]
async fn test_handle_add_root_folder_command() {
let expected_root_folder_path = "/nfs/test".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::AddRootFolder(Some(expected_root_folder_path.clone())).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let add_root_folder_command = SonarrAddCommand::RootFolder {
root_folder_path: expected_root_folder_path,
};
let result =
SonarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_add_series_command() {
let expected_add_series_body = AddSeriesBody {
tvdb_id: 1,
title: "test".to_owned(),
root_folder_path: "/test".to_owned(),
quality_profile_id: 1,
language_profile_id: 1,
series_type: "anime".to_owned(),
monitored: false,
tags: vec![1, 2],
season_folder: false,
add_options: AddSeriesOptions {
monitor: "future".to_owned(),
search_for_cutoff_unmet_episodes: false,
search_for_missing_episodes: false,
},
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::AddSeries(Some(expected_add_series_body)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let add_series_command = SonarrAddCommand::Series {
tvdb_id: 1,
title: "test".to_owned(),
root_folder_path: "/test".to_owned(),
quality_profile_id: 1,
language_profile_id: 1,
series_type: SeriesType::Anime,
disable_monitoring: true,
disable_season_folders: true,
tag: vec![1, 2],
monitor: SeriesMonitor::Future,
no_search_for_series: true,
};
let result = SonarrAddCommandHandler::with(&app_arc, add_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let add_tag_command = SonarrAddCommand::Tag {
name: expected_tag_name,
};
let result = SonarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+156
View File
@@ -0,0 +1,156 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::sonarr_models::DeleteSeriesParams,
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "delete_command_handler_tests.rs"]
mod delete_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrDeleteCommand {
#[command(about = "Delete the specified item from the Sonarr 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 download")]
Download {
#[arg(long, help = "The ID of the download to delete", required = true)]
download_id: i64,
},
#[command(about = "Delete the specified episode file from disk")]
EpisodeFile {
#[arg(long, help = "The ID of the episode file to delete", required = true)]
episode_file_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 a series from your Sonarr library")]
Series {
#[arg(long, help = "The ID of the series to delete", required = true)]
series_id: i64,
#[arg(long, help = "Delete the series files from disk as well")]
delete_files_from_disk: bool,
#[arg(long, help = "Add a list exclusion for this series")]
add_list_exclusion: bool,
},
#[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<SonarrDeleteCommand> for Command {
fn from(value: SonarrDeleteCommand) -> Self {
Command::Sonarr(SonarrCommand::Delete(value))
}
}
pub(super) struct SonarrDeleteCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrDeleteCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let resp = match self.command {
SonarrDeleteCommand::BlocklistItem { blocklist_item_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDeleteCommand::Download { download_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteDownload(Some(download_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDeleteCommand::EpisodeFile { episode_file_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteEpisodeFile(Some(episode_file_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDeleteCommand::Indexer { indexer_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteIndexer(Some(indexer_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDeleteCommand::RootFolder { root_folder_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteRootFolder(Some(root_folder_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDeleteCommand::Series {
series_id,
delete_files_from_disk,
add_list_exclusion,
} => {
let delete_series_params = DeleteSeriesParams {
id: series_id,
delete_series_files: delete_files_from_disk,
add_list_exclusion,
};
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteSeries(Some(delete_series_params)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDeleteCommand::Tag { tag_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::DeleteTag(tag_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(resp)
}
}
@@ -0,0 +1,466 @@
#[cfg(test)]
mod tests {
use crate::{
cli::{
sonarr::{delete_command_handler::SonarrDeleteCommand, SonarrCommand},
Command,
},
Cli,
};
use clap::{error::ErrorKind, CommandFactory, Parser};
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_delete_command_from() {
let command = SonarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Delete(command)));
}
mod cli {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_delete_blocklist_item_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "blocklist-item"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_blocklist_item_success() {
let expected_args = SonarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"delete",
"blocklist-item",
"--blocklist-item-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_download_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "download"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_download_success() {
let expected_args = SonarrDeleteCommand::Download { download_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"delete",
"download",
"--download-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_episode_file_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "episode-file"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_episode_file_success() {
let expected_args = SonarrDeleteCommand::EpisodeFile { episode_file_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"delete",
"episode-file",
"--episode-file-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "indexer"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_indexer_success() {
let expected_args = SonarrDeleteCommand::Indexer { indexer_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"delete",
"indexer",
"--indexer-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_root_folder_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "root-folder"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_root_folder_success() {
let expected_args = SonarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"delete",
"root-folder",
"--root-folder-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_series_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "series"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_series_defaults() {
let expected_args = SonarrDeleteCommand::Series {
series_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result =
Cli::try_parse_from(["managarr", "sonarr", "delete", "series", "--series-id", "1"]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_series_all_args_defined() {
let expected_args = SonarrDeleteCommand::Series {
series_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"delete",
"series",
"--series-id",
"1",
"--delete-files-from-disk",
"--add-list-exclusion",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
assert_eq!(delete_command, expected_args);
}
}
#[test]
fn test_delete_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "tag"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_tag_success() {
let expected_args = SonarrDeleteCommand::Tag { tag_id: 1 };
let result = Cli::try_parse_from(["managarr", "sonarr", "delete", "tag", "--tag-id", "1"]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command
{
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::{
sonarr::delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler},
CliCommandHandler,
},
models::{
sonarr_models::{DeleteSeriesParams, SonarrSerdeable},
Serdeable,
},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[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>(
SonarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_blocklist_item_command = SonarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = SonarrDeleteCommandHandler::with(
&app_arc,
delete_blocklist_item_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::DeleteDownload(Some(expected_download_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_download_command = SonarrDeleteCommand::Download { download_id: 1 };
let result =
SonarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::DeleteIndexer(Some(expected_indexer_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_indexer_command = SonarrDeleteCommand::Indexer { indexer_id: 1 };
let result =
SonarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::DeleteRootFolder(Some(expected_root_folder_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_root_folder_command = SonarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result =
SonarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_delete_series_command() {
let expected_delete_series_params = DeleteSeriesParams {
id: 1,
delete_series_files: true,
add_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::DeleteSeries(Some(expected_delete_series_params)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_series_command = SonarrDeleteCommand::Series {
series_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result =
SonarrDeleteCommandHandler::with(&app_arc, delete_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::DeleteTag(expected_tag_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_tag_command = SonarrDeleteCommand::Tag { tag_id: 1 };
let result =
SonarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+169
View File
@@ -0,0 +1,169 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::sonarr_models::SonarrReleaseDownloadBody,
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "download_command_handler_tests.rs"]
mod download_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrDownloadCommand {
#[command(about = "Manually download the given series release for the specified series ID")]
Series {
#[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,
#[arg(
long,
help = "The series ID that the release is associated with",
required = true
)]
series_id: i64,
},
#[command(
about = "Manually download the given season release corresponding to the series specified with the series ID"
)]
Season {
#[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,
#[arg(
long,
help = "The series ID that the release is associated with",
required = true
)]
series_id: i64,
#[arg(
long,
help = "The season number that the release corresponds to",
required = true
)]
season_number: i64,
},
#[command(about = "Manually download the given episode release for the specified episode ID")]
Episode {
#[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,
#[arg(
long,
help = "The episode ID that the release is associated with",
required = true
)]
episode_id: i64,
},
}
impl From<SonarrDownloadCommand> for Command {
fn from(value: SonarrDownloadCommand) -> Self {
Command::Sonarr(SonarrCommand::Download(value))
}
}
pub(super) struct SonarrDownloadCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrDownloadCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDownloadCommand>
for SonarrDownloadCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrDownloadCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrDownloadCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrDownloadCommand::Series {
guid,
indexer_id,
series_id,
} => {
let params = SonarrReleaseDownloadBody {
guid,
indexer_id,
series_id: Some(series_id),
..SonarrReleaseDownloadBody::default()
};
let resp = self
.network
.handle_network_event(SonarrEvent::DownloadRelease(params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDownloadCommand::Season {
guid,
indexer_id,
series_id,
season_number,
} => {
let params = SonarrReleaseDownloadBody {
guid,
indexer_id,
series_id: Some(series_id),
season_number: Some(season_number),
..SonarrReleaseDownloadBody::default()
};
let resp = self
.network
.handle_network_event(SonarrEvent::DownloadRelease(params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrDownloadCommand::Episode {
guid,
indexer_id,
episode_id,
} => {
let params = SonarrReleaseDownloadBody {
guid,
indexer_id,
episode_id: Some(episode_id),
..SonarrReleaseDownloadBody::default()
};
let resp = self
.network
.handle_network_event(SonarrEvent::DownloadRelease(params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,423 @@
#[cfg(test)]
mod tests {
use crate::{
cli::{
sonarr::{download_command_handler::SonarrDownloadCommand, SonarrCommand},
Command,
},
Cli,
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_download_command_from() {
let command = SonarrDownloadCommand::Series {
guid: "Test".to_owned(),
indexer_id: 1,
series_id: 1,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Download(command)));
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_download_series_requires_series_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"series",
"--indexer-id",
"1",
"--guid",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_series_requires_guid() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"series",
"--indexer-id",
"1",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_series_requires_indexer_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"series",
"--guid",
"1",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_series_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"series",
"--guid",
"1",
"--series-id",
"1",
"--indexer-id",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_download_season_requires_series_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"season",
"--indexer-id",
"1",
"--season-number",
"1",
"--guid",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_season_requires_season_number() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"season",
"--indexer-id",
"1",
"--series-id",
"1",
"--guid",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_season_requires_guid() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"season",
"--indexer-id",
"1",
"--season-number",
"1",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_season_requires_indexer_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"season",
"--guid",
"1",
"--season-number",
"1",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_season_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"season",
"--guid",
"1",
"--series-id",
"1",
"--season-number",
"1",
"--indexer-id",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_download_episode_requires_episode_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"episode",
"--indexer-id",
"1",
"--guid",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_episode_requires_guid() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"episode",
"--indexer-id",
"1",
"--episode-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_episode_requires_indexer_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"episode",
"--guid",
"1",
"--episode-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_episode_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"download",
"episode",
"--guid",
"1",
"--episode-id",
"1",
"--indexer-id",
"1",
]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler},
CliCommandHandler,
},
models::{
sonarr_models::{SonarrReleaseDownloadBody, SonarrSerdeable},
Serdeable,
},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_download_series_release_command() {
let expected_release_download_body = SonarrReleaseDownloadBody {
guid: "guid".to_owned(),
indexer_id: 1,
series_id: Some(1),
..SonarrReleaseDownloadBody::default()
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::DownloadRelease(expected_release_download_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let download_release_command = SonarrDownloadCommand::Series {
guid: "guid".to_owned(),
indexer_id: 1,
series_id: 1,
};
let result =
SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_download_season_release_command() {
let expected_release_download_body = SonarrReleaseDownloadBody {
guid: "guid".to_owned(),
indexer_id: 1,
series_id: Some(1),
season_number: Some(1),
..SonarrReleaseDownloadBody::default()
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::DownloadRelease(expected_release_download_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let download_release_command = SonarrDownloadCommand::Season {
guid: "guid".to_owned(),
indexer_id: 1,
series_id: 1,
season_number: 1,
};
let result =
SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_download_episode_release_command() {
let expected_release_download_body = SonarrReleaseDownloadBody {
guid: "guid".to_owned(),
indexer_id: 1,
episode_id: Some(1),
..SonarrReleaseDownloadBody::default()
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::DownloadRelease(expected_release_download_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let download_release_command = SonarrDownloadCommand::Episode {
guid: "guid".to_owned(),
indexer_id: 1,
episode_id: 1,
};
let result =
SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+363
View File
@@ -0,0 +1,363 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, ArgGroup, Subcommand};
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{mutex_flags_or_option, CliCommandHandler, Command},
models::{
servarr_models::EditIndexerParams,
sonarr_models::{EditSeriesParams, IndexerSettings, SeriesType, SonarrSerdeable},
Serdeable,
},
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "edit_command_handler_tests.rs"]
mod edit_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrEditCommand {
#[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 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 Sonarr that this indexer should be used when Sonarr periodically looks for releases via RSS Sync",
conflicts_with = "disable_rss"
)]
enable_rss: bool,
#[arg(
long,
help = "Disable using this indexer when Sonarr periodically looks for releases via RSS Sync",
conflicts_with = "enable_rss"
)]
disable_rss: bool,
#[arg(
long,
help = "Indicate to Sonarr that this indexer should be used when automatic searches are performed via the UI or by Sonarr",
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 Sonarr",
conflicts_with = "enable_automatic_search"
)]
disable_automatic_search: bool,
#[arg(
long,
help = "Indicate to Sonarr 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, Sonarr 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,
},
#[command(
about = "Edit preferences for the specified series",
group(
ArgGroup::new("edit_series")
.args([
"enable_monitoring",
"disable_monitoring",
"enable_season_folders",
"disable_season_folders",
"series_type",
"quality_profile_id",
"language_profile_id",
"root_folder_path",
"tag",
"clear_tags"
]).required(true)
.multiple(true))
)]
Series {
#[arg(
long,
help = "The ID of the series whose settings you want to edit",
required = true
)]
series_id: i64,
#[arg(
long,
help = "Enable monitoring of this series in Sonarr so Sonarr will automatically download this series if it is available",
conflicts_with = "disable_monitoring"
)]
enable_monitoring: bool,
#[arg(
long,
help = "Disable monitoring of this series so Sonarr does not automatically download the series if it is found to be available",
conflicts_with = "enable_monitoring"
)]
disable_monitoring: bool,
#[arg(
long,
help = "The minimum availability to monitor for this film",
value_enum
)]
#[arg(
long,
help = "Enable sorting episodes of this series into season folders",
conflicts_with = "disable_season_folders"
)]
enable_season_folders: bool,
#[arg(
long,
help = "Disable sorting episodes of this series into season folders",
conflicts_with = "enable_season_folders"
)]
disable_season_folders: bool,
#[arg(long, help = "The type of series", value_enum)]
series_type: Option<SeriesType>,
#[arg(long, help = "The ID of the quality profile to use for this series")]
quality_profile_id: Option<i64>,
#[arg(long, help = "The ID of the language profile to use for this series")]
language_profile_id: Option<i64>,
#[arg(
long,
help = "The root folder path where all film data and metadata should live"
)]
root_folder_path: Option<String>,
#[arg(
long,
help = "Tag IDs to tag this series with",
value_parser,
action = ArgAction::Append,
conflicts_with = "clear_tags"
)]
tag: Option<Vec<i64>>,
#[arg(long, help = "Clear all tags on this series", conflicts_with = "tag")]
clear_tags: bool,
},
}
impl From<SonarrEditCommand> for Command {
fn from(value: SonarrEditCommand) -> Self {
Command::Sonarr(SonarrCommand::Edit(value))
}
}
pub(super) struct SonarrEditCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrEditCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrEditCommand> for SonarrEditCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrEditCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrEditCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrEditCommand::AllIndexerSettings {
maximum_size,
minimum_age,
retention,
rss_sync_interval,
} => {
if let Serdeable::Sonarr(SonarrSerdeable::IndexerSettings(previous_indexer_settings)) = self
.network
.handle_network_event(SonarrEvent::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(SonarrEvent::EditAllIndexerSettings(Some(params)).into())
.await?;
"All indexer settings updated".to_owned()
} else {
String::new()
}
}
SonarrEditCommand::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,
priority,
clear_tags,
};
self
.network
.handle_network_event(SonarrEvent::EditIndexer(Some(edit_indexer_params)).into())
.await?;
"Indexer updated".to_owned()
}
SonarrEditCommand::Series {
series_id,
enable_monitoring,
disable_monitoring,
enable_season_folders,
disable_season_folders,
series_type,
quality_profile_id,
language_profile_id,
root_folder_path,
tag,
clear_tags,
} => {
let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring);
let season_folders_value =
mutex_flags_or_option(enable_season_folders, disable_season_folders);
let edit_series_params = EditSeriesParams {
series_id,
monitored: monitored_value,
use_season_folders: season_folders_value,
series_type,
quality_profile_id,
language_profile_id,
root_folder_path,
tags: tag,
clear_tags,
};
self
.network
.handle_network_event(SonarrEvent::EditSeries(Some(edit_series_params)).into())
.await?;
"Series Updated".to_owned()
}
};
Ok(result)
}
}
@@ -0,0 +1,874 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{edit_command_handler::SonarrEditCommand, SonarrCommand},
Command,
};
#[test]
fn test_sonarr_edit_command_from() {
let command = SonarrEditCommand::AllIndexerSettings {
maximum_size: None,
minimum_age: None,
retention: None,
rss_sync_interval: None,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Edit(command)));
}
mod cli {
use crate::{models::sonarr_models::SeriesType, Cli};
use super::*;
use clap::{error::ErrorKind, CommandFactory, Parser};
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", "sonarr", "edit", "all-indexer-settings"]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"all-indexer-settings",
flag,
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() {
let expected_args = SonarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: None,
retention: None,
rss_sync_interval: None,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"edit",
"all-indexer-settings",
"--maximum-size",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_all_indexer_settings_all_arguments_defined() {
let expected_args = SonarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"edit",
"all-indexer-settings",
"--maximum-size",
"1",
"--minimum-age",
"1",
"--retention",
"1",
"--rss-sync-interval",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "indexer"]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-rss",
"--disable-rss",
]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-automatic-search",
"--disable-automatic-search",
]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-interactive-search",
"--disable-interactive-search",
]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert!(result.is_err());
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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
flag,
]);
assert!(result.is_err());
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 = SonarrEditCommand::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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--name",
"Test",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_indexer_tag_argument_is_repeatable() {
let expected_args = SonarrEditCommand::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",
"sonarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_indexer_all_arguments_defined() {
let expected_args = SonarrEditCommand::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",
"sonarr",
"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!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_series_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "series"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_series_with_series_id_still_requires_arguments() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_series_monitoring_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--enable-monitoring",
"--disable-monitoring",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_series_season_folders_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--enable-season-folders",
"--disable-season-folders",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_series_tag_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[rstest]
fn test_edit_series_assert_argument_flags_require_args(
#[values(
"--series-type",
"--quality-profile-id",
"--language-profile-id",
"--root-folder-path",
"--tag"
)]
flag: &str,
) {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
flag,
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_series_series_type_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--series-type",
"test",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_series_only_requires_at_least_one_argument_plus_series_id() {
let expected_args = SonarrEditCommand::Series {
series_id: 1,
enable_monitoring: false,
disable_monitoring: false,
enable_season_folders: false,
disable_season_folders: false,
series_type: None,
quality_profile_id: None,
language_profile_id: None,
root_folder_path: Some("/nfs/test".to_owned()),
tag: None,
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--root-folder-path",
"/nfs/test",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_series_tag_argument_is_repeatable() {
let expected_args = SonarrEditCommand::Series {
series_id: 1,
enable_monitoring: false,
disable_monitoring: false,
enable_season_folders: false,
disable_season_folders: false,
series_type: None,
quality_profile_id: None,
language_profile_id: None,
root_folder_path: None,
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
assert_eq!(edit_command, expected_args);
}
}
#[test]
fn test_edit_series_all_arguments_defined() {
let expected_args = SonarrEditCommand::Series {
series_id: 1,
enable_monitoring: true,
disable_monitoring: false,
enable_season_folders: true,
disable_season_folders: false,
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_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",
"sonarr",
"edit",
"series",
"--series-id",
"1",
"--enable-monitoring",
"--enable-season-folders",
"--series-type",
"anime",
"--quality-profile-id",
"1",
"--language-profile-id",
"1",
"--root-folder-path",
"/nfs/test",
"--tag",
"1",
"--tag",
"2",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command {
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::{
app::App,
cli::{
sonarr::edit_command_handler::{SonarrEditCommand, SonarrEditCommandHandler},
CliCommandHandler,
},
models::{
servarr_models::EditIndexerParams,
sonarr_models::{EditSeriesParams, IndexerSettings, SeriesType, SonarrSerdeable},
Serdeable,
},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[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>(
SonarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::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>(
SonarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let edit_all_indexer_settings_command = SonarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
};
let result = SonarrEditCommandHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[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]),
priority: Some(25),
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::EditIndexer(Some(expected_edit_indexer_params)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let edit_indexer_command = SonarrEditCommand::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 =
SonarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_edit_series_command() {
let expected_edit_series_params = EditSeriesParams {
series_id: 1,
monitored: Some(true),
use_season_folders: Some(true),
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let edit_series_command = SonarrEditCommand::Series {
series_id: 1,
enable_monitoring: true,
disable_monitoring: false,
enable_season_folders: true,
disable_season_folders: false,
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_edit_series_command_handles_disable_monitoring_flag_properly() {
let expected_edit_series_params = EditSeriesParams {
series_id: 1,
monitored: Some(false),
use_season_folders: Some(false),
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let edit_series_command = SonarrEditCommand::Series {
series_id: 1,
enable_monitoring: false,
disable_monitoring: true,
enable_season_folders: false,
disable_season_folders: true,
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_edit_series_command_no_monitoring_boolean_flags_returns_none_value() {
let expected_edit_series_params = EditSeriesParams {
series_id: 1,
monitored: None,
use_season_folders: None,
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let edit_series_command = SonarrEditCommand::Series {
series_id: 1,
enable_monitoring: false,
disable_monitoring: false,
enable_season_folders: false,
disable_season_folders: false,
series_type: Some(SeriesType::Anime),
quality_profile_id: Some(1),
language_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+122
View File
@@ -0,0 +1,122 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "get_command_handler_tests.rs"]
mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrGetCommand {
#[command(about = "Get the shared settings for all indexers")]
AllIndexerSettings,
#[command(about = "Get detailed information for the episode with the given ID")]
EpisodeDetails {
#[arg(
long,
help = "The Sonarr ID of the episode whose details you wish to fetch",
required = true
)]
episode_id: i64,
},
#[command(about = "Fetch the host config for your Sonarr instance")]
HostConfig,
#[command(about = "Fetch the security config for your Sonarr instance")]
SecurityConfig,
#[command(about = "Get detailed information for the series with the given ID")]
SeriesDetails {
#[arg(
long,
help = "The Sonarr ID of the series whose details you wish to fetch",
required = true
)]
series_id: i64,
},
#[command(about = "Get the system status")]
SystemStatus,
}
impl From<SonarrGetCommand> for Command {
fn from(value: SonarrGetCommand) -> Self {
Command::Sonarr(SonarrCommand::Get(value))
}
}
pub(super) struct SonarrGetCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrGetCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrGetCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrGetCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrGetCommand::AllIndexerSettings => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetAllIndexerSettings.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrGetCommand::EpisodeDetails { episode_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetEpisodeDetails(Some(episode_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrGetCommand::HostConfig => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetHostConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrGetCommand::SecurityConfig => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetSecurityConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrGetCommand::SeriesDetails { series_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetSeriesDetails(Some(series_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrGetCommand::SystemStatus => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetStatus.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+277
View File
@@ -0,0 +1,277 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{get_command_handler::SonarrGetCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_get_command_from() {
let command = SonarrGetCommand::SystemStatus;
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Get(command)));
}
mod cli {
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_all_indexer_settings_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "all-indexer-settings"]);
assert!(result.is_ok());
}
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "system-status"]);
assert!(result.is_ok());
}
#[test]
fn test_episode_details_requires_episode_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "episode-details"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_episode_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"get",
"episode-details",
"--episode-id",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_get_host_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "host-config"]);
assert!(result.is_ok());
}
#[test]
fn test_get_security_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "security-config"]);
assert!(result.is_ok());
}
#[test]
fn test_series_details_requires_series_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "series-details"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_series_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"get",
"series-details",
"--series-id",
"1",
]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler},
CliCommandHandler,
},
models::{sonarr_models::SonarrSerdeable, Serdeable},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[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>(
SonarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_all_indexer_settings_command = SonarrGetCommand::AllIndexerSettings;
let result = SonarrGetCommandHandler::with(
&app_arc,
get_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_get_episode_details_command() {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetEpisodeDetails(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_episode_details_command = SonarrGetCommand::EpisodeDetails { episode_id: 1 };
let result =
SonarrGetCommandHandler::with(&app_arc, get_episode_details_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(SonarrEvent::GetHostConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_host_config_command = SonarrGetCommand::HostConfig;
let result =
SonarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(SonarrEvent::GetSecurityConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_security_config_command = SonarrGetCommand::SecurityConfig;
let result =
SonarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_get_series_details_command() {
let expected_series_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetSeriesDetails(Some(expected_series_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_series_details_command = SonarrGetCommand::SeriesDetails { series_id: 1 };
let result =
SonarrGetCommandHandler::with(&app_arc, get_series_details_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(SonarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_system_status_command = SonarrGetCommand::SystemStatus;
let result =
SonarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+249
View File
@@ -0,0 +1,249 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "list_command_handler_tests.rs"]
mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrListCommand {
#[command(about = "List all items in the Sonarr blocklist")]
Blocklist,
#[command(about = "List all active downloads in Sonarr")]
Downloads,
#[command(about = "List disk space details for all provisioned root folders in Sonarr")]
DiskSpace,
#[command(about = "List the episodes for the series with the given ID")]
Episodes {
#[arg(
long,
help = "The Sonarr ID of the series whose episodes you wish to fetch",
required = true
)]
series_id: i64,
},
#[command(about = "Fetch all history events for the episode with the given ID")]
EpisodeHistory {
#[arg(
long,
help = "The Sonarr ID of the episode whose history you wish to fetch",
required = true
)]
episode_id: i64,
},
#[command(about = "Fetch all Sonarr history events")]
History {
#[arg(long, help = "How many history events to fetch", default_value_t = 500)]
events: u64,
},
#[command(about = "List all Sonarr indexers")]
Indexers,
#[command(about = "List all Sonarr language profiles")]
LanguageProfiles,
#[command(about = "Fetch Sonarr 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 Sonarr quality profiles")]
QualityProfiles,
#[command(about = "List all queued events")]
QueuedEvents,
#[command(about = "List all root folders in Sonarr")]
RootFolders,
#[command(about = "List all series in your Sonarr library")]
Series,
#[command(about = "Fetch all history events for the series with the given ID")]
SeriesHistory {
#[arg(
long,
help = "The Sonarr ID of the series whose history you wish to fetch",
required = true
)]
series_id: i64,
},
#[command(about = "List all Sonarr tags")]
Tags,
#[command(about = "List all Sonarr tasks")]
Tasks,
#[command(about = "List all Sonarr updates")]
Updates,
}
impl From<SonarrListCommand> for Command {
fn from(value: SonarrListCommand) -> Self {
Command::Sonarr(SonarrCommand::List(value))
}
}
pub(super) struct SonarrListCommandHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrListCommandHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrListCommand::Blocklist => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Downloads => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetDownloads.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::DiskSpace => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetDiskSpace.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Episodes { series_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetEpisodes(Some(series_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::EpisodeHistory { episode_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetEpisodeHistory(Some(episode_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::History { events: items } => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetHistory(Some(items)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Indexers => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::LanguageProfiles => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetLanguageProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Logs {
events,
output_in_log_format,
} => {
let logs = self
.network
.handle_network_event(SonarrEvent::GetLogs(Some(events)).into())
.await?;
if output_in_log_format {
let log_lines = self.app.lock().await.data.sonarr_data.logs.items.clone();
serde_json::to_string_pretty(&log_lines)?
} else {
serde_json::to_string_pretty(&logs)?
}
}
SonarrListCommand::QualityProfiles => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetQualityProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::QueuedEvents => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetQueuedEvents.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::RootFolders => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetRootFolders.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Series => {
let resp = self
.network
.handle_network_event(SonarrEvent::ListSeries.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::SeriesHistory { series_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetSeriesHistory(Some(series_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Tags => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetTags.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Tasks => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetTasks.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrListCommand::Updates => {
let resp = self
.network
.handle_network_event(SonarrEvent::GetUpdates.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,372 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{list_command_handler::SonarrListCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_list_command_from() {
let command = SonarrListCommand::Series;
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::List(command)));
}
mod cli {
use super::*;
use clap::{error::ErrorKind, Parser};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_list_commands_have_no_arg_requirements(
#[values(
"blocklist",
"series",
"downloads",
"disk-space",
"quality-profiles",
"indexers",
"queued-events",
"root-folders",
"tags",
"tasks",
"updates",
"language-profiles"
)]
subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]);
assert!(result.is_ok());
}
#[test]
fn test_list_episodes_requires_series_id() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episodes"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_episode_history_requires_series_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episode-history"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_episode_history_success() {
let expected_args = SonarrListCommand::EpisodeHistory { episode_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"list",
"episode-history",
"--episode-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::List(episode_history_command))) =
result.unwrap().command
{
assert_eq!(episode_history_command, expected_args);
}
}
#[test]
fn test_list_history_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "history", "--events"]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_history_default_values() {
let expected_args = SonarrListCommand::History { events: 500 };
let result = Cli::try_parse_from(["managarr", "sonarr", "list", "history"]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::List(history_command))) = result.unwrap().command {
assert_eq!(history_command, expected_args);
}
}
#[test]
fn test_list_logs_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "logs", "--events"]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_logs_default_values() {
let expected_args = SonarrListCommand::Logs {
events: 500,
output_in_log_format: false,
};
let result = Cli::try_parse_from(["managarr", "sonarr", "list", "logs"]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::List(logs_command))) = result.unwrap().command {
assert_eq!(logs_command, expected_args);
}
}
#[test]
fn test_list_episodes_success() {
let expected_args = SonarrListCommand::Episodes { series_id: 1 };
let result =
Cli::try_parse_from(["managarr", "sonarr", "list", "episodes", "--series-id", "1"]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::List(episodes_command))) = result.unwrap().command
{
assert_eq!(episodes_command, expected_args);
}
}
#[test]
fn test_list_series_history_requires_series_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series-history"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_series_history_success() {
let expected_args = SonarrListCommand::SeriesHistory { series_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"list",
"series-history",
"--series-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::List(series_command))) = result.unwrap().command {
assert_eq!(series_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::cli::sonarr::list_command_handler::{SonarrListCommand, SonarrListCommandHandler};
use crate::cli::CliCommandHandler;
use crate::models::sonarr_models::SonarrSerdeable;
use crate::models::Serdeable;
use crate::network::sonarr_network::SonarrEvent;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)]
#[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)]
#[case(SonarrListCommand::DiskSpace, SonarrEvent::GetDiskSpace)]
#[case(SonarrListCommand::Indexers, SonarrEvent::GetIndexers)]
#[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)]
#[case(SonarrListCommand::QueuedEvents, SonarrEvent::GetQueuedEvents)]
#[case(SonarrListCommand::RootFolders, SonarrEvent::GetRootFolders)]
#[case(SonarrListCommand::Series, SonarrEvent::ListSeries)]
#[case(SonarrListCommand::Tags, SonarrEvent::GetTags)]
#[case(SonarrListCommand::Tasks, SonarrEvent::GetTasks)]
#[case(SonarrListCommand::Updates, SonarrEvent::GetUpdates)]
#[case(SonarrListCommand::LanguageProfiles, SonarrEvent::GetLanguageProfiles)]
#[tokio::test]
async fn test_handle_list_command(
#[case] list_command: SonarrListCommand,
#[case] expected_sonarr_event: SonarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_sonarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let result = SonarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_list_episodes_command() {
let expected_series_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetEpisodes(Some(expected_series_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_episodes_command = SonarrListCommand::Episodes { series_id: 1 };
let result =
SonarrListCommandHandler::with(&app_arc, list_episodes_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::GetHistory(Some(expected_events)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_history_command = SonarrListCommand::History { events: 1000 };
let result =
SonarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::GetLogs(Some(expected_events)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_logs_command = SonarrListCommand::Logs {
events: 1000,
output_in_log_format: false,
};
let result = SonarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_list_series_history_command() {
let expected_series_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetSeriesHistory(Some(expected_series_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_series_history_command = SonarrListCommand::SeriesHistory { series_id: 1 };
let result =
SonarrListCommandHandler::with(&app_arc, list_series_history_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_list_episode_history_command() {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetEpisodeHistory(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_episode_history_command = SonarrListCommand::EpisodeHistory { episode_id: 1 };
let result =
SonarrListCommandHandler::with(&app_arc, list_episode_history_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
@@ -0,0 +1,99 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "manual_search_command_handler_tests.rs"]
mod manual_search_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrManualSearchCommand {
#[command(about = "Trigger a manual search of releases for the episode with the given ID")]
Episode {
#[arg(
long,
help = "The Sonarr ID of the episode whose releases you wish to fetch and list",
required = true
)]
episode_id: i64,
},
#[command(
about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID.\nNote that when downloading a season release, ensure that the release includes 'fullSeason: true', otherwise you'll run into issues"
)]
Season {
#[arg(
long,
help = "The Sonarr ID of the series whose releases you wish to fetch and list",
required = true
)]
series_id: i64,
#[arg(long, help = "The season number to search for", required = true)]
season_number: i64,
},
}
impl From<SonarrManualSearchCommand> for Command {
fn from(value: SonarrManualSearchCommand) -> Self {
Command::Sonarr(SonarrCommand::ManualSearch(value))
}
}
pub(super) struct SonarrManualSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrManualSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrManualSearchCommand>
for SonarrManualSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrManualSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrManualSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrManualSearchCommand::Episode { episode_id } => {
println!("Searching for episode releases. This may take a minute...");
let resp = self
.network
.handle_network_event(SonarrEvent::GetEpisodeReleases(Some(episode_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrManualSearchCommand::Season {
series_id,
season_number,
} => {
println!("Searching for season releases. This may take a minute...");
let resp = self
.network
.handle_network_event(
SonarrEvent::GetSeasonReleases(Some((series_id, season_number))).into(),
)
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,188 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{manual_search_command_handler::SonarrManualSearchCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_manual_search_command_from() {
let command = SonarrManualSearchCommand::Episode { episode_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Sonarr(SonarrCommand::ManualSearch(command))
);
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_manual_season_search_requires_series_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"manual-search",
"season",
"--season-number",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_season_search_requires_season_number() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"manual-search",
"season",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_season_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"manual-search",
"season",
"--series-id",
"1",
"--season-number",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_manual_episode_search_requires_episode_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "manual-search", "episode"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_episode_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"manual-search",
"episode",
"--episode-id",
"1",
]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::manual_search_command_handler::{
SonarrManualSearchCommand, SonarrManualSearchCommandHandler,
},
CliCommandHandler,
},
models::{sonarr_models::SonarrSerdeable, Serdeable},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_manual_episode_search_command() {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let manual_episode_search_command = SonarrManualSearchCommand::Episode { episode_id: 1 };
let result = SonarrManualSearchCommandHandler::with(
&app_arc,
manual_episode_search_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_manual_season_search_command() {
let expected_series_id = 1;
let expected_season_number = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetSeasonReleases(Some((expected_series_id, expected_season_number))).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let manual_season_search_command = SonarrManualSearchCommand::Season {
series_id: 1,
season_number: 1,
};
let result = SonarrManualSearchCommandHandler::with(
&app_arc,
manual_season_search_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+252
View File
@@ -0,0 +1,252 @@
use std::sync::Arc;
use add_command_handler::{SonarrAddCommand, SonarrAddCommandHandler};
use anyhow::Result;
use clap::Subcommand;
use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler};
use download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler};
use edit_command_handler::{SonarrEditCommand, SonarrEditCommandHandler};
use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler};
use list_command_handler::{SonarrListCommand, SonarrListCommandHandler};
use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler};
use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler};
use tokio::sync::Mutex;
use trigger_automatic_search_command_handler::{
SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler,
};
use crate::{
app::App,
models::sonarr_models::SonarrTaskName,
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::{CliCommandHandler, Command};
mod add_command_handler;
mod delete_command_handler;
mod download_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 = "sonarr_command_tests.rs"]
mod sonarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrCommand {
#[command(
subcommand,
about = "Commands to add or create new resources within your Sonarr instance"
)]
Add(SonarrAddCommand),
#[command(
subcommand,
about = "Commands to delete resources from your Sonarr instance"
)]
Delete(SonarrDeleteCommand),
#[command(
subcommand,
about = "Commands to edit resources in your Sonarr instance"
)]
Edit(SonarrEditCommand),
#[command(
subcommand,
about = "Commands to fetch details of the resources in your Sonarr instance"
)]
Get(SonarrGetCommand),
#[command(
subcommand,
about = "Commands to download releases in your Sonarr instance"
)]
Download(SonarrDownloadCommand),
#[command(
subcommand,
about = "Commands to list attributes from your Sonarr instance"
)]
List(SonarrListCommand),
#[command(
subcommand,
about = "Commands to refresh the data in your Sonarr instance"
)]
Refresh(SonarrRefreshCommand),
#[command(subcommand, about = "Commands to manually search for releases")]
ManualSearch(SonarrManualSearchCommand),
#[command(
subcommand,
about = "Commands to trigger automatic searches for releases of different resources in your Sonarr instance"
)]
TriggerAutomaticSearch(SonarrTriggerAutomaticSearchCommand),
#[command(about = "Clear the blocklist")]
ClearBlocklist,
#[command(about = "Mark the Sonarr history item with the given ID as 'failed'")]
MarkHistoryItemAsFailed {
#[arg(
long,
help = "The Sonarr ID of the history item you wish to mark as 'failed'",
required = true
)]
history_item_id: i64,
},
#[command(about = "Search for a new series to add to Sonarr")]
SearchNewSeries {
#[arg(
long,
help = "The title of the series you want to search for",
required = true
)]
query: String,
},
#[command(about = "Start the specified Sonarr task")]
StartTask {
#[arg(
long,
help = "The name of the task to trigger",
value_enum,
required = true
)]
task_name: SonarrTaskName,
},
#[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 Sonarr indexers")]
TestAllIndexers,
}
impl From<SonarrCommand> for Command {
fn from(sonarr_command: SonarrCommand) -> Command {
Command::Sonarr(sonarr_command)
}
}
pub(super) struct SonarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: SonarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrCommand::Add(add_command) => {
SonarrAddCommandHandler::with(self.app, add_command, self.network)
.handle()
.await?
}
SonarrCommand::Delete(delete_command) => {
SonarrDeleteCommandHandler::with(self.app, delete_command, self.network)
.handle()
.await?
}
SonarrCommand::Edit(edit_command) => {
SonarrEditCommandHandler::with(self.app, edit_command, self.network)
.handle()
.await?
}
SonarrCommand::Download(download_command) => {
SonarrDownloadCommandHandler::with(self.app, download_command, self.network)
.handle()
.await?
}
SonarrCommand::Get(get_command) => {
SonarrGetCommandHandler::with(self.app, get_command, self.network)
.handle()
.await?
}
SonarrCommand::List(list_command) => {
SonarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
.await?
}
SonarrCommand::Refresh(refresh_command) => {
SonarrRefreshCommandHandler::with(self.app, refresh_command, self.network)
.handle()
.await?
}
SonarrCommand::ManualSearch(manual_search_command) => {
SonarrManualSearchCommandHandler::with(self.app, manual_search_command, self.network)
.handle()
.await?
}
SonarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => {
SonarrTriggerAutomaticSearchCommandHandler::with(
self.app,
trigger_automatic_search_command,
self.network,
)
.handle()
.await?
}
SonarrCommand::ClearBlocklist => {
self
.network
.handle_network_event(SonarrEvent::GetBlocklist.into())
.await?;
let resp = self
.network
.handle_network_event(SonarrEvent::ClearBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrCommand::MarkHistoryItemAsFailed { history_item_id } => {
let _ = self
.network
.handle_network_event(SonarrEvent::MarkHistoryItemAsFailed(history_item_id).into())
.await?;
"Sonarr history item marked as 'failed'".to_owned()
}
SonarrCommand::SearchNewSeries { query } => {
let resp = self
.network
.handle_network_event(SonarrEvent::SearchNewSeries(Some(query)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrCommand::StartTask { task_name } => {
let resp = self
.network
.handle_network_event(SonarrEvent::StartTask(Some(task_name)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrCommand::TestIndexer { indexer_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::TestIndexer(Some(indexer_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrCommand::TestAllIndexers => {
println!("Testing all Sonarr indexers. This may take a minute...");
let resp = self
.network
.handle_network_event(SonarrEvent::TestAllIndexers.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::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[cfg(test)]
#[path = "refresh_command_handler_tests.rs"]
mod refresh_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrRefreshCommand {
#[command(about = "Refresh all series data for all series in your Sonarr library")]
AllSeries,
#[command(about = "Refresh series data and scan disk for the series with the given ID")]
Series {
#[arg(
long,
help = "The ID of the series to refresh information on and to scan the disk for",
required = true
)]
series_id: i64,
},
#[command(about = "Refresh all downloads in Sonarr")]
Downloads,
}
impl From<SonarrRefreshCommand> for Command {
fn from(value: SonarrRefreshCommand) -> Self {
Command::Sonarr(SonarrCommand::Refresh(value))
}
}
pub(super) struct SonarrRefreshCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrRefreshCommand>
for SonarrRefreshCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrRefreshCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> anyhow::Result<String> {
let result = match self.command {
SonarrRefreshCommand::AllSeries => {
let resp = self
.network
.handle_network_event(SonarrEvent::UpdateAllSeries.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrRefreshCommand::Series { series_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::UpdateAndScanSeries(Some(series_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrRefreshCommand::Downloads => {
let resp = self
.network
.handle_network_event(SonarrEvent::UpdateDownloads.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,141 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::cli::{
sonarr::{refresh_command_handler::SonarrRefreshCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
#[test]
fn test_sonarr_refresh_command_from() {
let command = SonarrRefreshCommand::AllSeries;
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(SonarrCommand::Refresh(command)));
}
mod cli {
use super::*;
use clap::{error::ErrorKind, Parser};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_refresh_commands_have_no_arg_requirements(
#[values("all-series", "downloads")] subcommand: &str,
) {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", subcommand]);
assert!(result.is_ok());
}
#[test]
fn test_refresh_series_requires_series_id() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", "series"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_refresh_series_success() {
let expected_args = SonarrRefreshCommand::Series { series_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"sonarr",
"refresh",
"series",
"--series-id",
"1",
]);
assert!(result.is_ok());
if let Some(Command::Sonarr(SonarrCommand::Refresh(refresh_command))) =
result.unwrap().command
{
assert_eq!(refresh_command, expected_args);
}
}
}
mod handler {
use rstest::rstest;
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{app::App, cli::sonarr::refresh_command_handler::SonarrRefreshCommandHandler};
use crate::{
cli::{sonarr::refresh_command_handler::SonarrRefreshCommand, CliCommandHandler},
network::sonarr_network::SonarrEvent,
};
use crate::{
models::{sonarr_models::SonarrSerdeable, Serdeable},
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(SonarrRefreshCommand::AllSeries, SonarrEvent::UpdateAllSeries)]
#[case(SonarrRefreshCommand::Downloads, SonarrEvent::UpdateDownloads)]
#[tokio::test]
async fn test_handle_refresh_command(
#[case] refresh_command: SonarrRefreshCommand,
#[case] expected_sonarr_event: SonarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_sonarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let result = SonarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_refresh_series_command() {
let expected_series_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::UpdateAndScanSeries(Some(expected_series_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let refresh_series_command = SonarrRefreshCommand::Series { series_id: 1 };
let result =
SonarrRefreshCommandHandler::with(&app_arc, refresh_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
+620
View File
@@ -0,0 +1,620 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{list_command_handler::SonarrListCommand, SonarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_command_from() {
let command = SonarrCommand::List(SonarrListCommand::Series);
let result = Command::from(command.clone());
assert_eq!(result, Command::Sonarr(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", "sonarr", subcommand]);
assert!(result.is_ok());
}
#[test]
fn test_mark_history_item_as_failed_requires_history_item_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "mark-history-item-as-failed"]);
assert!(result.is_err());
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",
"sonarr",
"mark-history-item-as-failed",
"--history-item-id",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_search_new_series_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "search-new-series"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_search_new_series_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"search-new-series",
"--query",
"halo",
]);
assert!(result.is_ok());
}
#[test]
fn test_start_task_requires_task_name() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "start-task"]);
assert!(result.is_err());
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",
"sonarr",
"start-task",
"--task-name",
"test",
]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_start_task_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"start-task",
"--task-name",
"application-update-check",
]);
assert!(result.is_ok());
}
#[test]
fn test_test_indexer_requires_indexer_id() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "test-indexer"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_test_indexer_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"test-indexer",
"--indexer-id",
"1",
]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::{
add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand,
download_command_handler::SonarrDownloadCommand, edit_command_handler::SonarrEditCommand,
get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand,
manual_search_command_handler::SonarrManualSearchCommand,
refresh_command_handler::SonarrRefreshCommand,
trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand,
SonarrCliHandler, SonarrCommand,
},
CliCommandHandler,
},
models::{
sonarr_models::{
BlocklistItem, BlocklistResponse, IndexerSettings, Series, SonarrReleaseDownloadBody,
SonarrSerdeable, SonarrTaskName,
},
Serdeable,
},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_clear_blocklist_command() {
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(
BlocklistResponse {
records: vec![BlocklistItem::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::default()));
let claer_blocklist_command = SonarrCommand::ClearBlocklist;
let result = SonarrCliHandler::with(&app_arc, claer_blocklist_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_mark_history_item_as_failed_command() {
let expected_history_item_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let mark_history_item_as_failed_command =
SonarrCommand::MarkHistoryItemAsFailed { history_item_id: 1 };
let result = SonarrCliHandler::with(
&app_arc,
mark_history_item_as_failed_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_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>(
SonarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let add_tag_command = SonarrCommand::Add(SonarrAddCommand::Tag {
name: expected_tag_name,
});
let result = SonarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_blocklist_item_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let delete_blocklist_item_command =
SonarrCommand::Delete(SonarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
});
let result =
SonarrCliHandler::with(&app_arc, delete_blocklist_item_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_download_commands_to_the_download_command_handler() {
let expected_params = SonarrReleaseDownloadBody {
guid: "1234".to_owned(),
indexer_id: 1,
series_id: Some(1),
..SonarrReleaseDownloadBody::default()
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::DownloadRelease(expected_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let download_series_release_command =
SonarrCommand::Download(SonarrDownloadCommand::Series {
guid: "1234".to_owned(),
indexer_id: 1,
series_id: 1,
});
let result =
SonarrCliHandler::with(&app_arc, download_series_release_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_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>(
SonarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::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>(
SonarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let edit_all_indexer_settings_command =
SonarrCommand::Edit(SonarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
});
let result = SonarrCliHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_manual_search_commands_to_the_manual_search_command_handler(
) {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let manual_episode_search_command =
SonarrCommand::ManualSearch(SonarrManualSearchCommand::Episode { episode_id: 1 });
let result =
SonarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler(
) {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let manual_episode_search_command =
SonarrCommand::TriggerAutomaticSearch(SonarrTriggerAutomaticSearchCommand::Episode {
episode_id: 1,
});
let result =
SonarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_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>(SonarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_system_status_command = SonarrCommand::Get(SonarrGetCommand::SystemStatus);
let result = SonarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_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>(SonarrEvent::ListSeries.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::SeriesVec(vec![
Series::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let list_series_command = SonarrCommand::List(SonarrListCommand::Series);
let result = SonarrCliHandler::with(&app_arc, list_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sonarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() {
let expected_series_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::UpdateAndScanSeries(Some(expected_series_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let refresh_series_command =
SonarrCommand::Refresh(SonarrRefreshCommand::Series { series_id: 1 });
let result = SonarrCliHandler::with(&app_arc, refresh_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_search_new_series_command() {
let expected_search_query = "halo".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::SearchNewSeries(Some(expected_search_query)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let search_new_series_command = SonarrCommand::SearchNewSeries {
query: "halo".to_owned(),
};
let result = SonarrCliHandler::with(&app_arc, search_new_series_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_start_task_command() {
let expected_task_name = SonarrTaskName::ApplicationUpdateCheck;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::StartTask(Some(expected_task_name)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let start_task_command = SonarrCommand::StartTask {
task_name: SonarrTaskName::ApplicationUpdateCheck,
};
let result = SonarrCliHandler::with(&app_arc, start_task_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[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>(
SonarrEvent::TestIndexer(Some(expected_indexer_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let test_indexer_command = SonarrCommand::TestIndexer { indexer_id: 1 };
let result = SonarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_test_all_indexers_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(SonarrEvent::TestAllIndexers.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let test_all_indexers_command = SonarrCommand::TestAllIndexers;
let result = SonarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
}
}
@@ -0,0 +1,113 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{sonarr_network::SonarrEvent, NetworkTrait},
};
use super::SonarrCommand;
#[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 SonarrTriggerAutomaticSearchCommand {
#[command(about = "Trigger an automatic search for the series with the specified ID")]
Series {
#[arg(
long,
help = "The ID of the series you want to trigger an automatic search for",
required = true
)]
series_id: i64,
},
#[command(
about = "Trigger an automatic search for the given season corresponding to the series with the given ID"
)]
Season {
#[arg(
long,
help = "The Sonarr ID of the series whose season you wish to trigger an automatic search for",
required = true
)]
series_id: i64,
#[arg(long, help = "The season number to search for", required = true)]
season_number: i64,
},
#[command(about = "Trigger an automatic search for the episode with the specified ID")]
Episode {
#[arg(
long,
help = "The ID of the episode you want to trigger an automatic search for",
required = true
)]
episode_id: i64,
},
}
impl From<SonarrTriggerAutomaticSearchCommand> for Command {
fn from(value: SonarrTriggerAutomaticSearchCommand) -> Self {
Command::Sonarr(SonarrCommand::TriggerAutomaticSearch(value))
}
}
pub(super) struct SonarrTriggerAutomaticSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrTriggerAutomaticSearchCommand>
for SonarrTriggerAutomaticSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: SonarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
SonarrTriggerAutomaticSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
SonarrTriggerAutomaticSearchCommand::Series { series_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(series_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrTriggerAutomaticSearchCommand::Season {
series_id,
season_number,
} => {
let resp = self
.network
.handle_network_event(
SonarrEvent::TriggerAutomaticSeasonSearch(Some((series_id, season_number))).into(),
)
.await?;
serde_json::to_string_pretty(&resp)?
}
SonarrTriggerAutomaticSearchCommand::Episode { episode_id } => {
let resp = self
.network
.handle_network_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(episode_id)).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,259 @@
#[cfg(test)]
mod tests {
use crate::cli::{
sonarr::{
trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand, SonarrCommand,
},
Command,
};
use crate::Cli;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_sonarr_trigger_automatic_search_command_from() {
let command = SonarrTriggerAutomaticSearchCommand::Episode { episode_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Sonarr(SonarrCommand::TriggerAutomaticSearch(command))
);
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_trigger_automatic_series_search_requires_series_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"series",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_series_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"series",
"--series-id",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_trigger_automatic_season_search_requires_series_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"season",
"--season-number",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_season_search_requires_season_number() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"season",
"--series-id",
"1",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_season_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"season",
"--series-id",
"1",
"--season-number",
"1",
]);
assert!(result.is_ok());
}
#[test]
fn test_trigger_automatic_episode_search_requires_episode_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"episode",
]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_episode_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"trigger-automatic-search",
"episode",
"--episode-id",
"1",
]);
assert!(result.is_ok());
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
sonarr::trigger_automatic_search_command_handler::{
SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler,
},
CliCommandHandler,
},
models::{sonarr_models::SonarrSerdeable, Serdeable},
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_trigger_automatic_series_search_command() {
let expected_series_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::TriggerAutomaticSeriesSearch(Some(expected_series_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let trigger_automatic_series_search_command =
SonarrTriggerAutomaticSearchCommand::Series { series_id: 1 };
let result = SonarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_series_search_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_trigger_automatic_season_search_command() {
let expected_series_id = 1;
let expected_season_number = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::TriggerAutomaticSeasonSearch(Some((
expected_series_id,
expected_season_number,
)))
.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let trigger_automatic_season_search_command = SonarrTriggerAutomaticSearchCommand::Season {
series_id: 1,
season_number: 1,
};
let result = SonarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_season_search_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_trigger_automatic_episode_search_command() {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let trigger_automatic_episode_search_command =
SonarrTriggerAutomaticSearchCommand::Episode { episode_id: 1 };
let result = SonarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_episode_search_command,
&mut mock_network,
)
.handle()
.await;
assert!(result.is_ok());
}
}
}
@@ -11,10 +11,9 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::blocklist::{blocklist_sorting_options, BlocklistHandler};
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::{
BlocklistItem, BlocklistItemMovie, Language, Quality, QualityWrapper,
};
use crate::models::radarr_models::{BlocklistItem, BlocklistItemMovie};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS};
use crate::models::servarr_models::{Language, Quality, QualityWrapper};
use crate::models::stateful_table::SortOption;
mod test_handle_scroll_up_and_down {
@@ -584,6 +583,9 @@ mod tests {
mod test_handle_key_char {
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::network::radarr_network::RadarrEvent;
use super::*;
@@ -716,6 +718,43 @@ mod tests {
assert!(app.data.radarr_data.blocklist.sort.is_none());
assert!(!app.data.radarr_data.blocklist.sort_asc);
}
#[rstest]
#[case(
ActiveRadarrBlock::Blocklist,
ActiveRadarrBlock::DeleteBlocklistItemPrompt,
RadarrEvent::DeleteBlocklistItem(None)
)]
#[case(
ActiveRadarrBlock::Blocklist,
ActiveRadarrBlock::BlocklistClearAllItemsPrompt,
RadarrEvent::ClearBlocklist
)]
fn test_blocklist_prompt_confirm(
#[case] base_route: ActiveRadarrBlock,
#[case] prompt_block: ActiveRadarrBlock,
#[case] expected_action: RadarrEvent,
) {
let mut app = App::default();
app.data.radarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(base_route.into());
app.push_navigation_stack(prompt_block.into());
BlocklistHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&prompt_block,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(expected_action)
);
assert_eq!(app.get_current_route(), &base_route.into());
}
}
#[test]
@@ -920,6 +959,7 @@ mod tests {
id: 3,
source_title: "test 1".to_owned(),
languages: vec![Language {
id: 1,
name: "telgu".to_owned(),
}],
quality: QualityWrapper {
@@ -928,6 +968,7 @@ mod tests {
},
},
custom_formats: Some(vec![Language {
id: 2,
name: "nikki".to_owned(),
}]),
date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()),
@@ -940,6 +981,7 @@ mod tests {
id: 2,
source_title: "test 2".to_owned(),
languages: vec![Language {
id: 3,
name: "chinese".to_owned(),
}],
quality: QualityWrapper {
@@ -949,9 +991,11 @@ mod tests {
},
custom_formats: Some(vec![
Language {
id: 4,
name: "alex".to_owned(),
},
Language {
id: 5,
name: "English".to_owned(),
},
]),
@@ -965,6 +1009,7 @@ mod tests {
id: 1,
source_title: "test 3".to_owned(),
languages: vec![Language {
id: 1,
name: "english".to_owned(),
}],
quality: QualityWrapper {
@@ -973,6 +1018,7 @@ mod tests {
},
},
custom_formats: Some(vec![Language {
id: 2,
name: "English".to_owned(),
}]),
date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()),
+20 -2
View File
@@ -182,8 +182,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a,
fn handle_char_key_event(&mut self) {
let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::Blocklist {
match self.key {
match self.active_radarr_block {
ActiveRadarrBlock::Blocklist => match self.key {
_ if *key == DEFAULT_KEYBINDINGS.refresh.key => {
self.app.should_refresh = true;
}
@@ -204,7 +204,25 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a,
.push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into());
}
_ => (),
},
ActiveRadarrBlock::DeleteBlocklistItemPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::DeleteBlocklistItem(None));
self.app.pop_navigation_stack();
}
}
ActiveRadarrBlock::BlocklistClearAllItemsPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::ClearBlocklist);
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
}
@@ -1040,6 +1040,7 @@ mod tests {
use crate::models::servarr_data::radarr::radarr_data::{
RadarrData, EDIT_COLLECTION_SELECTION_BLOCKS,
};
use crate::network::radarr_network::RadarrEvent;
use crate::test_edit_collection_key;
use super::*;
@@ -1509,6 +1510,36 @@ mod tests {
assert!(app.data.radarr_data.collections.sort.is_none());
assert!(!app.data.radarr_data.collections.sort_asc);
}
#[test]
fn test_update_all_collections_prompt_confirm_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.collections
.set_items(vec![Collection::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Collections.into());
app.push_navigation_stack(ActiveRadarrBlock::UpdateAllCollectionsPrompt.into());
CollectionsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::UpdateAllCollectionsPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::UpdateCollections)
);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::Collections.into()
);
}
}
#[rstest]
@@ -291,19 +291,34 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
fn handle_char_key_event(&mut self) {
let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::EditCollectionRootFolderPathInput {
handle_text_box_keys!(
self,
key,
self
.app
.data
.radarr_data
.edit_collection_modal
.as_mut()
.unwrap()
.path
)
match self.active_radarr_block {
ActiveRadarrBlock::EditCollectionRootFolderPathInput => {
handle_text_box_keys!(
self,
key,
self
.app
.data
.radarr_data
.edit_collection_modal
.as_mut()
.unwrap()
.path
)
}
ActiveRadarrBlock::EditCollectionPrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::EditCollectionConfirmPrompt
&& *key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
}
@@ -871,7 +871,15 @@ mod tests {
mod test_handle_key_char {
use super::*;
use crate::models::servarr_data::radarr::modals::EditCollectionModal;
use crate::{
models::{
servarr_data::radarr::{
modals::EditCollectionModal, radarr_data::EDIT_COLLECTION_SELECTION_BLOCKS,
},
BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
#[test]
fn test_edit_collection_root_folder_path_input_backspace() {
@@ -927,6 +935,39 @@ mod tests {
"h"
);
}
#[test]
fn test_edit_collection_confirm_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default());
app.push_navigation_stack(ActiveRadarrBlock::Collections.into());
app.push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into());
app.data.radarr_data.selected_block =
BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1);
EditCollectionHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::EditCollectionPrompt,
&None,
)
.handle();
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::Collections.into()
);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditCollection(None))
);
assert!(app.should_refresh);
}
}
#[test]
@@ -385,6 +385,14 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'
.unwrap()
)
}
ActiveRadarrBlock::UpdateAllCollectionsPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateCollections);
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -349,6 +349,9 @@ mod tests {
mod test_handle_key_char {
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::network::radarr_network::RadarrEvent;
use super::*;
@@ -450,6 +453,47 @@ mod tests {
);
assert!(!app.should_refresh);
}
#[rstest]
#[case(
ActiveRadarrBlock::Downloads,
ActiveRadarrBlock::DeleteDownloadPrompt,
RadarrEvent::DeleteDownload(None)
)]
#[case(
ActiveRadarrBlock::Downloads,
ActiveRadarrBlock::UpdateDownloadsPrompt,
RadarrEvent::UpdateDownloads
)]
fn test_downloads_prompt_confirm_submit(
#[case] base_route: ActiveRadarrBlock,
#[case] prompt_block: ActiveRadarrBlock,
#[case] expected_action: RadarrEvent,
) {
let mut app = App::default();
app
.data
.radarr_data
.downloads
.set_items(vec![DownloadRecord::default()]);
app.push_navigation_stack(base_route.into());
app.push_navigation_stack(prompt_block.into());
DownloadsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&prompt_block,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(expected_action)
);
assert_eq!(app.get_current_route(), &base_route.into());
}
}
#[test]
+19 -2
View File
@@ -119,8 +119,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a,
fn handle_char_key_event(&mut self) {
let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::Downloads {
match self.key {
match self.active_radarr_block {
ActiveRadarrBlock::Downloads => match self.key {
_ if *key == DEFAULT_KEYBINDINGS.update.key => {
self
.app
@@ -130,7 +130,24 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a,
self.app.should_refresh = true;
}
_ => (),
},
ActiveRadarrBlock::DeleteDownloadPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteDownload(None));
self.app.pop_navigation_stack();
}
}
ActiveRadarrBlock::UpdateDownloadsPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateDownloads);
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
}
@@ -429,6 +429,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<'
.tags
);
}
ActiveRadarrBlock::EditIndexerPrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::EditIndexerConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditIndexer(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -5,7 +5,7 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS};
use strum::IntoEnumIterator;
@@ -14,7 +14,7 @@ mod tests {
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS;
use crate::models::BlockSelectionState;
@@ -69,7 +69,7 @@ mod tests {
use std::sync::atomic::Ordering;
use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use pretty_assertions::assert_eq;
use super::*;
@@ -334,7 +334,7 @@ mod tests {
use std::sync::atomic::Ordering;
use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::{
EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS,
};
@@ -759,7 +759,7 @@ mod tests {
use rstest::rstest;
use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::{
servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, BlockSelectionState,
};
@@ -1224,7 +1224,7 @@ mod tests {
use super::*;
use crate::app::App;
use crate::event::Key;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use pretty_assertions::assert_eq;
use rstest::rstest;
@@ -1281,7 +1281,10 @@ mod tests {
mod test_handle_key_char {
use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS;
use crate::models::BlockSelectionState;
use crate::network::radarr_network::RadarrEvent;
use pretty_assertions::assert_str_eq;
use super::*;
@@ -1560,6 +1563,37 @@ mod tests {
"h"
);
}
#[test]
fn test_edit_indexer_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.push_navigation_stack(ActiveRadarrBlock::Indexers.into());
app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into());
app.data.radarr_data.selected_block =
BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1);
app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default());
EditIndexerHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::EditIndexerPrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
assert!(app.data.radarr_data.edit_indexer_modal.is_some());
assert!(app.should_refresh);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditIndexer(None))
);
}
}
#[test]
@@ -241,19 +241,35 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
}
fn handle_char_key_event(&mut self) {
if self.active_radarr_block == &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput {
handle_text_box_keys!(
self,
self.key,
self
.app
.data
.radarr_data
.indexer_settings
.as_mut()
.unwrap()
.whitelisted_hardcoded_subs
)
match self.active_radarr_block {
ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput => {
handle_text_box_keys!(
self,
self.key,
self
.app
.data
.radarr_data
.indexer_settings
.as_mut()
.unwrap()
.whitelisted_hardcoded_subs
)
}
ActiveRadarrBlock::AllIndexerSettingsPrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::IndexerSettingsConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditAllIndexerSettings(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
}
@@ -851,7 +851,13 @@ mod tests {
mod test_handle_key_char {
use pretty_assertions::assert_str_eq;
use crate::models::radarr_models::IndexerSettings;
use crate::{
models::{
radarr_models::IndexerSettings,
servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
use super::*;
@@ -909,6 +915,37 @@ mod tests {
"h"
);
}
#[test]
fn test_edit_indexer_settings_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.push_navigation_stack(ActiveRadarrBlock::Indexers.into());
app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into());
app.data.radarr_data.selected_block =
BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1);
app.data.radarr_data.indexer_settings = Some(IndexerSettings::default());
IndexerSettingsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::AllIndexerSettingsPrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditAllIndexerSettings(None))
);
assert!(app.data.radarr_data.indexer_settings.is_some());
assert!(app.should_refresh);
}
}
#[test]
@@ -9,16 +9,15 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::indexers::IndexersHandler;
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::Indexer;
use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, EDIT_INDEXER_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS,
};
use crate::models::servarr_models::Indexer;
use crate::test_handler_delegation;
mod test_handle_scroll_up_and_down {
use rstest::rstest;
use crate::models::radarr_models::Indexer;
use crate::{simple_stateful_iterable_vec, test_iterable_scroll};
use super::*;
@@ -65,7 +64,6 @@ mod tests {
}
mod test_handle_home_end {
use crate::models::radarr_models::Indexer;
use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end};
use super::*;
@@ -239,11 +237,11 @@ mod tests {
}
mod test_handle_submit {
use crate::models::radarr_models::{Indexer, IndexerField};
use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::{
RadarrData, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS,
};
use crate::models::servarr_models::{Indexer, IndexerField};
use bimap::BiMap;
use pretty_assertions::assert_eq;
use serde_json::{Number, Value};
@@ -464,7 +462,10 @@ mod tests {
mod test_handle_key_char {
use pretty_assertions::assert_eq;
use crate::models::servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS;
use crate::{
models::servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS,
network::radarr_network::RadarrEvent,
};
use super::*;
@@ -696,6 +697,33 @@ mod tests {
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
}
#[test]
fn test_delete_indexer_prompt_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.indexers
.set_items(vec![Indexer::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Indexers.into());
app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into());
IndexersHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::DeleteIndexerPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::DeleteIndexer(None))
);
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
}
}
#[rstest]
+13 -3
View File
@@ -10,7 +10,8 @@ use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS,
INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS,
};
use crate::models::{BlockSelectionState, Scrollable};
use crate::models::BlockSelectionState;
use crate::models::Scrollable;
use crate::network::radarr_network::RadarrEvent;
mod edit_indexer_handler;
@@ -166,8 +167,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a,
fn handle_char_key_event(&mut self) {
let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::Indexers {
match self.key {
match self.active_radarr_block {
ActiveRadarrBlock::Indexers => match self.key {
_ if *key == DEFAULT_KEYBINDINGS.add.key => {
self
.app
@@ -194,7 +195,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a,
BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS);
}
_ => (),
},
ActiveRadarrBlock::DeleteIndexerPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteIndexer(None));
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
}
@@ -5,7 +5,7 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem;
use crate::models::servarr_data::modals::IndexerTestResultModalItem;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::stateful_table::StatefulTable;
use strum::IntoEnumIterator;
@@ -14,7 +14,7 @@ mod tests {
use pretty_assertions::assert_str_eq;
use rstest::rstest;
use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem;
use crate::models::servarr_data::modals::IndexerTestResultModalItem;
use crate::models::stateful_table::StatefulTable;
use crate::simple_stateful_iterable_vec;
@@ -112,7 +112,7 @@ mod tests {
mod test_handle_home_end {
use crate::extended_stateful_iterable_vec;
use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem;
use crate::models::servarr_data::modals::IndexerTestResultModalItem;
use crate::models::stateful_table::StatefulTable;
use pretty_assertions::assert_str_eq;
@@ -461,6 +461,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a,
.tags
)
}
ActiveRadarrBlock::AddMoviePrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::AddMovieConfirmPrompt
&& *key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::AddMovie(None));
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -8,10 +8,9 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::library::add_movie_handler::AddMovieHandler;
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::{
AddMovieSearchResult, MinimumAvailability, Monitor, RootFolder,
};
use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, MovieMonitor};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS};
use crate::models::servarr_models::RootFolder;
use crate::models::HorizontallyScrollableText;
mod test_handle_scroll_up_and_down {
@@ -142,7 +141,7 @@ mod tests {
fn test_add_movie_select_monitor_scroll(
#[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key,
) {
let monitor_vec = Vec::from_iter(Monitor::iter());
let monitor_vec = Vec::from_iter(MovieMonitor::iter());
let mut app = App::default();
app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default());
app
@@ -535,7 +534,7 @@ mod tests {
#[test]
fn test_add_movie_select_monitor_home_end() {
let monitor_vec = Vec::from_iter(Monitor::iter());
let monitor_vec = Vec::from_iter(MovieMonitor::iter());
let mut app = App::default();
app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default());
app
@@ -1494,7 +1493,13 @@ mod tests {
mod test_handle_key_char {
use super::*;
use crate::models::servarr_data::radarr::modals::AddMovieModal;
use crate::{
models::{
servarr_data::radarr::{modals::AddMovieModal, radarr_data::ADD_MOVIE_SELECTION_BLOCKS},
BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
#[test]
fn test_add_movie_search_input_backspace() {
@@ -1588,6 +1593,35 @@ mod tests {
"h"
);
}
#[test]
fn test_add_movie_confirm_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default());
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into());
app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1);
AddMovieHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::AddMoviePrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::AddMovie(None))
);
assert!(app.data.radarr_data.add_movie_modal.is_some());
}
}
#[test]
@@ -1,3 +1,4 @@
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::event::Key;
use crate::handlers::{handle_prompt_toggle, KeyEventHandler};
@@ -100,5 +101,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<'
}
}
fn handle_char_key_event(&mut self) {}
fn handle_char_key_event(&mut self) {
if self.active_radarr_block == &ActiveRadarrBlock::DeleteMoviePrompt
&& self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::DeleteMovieConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteMovie(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
}
@@ -250,6 +250,51 @@ mod tests {
}
}
mod test_handle_key_char {
use crate::{
models::{
servarr_data::radarr::radarr_data::DELETE_MOVIE_SELECTION_BLOCKS, BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
use super::*;
#[test]
fn test_delete_movie_confirm_prompt_prompt_confirm() {
let mut app = App::default();
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into());
app.data.radarr_data.delete_movie_files = true;
app.data.radarr_data.add_list_exclusion = true;
app.data.radarr_data.selected_block =
BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1);
DeleteMovieHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::DeleteMoviePrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::DeleteMovie(None))
);
assert!(app.should_refresh);
assert!(app.data.radarr_data.prompt_confirm);
assert!(app.data.radarr_data.delete_movie_files);
assert!(app.data.radarr_data.add_list_exclusion);
}
}
#[test]
fn test_delete_movie_handler_accepts() {
ActiveRadarrBlock::iter().for_each(|active_radarr_block| {
@@ -327,6 +327,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a,
.tags
)
}
ActiveRadarrBlock::EditMoviePrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::EditMovieConfirmPrompt
&& *key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditMovie(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -940,7 +940,16 @@ mod tests {
mod test_handle_key_char {
use super::*;
use crate::models::servarr_data::radarr::modals::EditMovieModal;
use crate::{
models::{
servarr_data::radarr::{
modals::EditMovieModal,
radarr_data::{EDIT_COLLECTION_SELECTION_BLOCKS, EDIT_MOVIE_SELECTION_BLOCKS},
},
BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
#[test]
fn test_edit_movie_path_input_backspace() {
@@ -1051,6 +1060,36 @@ mod tests {
"h"
);
}
#[test]
fn test_edit_movie_confirm_prompt_prompt_confirm() {
let mut app = App::default();
app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default());
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into());
app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1);
EditMovieHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::EditMoviePrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditMovie(None))
);
assert!(app.data.radarr_data.edit_movie_modal.is_some());
assert!(app.should_refresh);
}
}
#[test]
@@ -11,11 +11,12 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::library::{movies_sorting_options, LibraryHandler};
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::{Language, Movie};
use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, ADD_MOVIE_BLOCKS, DELETE_MOVIE_BLOCKS, EDIT_MOVIE_BLOCKS, LIBRARY_BLOCKS,
MOVIE_DETAILS_BLOCKS,
};
use crate::models::servarr_models::Language;
use crate::models::stateful_table::SortOption;
use crate::models::HorizontallyScrollableText;
use crate::test_handler_delegation;
@@ -996,6 +997,7 @@ mod tests {
RadarrData, EDIT_MOVIE_SELECTION_BLOCKS,
};
use crate::network::radarr_network::RadarrEvent;
use crate::test_edit_movie_key;
use super::*;
@@ -1452,6 +1454,33 @@ mod tests {
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert!(app.data.radarr_data.movies.sort.is_none());
}
#[test]
fn test_update_all_movies_prompt_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.movies
.set_items(vec![Movie::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::UpdateAllMoviesPrompt.into());
LibraryHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::UpdateAllMoviesPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::UpdateAllMovies)
);
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
}
}
#[rstest]
@@ -1778,6 +1807,7 @@ mod tests {
id: 3,
title: "test 1".into(),
original_language: Language {
id: 1,
name: "English".to_owned(),
},
size_on_disk: 1024,
@@ -1794,6 +1824,7 @@ mod tests {
id: 2,
title: "test 2".into(),
original_language: Language {
id: 2,
name: "Chinese".to_owned(),
},
size_on_disk: 2048,
@@ -1810,6 +1841,7 @@ mod tests {
id: 1,
title: "test 3".into(),
original_language: Language {
id: 3,
name: "Japanese".to_owned(),
},
size_on_disk: 512,
@@ -386,6 +386,14 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, '
self.app.data.radarr_data.movies.filter.as_mut().unwrap()
)
}
ActiveRadarrBlock::UpdateAllMoviesPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAllMovies);
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -4,10 +4,11 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::event::Key;
use crate::handlers::{handle_prompt_toggle, KeyEventHandler};
use crate::models::radarr_models::{Language, Release};
use crate::models::radarr_models::RadarrRelease;
use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS,
};
use crate::models::servarr_models::Language;
use crate::models::stateful_table::SortOption;
use crate::models::{BlockSelectionState, Scrollable};
use crate::network::radarr_network::RadarrEvent;
@@ -47,18 +48,28 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<
}
fn is_ready(&self) -> bool {
let movie_details_modal_is_ready =
if let Some(movie_details_modal) = &self.app.data.radarr_data.movie_details_modal {
!movie_details_modal.movie_details.is_empty()
|| !movie_details_modal.movie_history.is_empty()
|| !movie_details_modal.movie_cast.is_empty()
|| !movie_details_modal.movie_crew.is_empty()
|| !movie_details_modal.movie_releases.is_empty()
} else {
false
};
!self.app.is_loading && movie_details_modal_is_ready
if let Some(movie_details_modal) = &self.app.data.radarr_data.movie_details_modal {
match self.active_radarr_block {
ActiveRadarrBlock::MovieDetails => {
!self.app.is_loading && !movie_details_modal.movie_details.is_empty()
}
ActiveRadarrBlock::MovieHistory => {
!self.app.is_loading && !movie_details_modal.movie_history.is_empty()
}
ActiveRadarrBlock::Cast => {
!self.app.is_loading && !movie_details_modal.movie_cast.is_empty()
}
ActiveRadarrBlock::Crew => {
!self.app.is_loading && !movie_details_modal.movie_crew.is_empty()
}
ActiveRadarrBlock::ManualSearch => {
!self.app.is_loading && !movie_details_modal.movie_releases.is_empty()
}
_ => !self.app.is_loading,
}
} else {
false
}
}
fn handle_scroll_up(&mut self) {
@@ -464,12 +475,38 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<
}
_ => (),
},
ActiveRadarrBlock::AutomaticallySearchMoviePrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::TriggerAutomaticSearch(None));
self.app.pop_navigation_stack();
}
}
ActiveRadarrBlock::UpdateAndScanPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None));
self.app.pop_navigation_stack();
}
}
ActiveRadarrBlock::ManualSearchConfirmPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::DownloadRelease(None));
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
}
fn releases_sorting_options() -> Vec<SortOption<Release>> {
fn releases_sorting_options() -> Vec<SortOption<RadarrRelease>> {
vec![
SortOption {
name: "Source",
@@ -524,6 +561,7 @@ fn releases_sorting_options() -> Vec<SortOption<Release>> {
name: "Language",
cmp_fn: Some(|a, b| {
let default_language_vec = vec![Language {
id: 1,
name: "_".to_owned(),
}];
let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0];
@@ -3,6 +3,7 @@ mod tests {
use std::cmp::Ordering;
use pretty_assertions::assert_str_eq;
use rstest::rstest;
use serde_json::Number;
use strum::IntoEnumIterator;
@@ -13,11 +14,11 @@ mod tests {
releases_sorting_options, MovieDetailsHandler,
};
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::{
Credit, Language, MovieHistoryItem, Quality, QualityWrapper, Release,
};
use crate::models::radarr_models::RadarrRelease;
use crate::models::radarr_models::{Credit, MovieHistoryItem};
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS};
use crate::models::servarr_models::{Language, Quality, QualityWrapper};
use crate::models::stateful_table::SortOption;
use crate::models::{HorizontallyScrollableText, ScrollableText};
@@ -405,7 +406,7 @@ mod tests {
movie_details_modal
.movie_releases
.set_items(simple_stateful_iterable_vec!(
Release,
RadarrRelease,
HorizontallyScrollableText
));
app.data.radarr_data.movie_details_modal = Some(movie_details_modal);
@@ -453,7 +454,7 @@ mod tests {
movie_details_modal
.movie_releases
.set_items(simple_stateful_iterable_vec!(
Release,
RadarrRelease,
HorizontallyScrollableText
));
app.data.radarr_data.movie_details_modal = Some(movie_details_modal);
@@ -996,7 +997,7 @@ mod tests {
movie_details_modal
.movie_releases
.set_items(extended_stateful_iterable_vec!(
Release,
RadarrRelease,
HorizontallyScrollableText
));
app.data.radarr_data.movie_details_modal = Some(movie_details_modal);
@@ -1054,7 +1055,7 @@ mod tests {
movie_details_modal
.movie_releases
.set_items(extended_stateful_iterable_vec!(
Release,
RadarrRelease,
HorizontallyScrollableText
));
app.data.radarr_data.movie_details_modal = Some(movie_details_modal);
@@ -1245,10 +1246,14 @@ mod tests {
#[test]
fn test_manual_search_submit() {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_releases
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into());
MovieDetailsHandler::with(
@@ -1468,6 +1473,7 @@ mod tests {
use crate::models::servarr_data::radarr::radarr_data::{
RadarrData, EDIT_MOVIE_SELECTION_BLOCKS,
};
use crate::network::radarr_network::RadarrEvent;
use crate::test_edit_movie_key;
use super::*;
@@ -1484,11 +1490,22 @@ mod tests {
)]
active_radarr_block: ActiveRadarrBlock,
) {
use crate::models::radarr_models::RadarrRelease;
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal
.movie_releases
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.search.key,
@@ -1538,10 +1555,9 @@ mod tests {
#[test]
fn test_sort_key() {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default()
});
let mut modal = MovieDetailsModal::default();
modal.movie_releases.set_items(release_vec());
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.sort.key,
@@ -1669,10 +1685,19 @@ mod tests {
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal
.movie_releases
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.update.key,
@@ -1732,10 +1757,19 @@ mod tests {
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal
.movie_releases
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.refresh.key,
@@ -1780,11 +1814,56 @@ mod tests {
assert_eq!(app.get_current_route(), &active_radarr_block.into());
assert!(app.is_routing);
}
#[rstest]
#[case(
ActiveRadarrBlock::AutomaticallySearchMoviePrompt,
RadarrEvent::TriggerAutomaticSearch(None)
)]
#[case(
ActiveRadarrBlock::UpdateAndScanPrompt,
RadarrEvent::UpdateAndScan(None)
)]
#[case(
ActiveRadarrBlock::ManualSearchConfirmPrompt,
RadarrEvent::DownloadRelease(None)
)]
fn test_movie_info_prompt_confirm(
#[case] prompt_block: ActiveRadarrBlock,
#[case] expected_action: RadarrEvent,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default()
});
app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into());
app.push_navigation_stack(prompt_block.into());
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&prompt_block,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::MovieDetails.into()
);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(expected_action)
);
}
}
#[test]
fn test_releases_sorting_options_source() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.protocol.cmp(&b.protocol);
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering =
|a, b| a.protocol.cmp(&b.protocol);
let mut expected_releases_vec = release_vec();
expected_releases_vec.sort_by(expected_cmp_fn);
@@ -1798,7 +1877,7 @@ mod tests {
#[test]
fn test_releases_sorting_options_age() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.age.cmp(&b.age);
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| a.age.cmp(&b.age);
let mut expected_releases_vec = release_vec();
expected_releases_vec.sort_by(expected_cmp_fn);
@@ -1812,7 +1891,8 @@ mod tests {
#[test]
fn test_releases_sorting_options_rejected() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.rejected.cmp(&b.rejected);
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering =
|a, b| a.rejected.cmp(&b.rejected);
let mut expected_releases_vec = release_vec();
expected_releases_vec.sort_by(expected_cmp_fn);
@@ -1826,7 +1906,7 @@ mod tests {
#[test]
fn test_releases_sorting_options_title() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| {
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| {
a.title
.text
.to_lowercase()
@@ -1845,7 +1925,7 @@ mod tests {
#[test]
fn test_releases_sorting_options_indexer() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering =
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering =
|a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase());
let mut expected_releases_vec = release_vec();
expected_releases_vec.sort_by(expected_cmp_fn);
@@ -1860,7 +1940,8 @@ mod tests {
#[test]
fn test_releases_sorting_options_size() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.size.cmp(&b.size);
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering =
|a, b| a.size.cmp(&b.size);
let mut expected_releases_vec = release_vec();
expected_releases_vec.sort_by(expected_cmp_fn);
@@ -1874,7 +1955,7 @@ mod tests {
#[test]
fn test_releases_sorting_options_peers() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| {
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| {
let default_number = Number::from(i64::MAX);
let seeder_a = a
.seeders
@@ -1904,8 +1985,9 @@ mod tests {
#[test]
fn test_releases_sorting_options_language() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| {
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| {
let default_language_vec = vec![Language {
id: 1,
name: "_".to_owned(),
}];
let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0];
@@ -1926,7 +2008,8 @@ mod tests {
#[test]
fn test_releases_sorting_options_quality() {
let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.quality.cmp(&b.quality);
let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering =
|a, b| a.quality.cmp(&b.quality);
let mut expected_releases_vec = release_vec();
expected_releases_vec.sort_by(expected_cmp_fn);
@@ -1949,15 +2032,39 @@ mod tests {
});
}
#[test]
fn test_movie_details_handler_is_not_ready_when_loading() {
#[rstest]
fn test_movie_details_handler_is_not_ready_when_loading(
#[values(
ActiveRadarrBlock::MovieDetails,
ActiveRadarrBlock::MovieHistory,
ActiveRadarrBlock::FileInfo,
ActiveRadarrBlock::Cast,
ActiveRadarrBlock::Crew,
ActiveRadarrBlock::ManualSearch,
ActiveRadarrBlock::ManualSearch
)]
movie_details_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.is_loading = true;
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal
.movie_releases
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
let handler = MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.esc.key,
&mut app,
&ActiveRadarrBlock::MovieDetails,
&movie_details_block,
&None,
);
@@ -2060,7 +2167,9 @@ mod tests {
let mut app = App::default();
app.is_loading = false;
let mut modal = MovieDetailsModal::default();
modal.movie_releases.set_items(vec![Release::default()]);
modal
.movie_releases
.set_items(vec![RadarrRelease::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
let handler = MovieDetailsHandler::with(
@@ -2073,8 +2182,8 @@ mod tests {
assert!(handler.is_ready());
}
fn release_vec() -> Vec<Release> {
let release_a = Release {
fn release_vec() -> Vec<RadarrRelease> {
let release_a = RadarrRelease {
protocol: "Protocol A".to_owned(),
age: 1,
title: HorizontallyScrollableText::from("Title A"),
@@ -2083,6 +2192,7 @@ mod tests {
rejected: true,
seeders: Some(Number::from(1)),
languages: Some(vec![Language {
id: 1,
name: "Language A".to_owned(),
}]),
quality: QualityWrapper {
@@ -2090,9 +2200,9 @@ mod tests {
name: "Quality A".to_owned(),
},
},
..Release::default()
..RadarrRelease::default()
};
let release_b = Release {
let release_b = RadarrRelease {
protocol: "Protocol B".to_owned(),
age: 2,
title: HorizontallyScrollableText::from("title B"),
@@ -2101,6 +2211,7 @@ mod tests {
rejected: false,
seeders: Some(Number::from(2)),
languages: Some(vec![Language {
id: 2,
name: "Language B".to_owned(),
}]),
quality: QualityWrapper {
@@ -2108,9 +2219,9 @@ mod tests {
name: "Quality B".to_owned(),
},
},
..Release::default()
..RadarrRelease::default()
};
let release_c = Release {
let release_c = RadarrRelease {
protocol: "Protocol C".to_owned(),
age: 3,
title: HorizontallyScrollableText::from("Title C"),
@@ -2124,13 +2235,13 @@ mod tests {
name: "Quality C".to_owned(),
},
},
..Release::default()
..RadarrRelease::default()
};
vec![release_a, release_b, release_c]
}
fn sort_options() -> Vec<SortOption<Release>> {
fn sort_options() -> Vec<SortOption<RadarrRelease>> {
vec![SortOption {
name: "Test 1",
cmp_fn: Some(|a, b| a.age.cmp(&b.age)),
@@ -180,6 +180,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<'
self.app.data.radarr_data.edit_root_folder.as_mut().unwrap()
)
}
ActiveRadarrBlock::DeleteRootFolderPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::DeleteRootFolder(None));
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -8,14 +8,14 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler;
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::RootFolder;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS};
use crate::models::servarr_models::RootFolder;
use crate::models::HorizontallyScrollableText;
mod test_handle_scroll_up_and_down {
use rstest::rstest;
use crate::models::radarr_models::RootFolder;
use crate::models::servarr_models::RootFolder;
use crate::{simple_stateful_iterable_vec, test_iterable_scroll};
use super::*;
@@ -63,7 +63,7 @@ mod tests {
use pretty_assertions::assert_eq;
use crate::models::radarr_models::RootFolder;
use crate::models::servarr_models::RootFolder;
use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end};
use super::*;
@@ -554,6 +554,8 @@ mod tests {
mod test_handle_key_char {
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::network::radarr_network::RadarrEvent;
use super::*;
#[test]
@@ -706,6 +708,36 @@ mod tests {
"h"
);
}
#[test]
fn test_delete_root_folder_prompt_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.root_folders
.set_items(vec![RootFolder::default()]);
app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into());
app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into());
RootFoldersHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::DeleteRootFolderPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::DeleteRootFolder(None))
);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::RootFolders.into()
);
}
}
#[test]
@@ -168,5 +168,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler
{
self.app.should_refresh = true;
}
if self.active_radarr_block == &ActiveRadarrBlock::SystemTaskStartConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::StartTask(None));
self.app.pop_navigation_stack();
}
}
}
@@ -8,10 +8,11 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler;
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::{QueueEvent, Task};
use crate::models::radarr_models::RadarrTask;
use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS,
};
use crate::models::servarr_models::QueueEvent;
use crate::models::{HorizontallyScrollableText, ScrollableText};
mod test_handle_scroll_up_and_down {
@@ -73,7 +74,7 @@ mod tests {
.data
.radarr_data
.tasks
.set_items(simple_stateful_iterable_vec!(Task, String, name));
.set_items(simple_stateful_iterable_vec!(RadarrTask, String, name));
SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle();
@@ -101,7 +102,7 @@ mod tests {
.data
.radarr_data
.tasks
.set_items(simple_stateful_iterable_vec!(Task, String, name));
.set_items(simple_stateful_iterable_vec!(RadarrTask, String, name));
SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle();
@@ -317,7 +318,7 @@ mod tests {
.data
.radarr_data
.tasks
.set_items(extended_stateful_iterable_vec!(Task, String, name));
.set_items(extended_stateful_iterable_vec!(RadarrTask, String, name));
SystemDetailsHandler::with(
&DEFAULT_KEYBINDINGS.end.key,
@@ -356,7 +357,7 @@ mod tests {
.data
.radarr_data
.tasks
.set_items(extended_stateful_iterable_vec!(Task, String, name));
.set_items(extended_stateful_iterable_vec!(RadarrTask, String, name));
SystemDetailsHandler::with(
&DEFAULT_KEYBINDINGS.end.key,
@@ -788,7 +789,11 @@ mod tests {
app.is_loading = is_ready;
app.push_navigation_stack(ActiveRadarrBlock::System.into());
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemDetailsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::SystemTasks, &None)
.handle();
@@ -858,6 +863,8 @@ mod tests {
mod test_handle_key_char {
use rstest::rstest;
use crate::network::radarr_network::RadarrEvent;
use super::*;
#[rstest]
@@ -912,6 +919,32 @@ mod tests {
assert_eq!(app.get_current_route(), &active_radarr_block.into());
assert!(!app.should_refresh);
}
#[test]
fn test_system_tasks_start_task_prompt_confirm() {
let mut app = App::default();
app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned());
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into());
SystemDetailsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::SystemTaskStartConfirmPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::StartTask(None))
);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::SystemTasks.into()
);
}
}
#[test]
@@ -9,10 +9,11 @@ mod tests {
use crate::event::Key;
use crate::handlers::radarr_handlers::system::SystemHandler;
use crate::handlers::KeyEventHandler;
use crate::models::radarr_models::{QueueEvent, Task};
use crate::models::radarr_models::RadarrTask;
use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS,
};
use crate::models::servarr_models::QueueEvent;
use crate::test_handler_delegation;
mod test_handle_left_right_action {
@@ -104,7 +105,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.update.key,
@@ -134,7 +139,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.update.key,
@@ -159,7 +168,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.events.key,
@@ -189,7 +202,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.events.key,
@@ -214,7 +231,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
app.push_navigation_stack(ActiveRadarrBlock::System.into());
SystemHandler::with(
@@ -243,7 +264,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
app.push_navigation_stack(ActiveRadarrBlock::System.into());
SystemHandler::with(
@@ -270,7 +295,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.logs.key,
@@ -308,7 +337,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.logs.key,
@@ -334,7 +367,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.tasks.key,
@@ -364,7 +401,11 @@ mod tests {
.radarr_data
.queued_events
.set_items(vec![QueueEvent::default()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
SystemHandler::with(
&DEFAULT_KEYBINDINGS.tasks.key,
@@ -429,7 +470,11 @@ mod tests {
fn test_system_handler_is_not_ready_when_logs_is_empty() {
let mut app = App::default();
app.is_loading = false;
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
app
.data
.radarr_data
@@ -472,7 +517,11 @@ mod tests {
let mut app = App::default();
app.is_loading = false;
app.data.radarr_data.logs.set_items(vec!["test".into()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
let system_handler = SystemHandler::with(
&DEFAULT_KEYBINDINGS.update.key,
@@ -489,7 +538,11 @@ mod tests {
let mut app = App::default();
app.is_loading = false;
app.data.radarr_data.logs.set_items(vec!["test".into()]);
app.data.radarr_data.tasks.set_items(vec![Task::default()]);
app
.data
.radarr_data
.tasks
.set_items(vec![RadarrTask::default()]);
app
.data
.radarr_data
+50 -58
View File
@@ -1,32 +1,30 @@
#![warn(rust_2018_idioms)]
use std::fs::File;
use std::io::BufReader;
use anyhow::Result;
use std::panic::PanicHookInfo;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::{io, panic, process};
use anyhow::anyhow;
use anyhow::Result;
use app::AppConfig;
use clap::{
command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser,
};
use clap::{crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser};
use clap_complete::generate;
use colored::Colorize;
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, size, EnterAlternateScreen, LeaveAlternateScreen,
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use log::error;
use log::{error, warn};
use network::NetworkTrait;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use reqwest::Client;
use tokio::select;
use tokio::sync::mpsc::Receiver;
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
use utils::{
build_network_client, load_config, start_cli_no_spinner, start_cli_with_spinner, tail_logs,
};
use crate::app::App;
use crate::cli::Command;
@@ -45,9 +43,6 @@ mod network;
mod ui;
mod utils;
static MIN_TERM_WIDTH: u16 = 205;
static MIN_TERM_HEIGHT: u16 = 40;
#[derive(Debug, Parser)]
#[command(
name = crate_name!(),
@@ -66,22 +61,21 @@ static MIN_TERM_HEIGHT: u16 = 40;
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(
long,
global = true,
env = "MANAGARR_DISABLE_SPINNER",
help = "Disable the spinner (can sometimes make parsing output challenging)"
)]
disable_spinner: bool,
#[arg(
long,
global = true,
value_parser,
env = "MANAGARR_CONFIG_FILE",
help = "The Managarr configuration file to use"
)]
config: Option<PathBuf>,
#[arg(long, global = true, help = "Disable the terminal size checks")]
disable_terminal_size_checks: bool,
}
fn load_config(path: &str) -> Result<AppConfig> {
let file = File::open(path).map_err(|e| anyhow!(e))?;
let reader = BufReader::new(file);
let config = serde_yaml::from_reader(reader)?;
Ok(config)
}
#[tokio::main]
@@ -98,6 +92,9 @@ async fn main() -> Result<()> {
} else {
confy::load("managarr", "config")?
};
let spinner_disabled = args.disable_spinner;
config.validate();
let reqwest_client = build_network_client(&config);
let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
let cancellation_token = CancellationToken::new();
let ctrlc_cancellation_token = cancellation_token.clone();
@@ -111,30 +108,31 @@ async fn main() -> Result<()> {
let app = Arc::new(Mutex::new(App::new(
sync_network_tx,
config,
config.clone(),
cancellation_token.clone(),
)));
match args.command {
Some(command) => match command {
Command::Radarr(_) => {
let app_nw = Arc::clone(&app);
let mut network = Network::new(&app_nw, cancellation_token);
if let Err(e) = cli::handle_command(&app, command, &mut network).await {
eprintln!("error: {}", e.to_string().red());
process::exit(1);
Command::Radarr(_) | Command::Sonarr(_) => {
if spinner_disabled {
start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await;
} else {
start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await;
}
}
Command::Completions { shell } => {
let mut cli = Cli::command();
generate(shell, &mut cli, "managarr", &mut io::stdout())
}
Command::TailLogs { no_color } => tail_logs(no_color).await,
},
None => {
let app_nw = Arc::clone(&app);
std::thread::spawn(move || start_networking(sync_network_rx, &app_nw, cancellation_token));
start_ui(&app, !args.disable_terminal_size_checks).await?;
std::thread::spawn(move || {
start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client)
});
start_ui(&app).await?;
}
}
@@ -146,30 +144,29 @@ async fn start_networking(
mut network_rx: Receiver<NetworkEvent>,
app: &Arc<Mutex<App<'_>>>,
cancellation_token: CancellationToken,
client: Client,
) {
let mut network = Network::new(app, cancellation_token);
let mut network = Network::new(app, cancellation_token, client);
while let Some(network_event) = network_rx.recv().await {
if let Err(e) = network.handle_network_event(network_event).await {
error!("Encountered an error handling network event: {e:?}");
loop {
select! {
Some(network_event) = network_rx.recv() => {
if let Err(e) = network.handle_network_event(network_event).await {
error!("Encountered an error handling network event: {e:?}");
}
}
_ = network.cancellation_token.cancelled() => {
warn!("Clearing network channel");
while network_rx.try_recv().is_ok() {
// Discard the message
}
network.reset_cancellation_token().await;
}
}
}
}
async fn start_ui(app: &Arc<Mutex<App<'_>>>, check_terminal_size: bool) -> Result<()> {
if check_terminal_size {
let (width, height) = size()?;
if width < MIN_TERM_WIDTH || height < MIN_TERM_HEIGHT {
return Err(anyhow!(
"Terminal too small. Minimum size required: {}x{}; current terminal size: {}x{}",
MIN_TERM_WIDTH,
MIN_TERM_HEIGHT,
width,
height
));
}
}
async fn start_ui(app: &Arc<Mutex<App<'_>>>) -> Result<()> {
let mut stdout = io::stdout();
enable_raw_mode()?;
@@ -240,14 +237,9 @@ fn panic_hook(info: &PanicHookInfo<'_>) {
#[cfg(not(debug_assertions))]
fn panic_hook(info: &PanicHookInfo<'_>) {
use human_panic::{handle_dump, print_msg, Metadata};
use human_panic::{handle_dump, metadata, print_msg};
let meta = Metadata {
version: env!("CARGO_PKG_VERSION").into(),
name: env!("CARGO_PKG_NAME").into(),
authors: env!("CARGO_PKG_AUTHORS").replace(":", ", ").into(),
homepage: env!("CARGO_PKG_HOMEPAGE").into(),
};
let meta = metadata!();
let file_path = handle_dump(&meta, info);
disable_raw_mode().unwrap();
execute!(io::stdout(), LeaveAlternateScreen).unwrap();
+21 -1
View File
@@ -6,10 +6,15 @@ use radarr_models::RadarrSerdeable;
use regex::Regex;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Number;
use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use sonarr_models::SonarrSerdeable;
pub mod radarr_models;
pub mod servarr_data;
pub mod servarr_models;
pub mod sonarr_models;
pub mod stateful_list;
pub mod stateful_table;
pub mod stateful_tree;
#[cfg(test)]
#[path = "model_tests.rs"]
@@ -20,7 +25,7 @@ mod model_tests;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Route {
Radarr(ActiveRadarrBlock, Option<ActiveRadarrBlock>),
Sonarr,
Sonarr(ActiveSonarrBlock, Option<ActiveSonarrBlock>),
Readarr,
Lidarr,
Whisparr,
@@ -33,6 +38,11 @@ pub enum Route {
#[serde(untagged)]
pub enum Serdeable {
Radarr(RadarrSerdeable),
Sonarr(SonarrSerdeable),
}
pub trait EnumDisplayStyle<'a> {
fn to_display_str(self) -> &'a str;
}
pub trait Scrollable {
@@ -359,6 +369,16 @@ where
)))
}
pub fn from_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let num: Number = Deserialize::deserialize(deserializer)?;
num.as_f64().ok_or(de::Error::custom(format!(
"Unable to convert Number to f64: {num:?}"
)))
}
pub fn strip_non_search_characters(input: &str) -> String {
Regex::new(r"[^a-zA-Z0-9.,/'\-:\s]")
.unwrap()
+8
View File
@@ -10,6 +10,7 @@ mod tests {
use serde::de::IntoDeserializer;
use serde_json::to_string;
use crate::models::from_f64;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::{from_i64, strip_non_search_characters};
use crate::models::{
@@ -649,6 +650,13 @@ mod tests {
);
}
#[test]
fn test_from_f64() {
let deserializer: F64Deserializer<ValueError> = 1f64.into_deserializer();
assert_eq!(from_f64(deserializer), Ok(1.0));
}
#[test]
fn test_horizontally_scrollable_serialize() {
let text = HorizontallyScrollableText::from("Test");
+33 -194
View File
@@ -9,7 +9,11 @@ use strum_macros::EnumIter;
use crate::{models::HorizontallyScrollableText, serde_enum_from};
use super::Serdeable;
use super::servarr_models::{
DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper,
QueueEvent, RootFolder, SecurityConfig, Tag, Update,
};
use super::{EnumDisplayStyle, Serdeable};
#[cfg(test)]
#[path = "radarr_models_tests.rs"]
@@ -25,7 +29,7 @@ pub struct AddMovieBody {
pub minimum_availability: String,
pub monitored: bool,
pub tags: Vec<i64>,
pub add_options: AddOptions,
pub add_options: AddMovieOptions,
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
@@ -47,16 +51,11 @@ pub struct AddMovieSearchResult {
#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AddOptions {
pub struct AddMovieOptions {
pub monitor: String,
pub search_for_movie: bool,
}
#[derive(Default, Serialize, Debug)]
pub struct AddRootFolderBody {
pub path: String,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct BlocklistResponse {
pub records: Vec<BlocklistItem>,
@@ -117,12 +116,6 @@ pub struct CollectionMovie {
pub ratings: RatingsList,
}
#[derive(Default, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CommandBody {
pub name: String,
}
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Credit {
@@ -150,15 +143,6 @@ pub struct DeleteMovieParams {
pub add_list_exclusion: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DiskSpace {
#[serde(deserialize_with = "super::from_i64")]
pub free_space: i64,
#[serde(deserialize_with = "super::from_i64")]
pub total_space: i64,
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
@@ -195,22 +179,6 @@ pub struct EditCollectionParams {
pub search_on_add: Option<bool>,
}
#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditIndexerParams {
pub indexer_id: i64,
pub name: Option<String>,
pub enable_rss: Option<bool>,
pub enable_automatic_search: Option<bool>,
pub enable_interactive_search: Option<bool>,
pub url: Option<String>,
pub api_key: Option<String>,
pub seed_ratio: Option<String>,
pub tags: Option<Vec<i64>>,
pub priority: Option<i64>,
pub clear_tags: bool,
}
#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditMovieParams {
@@ -223,35 +191,6 @@ pub struct EditMovieParams {
pub clear_tags: bool,
}
#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Indexer {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub name: Option<String>,
pub implementation: Option<String>,
pub implementation_name: Option<String>,
pub config_contract: Option<String>,
pub supports_rss: bool,
pub supports_search: bool,
pub fields: Option<Vec<IndexerField>>,
pub enable_rss: bool,
pub enable_automatic_search: bool,
pub enable_interactive_search: bool,
pub protocol: String,
#[serde(deserialize_with = "super::from_i64")]
pub priority: i64,
#[serde(deserialize_with = "super::from_i64")]
pub download_client_id: i64,
pub tags: Vec<Number>,
}
#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)]
pub struct IndexerField {
pub name: Option<String>,
pub value: Option<Value>,
}
#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IndexerSettings {
@@ -290,28 +229,6 @@ pub struct IndexerValidationFailure {
pub severity: String,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
pub struct Language {
pub name: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Log {
pub time: DateTime<Utc>,
pub exception: Option<String>,
pub exception_type: Option<String>,
pub level: String,
pub logger: Option<String>,
pub message: Option<String>,
pub method: Option<String>,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct LogResponse {
pub records: Vec<Log>,
}
#[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)]
#[derivative(Default)]
#[serde(rename_all = "camelCase")]
@@ -360,8 +277,8 @@ impl Display for MinimumAvailability {
}
}
impl MinimumAvailability {
pub fn to_display_str<'a>(self) -> &'a str {
impl<'a> EnumDisplayStyle<'a> for MinimumAvailability {
fn to_display_str(self) -> &'a str {
match self {
MinimumAvailability::Tba => "TBA",
MinimumAvailability::Announced => "Announced",
@@ -372,30 +289,30 @@ impl MinimumAvailability {
}
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum)]
pub enum Monitor {
pub enum MovieMonitor {
#[default]
MovieOnly,
MovieAndCollection,
None,
}
impl Display for Monitor {
impl Display for MovieMonitor {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let monitor = match self {
Monitor::MovieOnly => "movieOnly",
Monitor::MovieAndCollection => "movieAndCollection",
Monitor::None => "none",
MovieMonitor::MovieOnly => "movieOnly",
MovieMonitor::MovieAndCollection => "movieAndCollection",
MovieMonitor::None => "none",
};
write!(f, "{monitor}")
}
}
impl Monitor {
pub fn to_display_str<'a>(self) -> &'a str {
impl<'a> EnumDisplayStyle<'a> for MovieMonitor {
fn to_display_str(self) -> &'a str {
match self {
Monitor::MovieOnly => "Movie only",
Monitor::MovieAndCollection => "Movie and Collection",
Monitor::None => "None",
MovieMonitor::MovieOnly => "Movie only",
MovieMonitor::MovieAndCollection => "Movie and Collection",
MovieMonitor::None => "None",
}
}
}
@@ -464,45 +381,6 @@ pub struct MovieHistoryItem {
pub event_type: String,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
pub struct Quality {
pub name: String,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct QualityProfile {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub name: String,
}
impl From<(&i64, &String)> for QualityProfile {
fn from(value: (&i64, &String)) -> Self {
QualityProfile {
id: *value.0,
name: value.1.clone(),
}
}
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
pub struct QualityWrapper {
pub quality: Quality,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct QueueEvent {
pub trigger: String,
pub name: String,
pub command_name: String,
pub status: String,
pub queued: DateTime<Utc>,
pub started: Option<DateTime<Utc>>,
pub ended: Option<DateTime<Utc>>,
pub duration: Option<String>,
}
#[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[derivative(Default)]
pub struct Rating {
@@ -521,7 +399,7 @@ pub struct RatingsList {
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[serde(default)]
pub struct Release {
pub struct RadarrRelease {
pub guid: String,
pub protocol: String,
#[serde(deserialize_with = "super::from_i64")]
@@ -542,24 +420,12 @@ pub struct Release {
#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseDownloadBody {
pub struct RadarrReleaseDownloadBody {
pub guid: String,
pub indexer_id: i64,
pub movie_id: i64,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RootFolder {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub path: String,
pub accessible: bool,
#[serde(deserialize_with = "super::from_i64")]
pub free_space: i64,
pub unmapped_folders: Option<Vec<UnmappedFolder>>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
@@ -567,18 +433,11 @@ pub struct SystemStatus {
pub start_time: DateTime<Utc>,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct Tag {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub label: String,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Task {
pub struct RadarrTask {
pub name: String,
pub task_name: TaskName,
pub task_name: RadarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
pub last_execution: DateTime<Utc>,
@@ -588,7 +447,7 @@ pub struct Task {
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)]
#[serde(rename_all = "PascalCase")]
pub enum TaskName {
pub enum RadarrTaskName {
#[default]
ApplicationCheckUpdate,
Backup,
@@ -603,7 +462,7 @@ pub enum TaskName {
RssSync,
}
impl Display for TaskName {
impl Display for RadarrTaskName {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let task_name = serde_json::to_string(&self)
.expect("Unable to serialize task name")
@@ -612,30 +471,6 @@ impl Display for TaskName {
}
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)]
pub struct UnmappedFolder {
pub name: String,
pub path: String,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Update {
pub version: String,
pub release_date: DateTime<Utc>,
pub installed: bool,
pub latest: bool,
pub installed_on: Option<DateTime<Utc>>,
pub changes: UpdateChanges,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UpdateChanges {
pub new: Option<Vec<String>>,
pub fixed: Option<Vec<String>>,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
@@ -647,6 +482,7 @@ pub enum RadarrSerdeable {
Credits(Vec<Credit>),
DiskSpaces(Vec<DiskSpace>),
DownloadsResponse(DownloadsResponse),
HostConfig(HostConfig),
Indexers(Vec<Indexer>),
IndexerSettings(IndexerSettings),
LogResponse(LogResponse),
@@ -655,11 +491,12 @@ pub enum RadarrSerdeable {
Movies(Vec<Movie>),
QualityProfiles(Vec<QualityProfile>),
QueueEvents(Vec<QueueEvent>),
Releases(Vec<Release>),
Releases(Vec<RadarrRelease>),
RootFolders(Vec<RootFolder>),
SecurityConfig(SecurityConfig),
SystemStatus(SystemStatus),
Tags(Vec<Tag>),
Tasks(Vec<Task>),
Tasks(Vec<RadarrTask>),
Updates(Vec<Update>),
AddMovieSearchResults(Vec<AddMovieSearchResult>),
IndexerTestResults(Vec<IndexerTestResult>),
@@ -686,6 +523,7 @@ serde_enum_from!(
Credits(Vec<Credit>),
DiskSpaces(Vec<DiskSpace>),
DownloadsResponse(DownloadsResponse),
HostConfig(HostConfig),
Indexers(Vec<Indexer>),
IndexerSettings(IndexerSettings),
LogResponse(LogResponse),
@@ -694,11 +532,12 @@ serde_enum_from!(
Movies(Vec<Movie>),
QualityProfiles(Vec<QualityProfile>),
QueueEvents(Vec<QueueEvent>),
Releases(Vec<Release>),
Releases(Vec<RadarrRelease>),
RootFolders(Vec<RootFolder>),
SecurityConfig(SecurityConfig),
SystemStatus(SystemStatus),
Tags(Vec<Tag>),
Tasks(Vec<Task>),
Tasks(Vec<RadarrTask>),
Updates(Vec<Update>),
AddMovieSearchResults(Vec<AddMovieSearchResult>),
IndexerTestResults(Vec<IndexerTestResult>),
+43 -15
View File
@@ -6,17 +6,18 @@ mod tests {
use crate::models::{
radarr_models::{
AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace,
DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log,
LogResponse, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile,
QueueEvent, RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update,
DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult,
MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease,
RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, Tag, Update,
},
Serdeable,
servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig},
EnumDisplayStyle, Serdeable,
};
#[test]
fn test_task_name_display() {
assert_str_eq!(
TaskName::ApplicationCheckUpdate.to_string(),
RadarrTaskName::ApplicationCheckUpdate.to_string(),
"ApplicationCheckUpdate"
);
}
@@ -42,22 +43,22 @@ mod tests {
#[test]
fn test_monitor_display() {
assert_str_eq!(Monitor::MovieOnly.to_string(), "movieOnly");
assert_str_eq!(MovieMonitor::MovieOnly.to_string(), "movieOnly");
assert_str_eq!(
Monitor::MovieAndCollection.to_string(),
MovieMonitor::MovieAndCollection.to_string(),
"movieAndCollection"
);
assert_str_eq!(Monitor::None.to_string(), "none");
assert_str_eq!(MovieMonitor::None.to_string(), "none");
}
#[test]
fn test_monitor_to_display_str() {
assert_str_eq!(Monitor::MovieOnly.to_display_str(), "Movie only");
assert_str_eq!(MovieMonitor::MovieOnly.to_display_str(), "Movie only");
assert_str_eq!(
Monitor::MovieAndCollection.to_display_str(),
MovieMonitor::MovieAndCollection.to_display_str(),
"Movie and Collection"
);
assert_str_eq!(Monitor::None.to_display_str(), "None");
assert_str_eq!(MovieMonitor::None.to_display_str(), "None");
}
#[test]
@@ -178,6 +179,18 @@ mod tests {
assert_eq!(radarr_serdeable, RadarrSerdeable::DiskSpaces(disk_spaces));
}
#[test]
fn test_radarr_serdeable_from_host_config() {
let host_config = HostConfig {
port: 1234,
..HostConfig::default()
};
let radarr_serdeable: RadarrSerdeable = host_config.clone().into();
assert_eq!(radarr_serdeable, RadarrSerdeable::HostConfig(host_config));
}
#[test]
fn test_radarr_serdeable_from_downloads_response() {
let downloads_response = DownloadsResponse {
@@ -304,9 +317,9 @@ mod tests {
#[test]
fn test_radarr_serdeable_from_releases() {
let releases = vec![Release {
let releases = vec![RadarrRelease {
size: 1,
..Release::default()
..RadarrRelease::default()
}];
let radarr_serdeable: RadarrSerdeable = releases.clone().into();
@@ -326,6 +339,21 @@ mod tests {
assert_eq!(radarr_serdeable, RadarrSerdeable::RootFolders(root_folders));
}
#[test]
fn test_radarr_serdeable_from_security_config() {
let security_config = SecurityConfig {
username: Some("Test".to_owned()),
..SecurityConfig::default()
};
let radarr_serdeable: RadarrSerdeable = security_config.clone().into();
assert_eq!(
radarr_serdeable,
RadarrSerdeable::SecurityConfig(security_config)
);
}
#[test]
fn test_radarr_serdeable_from_system_status() {
let system_status = SystemStatus {
@@ -355,9 +383,9 @@ mod tests {
#[test]
fn test_radarr_serdeable_from_tasks() {
let tasks = vec![Task {
let tasks = vec![RadarrTask {
name: "test".to_owned(),
..Task::default()
..RadarrTask::default()
}];
let radarr_serdeable: RadarrSerdeable = tasks.clone().into();
+2
View File
@@ -1 +1,3 @@
pub mod modals;
pub mod radarr;
pub mod sonarr;
+20
View File
@@ -0,0 +1,20 @@
use crate::models::HorizontallyScrollableText;
#[derive(Default, Debug, PartialEq, Eq)]
pub struct EditIndexerModal {
pub name: HorizontallyScrollableText,
pub enable_rss: Option<bool>,
pub enable_automatic_search: Option<bool>,
pub enable_interactive_search: Option<bool>,
pub url: HorizontallyScrollableText,
pub api_key: HorizontallyScrollableText,
pub seed_ratio: HorizontallyScrollableText,
pub tags: HorizontallyScrollableText,
}
#[derive(Default, Clone, Eq, PartialEq, Debug)]
pub struct IndexerTestResultModalItem {
pub name: String,
pub is_valid: bool,
pub validation_failures: HorizontallyScrollableText,
}
+6 -24
View File
@@ -1,10 +1,11 @@
use strum::IntoEnumIterator;
use crate::models::radarr_models::{
Collection, Credit, Indexer, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release,
RootFolder,
Collection, Credit, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrRelease,
};
use crate::models::servarr_data::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::RadarrData;
use crate::models::servarr_models::{Indexer, RootFolder};
use crate::models::stateful_list::StatefulList;
use crate::models::stateful_table::StatefulTable;
use crate::models::{HorizontallyScrollableText, ScrollableText};
@@ -22,19 +23,7 @@ pub struct MovieDetailsModal {
pub movie_history: StatefulTable<MovieHistoryItem>,
pub movie_cast: StatefulTable<Credit>,
pub movie_crew: StatefulTable<Credit>,
pub movie_releases: StatefulTable<Release>,
}
#[derive(Default, Debug, PartialEq, Eq)]
pub struct EditIndexerModal {
pub name: HorizontallyScrollableText,
pub enable_rss: Option<bool>,
pub enable_automatic_search: Option<bool>,
pub enable_interactive_search: Option<bool>,
pub url: HorizontallyScrollableText,
pub api_key: HorizontallyScrollableText,
pub seed_ratio: HorizontallyScrollableText,
pub tags: HorizontallyScrollableText,
pub movie_releases: StatefulTable<RadarrRelease>,
}
impl From<&RadarrData<'_>> for EditIndexerModal {
@@ -195,7 +184,7 @@ impl From<&RadarrData<'_>> for EditMovieModal {
#[derive(Default)]
pub struct AddMovieModal {
pub root_folder_list: StatefulList<RootFolder>,
pub monitor_list: StatefulList<Monitor>,
pub monitor_list: StatefulList<MovieMonitor>,
pub minimum_availability_list: StatefulList<MinimumAvailability>,
pub quality_profile_list: StatefulList<String>,
pub tags: HorizontallyScrollableText,
@@ -206,7 +195,7 @@ impl From<&RadarrData<'_>> for AddMovieModal {
let mut add_movie_modal = AddMovieModal::default();
add_movie_modal
.monitor_list
.set_items(Vec::from_iter(Monitor::iter()));
.set_items(Vec::from_iter(MovieMonitor::iter()));
add_movie_modal
.minimum_availability_list
.set_items(Vec::from_iter(MinimumAvailability::iter()));
@@ -291,10 +280,3 @@ impl From<&RadarrData<'_>> for EditCollectionModal {
edit_collection_modal
}
}
#[derive(Default, Clone, Eq, PartialEq, Debug)]
pub struct IndexerTestResultModalItem {
pub name: String,
pub is_valid: bool,
pub validation_failures: HorizontallyScrollableText,
}
@@ -1,13 +1,12 @@
#[cfg(test)]
mod test {
use crate::models::radarr_models::{
Collection, Indexer, IndexerField, MinimumAvailability, Monitor, Movie, RootFolder,
};
use crate::models::radarr_models::{Collection, MinimumAvailability, Movie, MovieMonitor};
use crate::models::servarr_data::radarr::modals::{
AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal,
};
use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data;
use crate::models::servarr_data::radarr::radarr_data::RadarrData;
use crate::models::servarr_models::{Indexer, IndexerField, RootFolder};
use crate::models::stateful_table::StatefulTable;
use bimap::BiMap;
use pretty_assertions::{assert_eq, assert_str_eq};
@@ -184,7 +183,7 @@ mod test {
assert_eq!(
add_movie_modal.monitor_list.items,
Vec::from_iter(Monitor::iter())
Vec::from_iter(MovieMonitor::iter())
);
assert_eq!(
add_movie_modal.minimum_availability_list.items,
@@ -6,13 +6,14 @@ use crate::app::radarr::radarr_context_clues::{
SYSTEM_CONTEXT_CLUES,
};
use crate::models::radarr_models::{
AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DiskSpace, DownloadRecord,
Indexer, IndexerSettings, Movie, QueueEvent, RootFolder, Task,
AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord,
IndexerSettings, Movie, RadarrTask,
};
use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem};
use crate::models::servarr_data::radarr::modals::{
AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem,
MovieDetailsModal,
AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal,
};
use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder};
use crate::models::stateful_list::StatefulList;
use crate::models::stateful_table::StatefulTable;
use crate::models::{
@@ -47,7 +48,7 @@ pub struct RadarrData<'a> {
pub collection_movies: StatefulTable<CollectionMovie>,
pub logs: StatefulList<HorizontallyScrollableText>,
pub log_details: StatefulList<HorizontallyScrollableText>,
pub tasks: StatefulTable<Task>,
pub tasks: StatefulTable<RadarrTask>,
pub queued_events: StatefulTable<QueueEvent>,
pub updates: ScrollableText,
pub main_tabs: TabState,
@@ -19,6 +19,14 @@ mod tests {
use crate::assert_movie_info_tabs_reset;
use crate::models::BlockSelectionState;
#[test]
fn test_from_active_radarr_block_to_route() {
assert_eq!(
Route::from(ActiveRadarrBlock::AddMoviePrompt),
Route::Radarr(ActiveRadarrBlock::AddMoviePrompt, None)
);
}
#[test]
fn test_from_tuple_to_route_with_context() {
assert_eq!(
@@ -60,7 +68,7 @@ mod tests {
assert_eq!(radarr_data.disk_space_vec, Vec::new());
assert!(radarr_data.version.is_empty());
assert_eq!(radarr_data.start_time, <DateTime<Utc>>::default());
assert!(radarr_data.movies.items.is_empty());
assert!(radarr_data.movies.is_empty());
assert_eq!(radarr_data.selected_block, BlockSelectionState::default());
assert!(radarr_data.downloads.items.is_empty());
assert!(radarr_data.indexers.items.is_empty());
@@ -1,7 +1,7 @@
#[cfg(test)]
pub mod utils {
use crate::models::radarr_models::{
AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, Release,
AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, RadarrRelease,
};
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
use crate::models::servarr_data::radarr::radarr_data::RadarrData;
@@ -24,7 +24,7 @@ pub mod utils {
.set_items(vec![Credit::default()]);
movie_details_modal
.movie_releases
.set_items(vec![Release::default()]);
.set_items(vec![RadarrRelease::default()]);
let mut radarr_data = RadarrData {
delete_movie_files: true,
+2
View File
@@ -0,0 +1,2 @@
pub mod modals;
pub mod sonarr_data;
+267
View File
@@ -0,0 +1,267 @@
use strum::IntoEnumIterator;
use crate::models::{
servarr_data::modals::EditIndexerModal,
servarr_models::{Indexer, RootFolder},
sonarr_models::{Episode, Series, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease},
stateful_list::StatefulList,
stateful_table::StatefulTable,
HorizontallyScrollableText, ScrollableText,
};
use super::sonarr_data::SonarrData;
#[cfg(test)]
#[path = "modals_tests.rs"]
mod modals_tests;
#[derive(Default)]
pub struct AddSeriesModal {
pub root_folder_list: StatefulList<RootFolder>,
pub monitor_list: StatefulList<SeriesMonitor>,
pub quality_profile_list: StatefulList<String>,
pub language_profile_list: StatefulList<String>,
pub series_type_list: StatefulList<SeriesType>,
pub use_season_folder: bool,
pub tags: HorizontallyScrollableText,
}
impl From<&SonarrData> for AddSeriesModal {
fn from(sonarr_data: &SonarrData) -> AddSeriesModal {
let mut add_series_modal = AddSeriesModal {
use_season_folder: true,
..AddSeriesModal::default()
};
add_series_modal
.monitor_list
.set_items(Vec::from_iter(SeriesMonitor::iter()));
add_series_modal
.series_type_list
.set_items(Vec::from_iter(SeriesType::iter()));
let mut quality_profile_names: Vec<String> = sonarr_data
.quality_profile_map
.right_values()
.cloned()
.collect();
quality_profile_names.sort();
add_series_modal
.quality_profile_list
.set_items(quality_profile_names);
let mut language_profile_names: Vec<String> = sonarr_data
.language_profiles_map
.right_values()
.cloned()
.collect();
language_profile_names.sort();
add_series_modal
.language_profile_list
.set_items(language_profile_names);
add_series_modal
.root_folder_list
.set_items(sonarr_data.root_folders.items.to_vec());
add_series_modal
}
}
impl From<&SonarrData> for EditIndexerModal {
fn from(sonarr_data: &SonarrData) -> EditIndexerModal {
let mut edit_indexer_modal = EditIndexerModal::default();
let Indexer {
name,
enable_rss,
enable_automatic_search,
enable_interactive_search,
tags,
fields,
..
} = sonarr_data.indexers.current_selection();
let seed_ratio_field_option = fields
.as_ref()
.unwrap()
.iter()
.find(|field| field.name.as_ref().unwrap() == "seedCriteria.seedRatio");
let seed_ratio_value_option = if let Some(seed_ratio_field) = seed_ratio_field_option {
seed_ratio_field.value.clone()
} else {
None
};
edit_indexer_modal.name = name.clone().unwrap().into();
edit_indexer_modal.enable_rss = Some(*enable_rss);
edit_indexer_modal.enable_automatic_search = Some(*enable_automatic_search);
edit_indexer_modal.enable_interactive_search = Some(*enable_interactive_search);
edit_indexer_modal.url = fields
.as_ref()
.unwrap()
.iter()
.find(|field| field.name.as_ref().unwrap() == "baseUrl")
.unwrap()
.value
.clone()
.unwrap()
.as_str()
.unwrap()
.into();
edit_indexer_modal.api_key = fields
.as_ref()
.unwrap()
.iter()
.find(|field| field.name.as_ref().unwrap() == "apiKey")
.unwrap()
.value
.clone()
.unwrap()
.as_str()
.unwrap()
.into();
if seed_ratio_value_option.is_some() {
edit_indexer_modal.seed_ratio = seed_ratio_value_option
.unwrap()
.as_f64()
.unwrap()
.to_string()
.into();
}
edit_indexer_modal.tags = tags
.iter()
.map(|tag_id| {
sonarr_data
.tags_map
.get_by_left(&tag_id.as_i64().unwrap())
.unwrap()
.clone()
})
.collect::<Vec<String>>()
.join(", ")
.into();
edit_indexer_modal
}
}
#[derive(Default)]
pub struct EditSeriesModal {
pub series_type_list: StatefulList<SeriesType>,
pub quality_profile_list: StatefulList<String>,
pub language_profile_list: StatefulList<String>,
pub monitored: Option<bool>,
pub use_season_folders: Option<bool>,
pub path: HorizontallyScrollableText,
pub tags: HorizontallyScrollableText,
}
impl From<&SonarrData> for EditSeriesModal {
fn from(sonarr_data: &SonarrData) -> EditSeriesModal {
let mut edit_series_modal = EditSeriesModal::default();
let Series {
path,
tags,
monitored,
season_folder,
series_type,
quality_profile_id,
language_profile_id,
..
} = sonarr_data.series.current_selection();
edit_series_modal
.series_type_list
.set_items(Vec::from_iter(SeriesType::iter()));
edit_series_modal.path = path.clone().into();
edit_series_modal.tags = tags
.iter()
.map(|tag_id| {
sonarr_data
.tags_map
.get_by_left(&tag_id.as_i64().unwrap())
.unwrap()
.clone()
})
.collect::<Vec<String>>()
.join(", ")
.into();
edit_series_modal.monitored = Some(*monitored);
edit_series_modal.use_season_folders = Some(*season_folder);
let series_type_index = edit_series_modal
.series_type_list
.items
.iter()
.position(|st| st == series_type);
edit_series_modal
.series_type_list
.state
.select(series_type_index);
let mut quality_profile_names: Vec<String> = sonarr_data
.quality_profile_map
.right_values()
.cloned()
.collect();
quality_profile_names.sort();
edit_series_modal
.quality_profile_list
.set_items(quality_profile_names);
let quality_profile_name = sonarr_data
.quality_profile_map
.get_by_left(quality_profile_id)
.unwrap();
let quality_profile_index = edit_series_modal
.quality_profile_list
.items
.iter()
.position(|profile| profile == quality_profile_name);
edit_series_modal
.quality_profile_list
.state
.select(quality_profile_index);
let mut language_profile_names: Vec<String> = sonarr_data
.language_profiles_map
.right_values()
.cloned()
.collect();
language_profile_names.sort();
edit_series_modal
.language_profile_list
.set_items(language_profile_names);
let language_profile_name = sonarr_data
.language_profiles_map
.get_by_left(language_profile_id)
.unwrap();
let language_profile_index = edit_series_modal
.language_profile_list
.items
.iter()
.position(|profile| profile == language_profile_name);
edit_series_modal
.language_profile_list
.state
.select(language_profile_index);
edit_series_modal
}
}
#[derive(Default)]
pub struct EpisodeDetailsModal {
// Temporarily allowing this, since the value is only current written and not read.
// This will be read from once I begin the UI work for Sonarr
#[allow(dead_code)]
pub episode_details: ScrollableText,
pub file_details: String,
pub audio_details: String,
pub video_details: String,
pub episode_history: StatefulTable<SonarrHistoryItem>,
pub episode_releases: StatefulTable<SonarrRelease>,
}
#[derive(Default)]
pub struct SeasonDetailsModal {
pub episodes: StatefulTable<Episode>,
pub episode_details_modal: Option<EpisodeDetailsModal>,
pub season_releases: StatefulTable<SonarrRelease>,
}

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