Compare commits

...

5 Commits

Author SHA1 Message Date
9e96e74c87 ci: Fixed the docker-build Justfile recipe
Check / stable / fmt (pull_request) Successful in 10m40s
Check / beta / clippy (pull_request) Successful in 10m59s
Check / stable / clippy (pull_request) Successful in 10m59s
Check / nightly / doc (pull_request) Successful in 58s
Check / 1.89.0 / check (pull_request) Successful in 1m2s
Test Suite / ubuntu / beta (pull_request) Successful in 1m43s
Test Suite / ubuntu / stable (pull_request) Successful in 1m41s
Test Suite / ubuntu / stable / coverage (pull_request) Successful in 13m1s
Test Suite / macos-latest / stable (pull_request) Has been cancelled
Test Suite / windows-latest / stable (pull_request) Has been cancelled
2026-01-21 10:39:51 -07:00
ddb869c341 docs: Reword some Sonarr manual search CLI docs to be more explicit about how the results are filtered 2026-01-20 14:37:42 -07:00
f17f542e8e refactor: Refactored the SonarrEvent enum to not unnecessarily wrap dual series_id and season_number values in a tuple when both values can be passed directly 2026-01-19 16:44:10 -07:00
a2e6400a38 docs: Updated README with information about Lidarr support 2026-01-19 16:29:02 -07:00
89f5ff6bc7 feat: Blocklist support in Lidarr in both the CLI and TUI 2026-01-19 16:13:11 -07:00
73 changed files with 2341 additions and 166 deletions
+53 -21
View File
@@ -12,17 +12,16 @@
![Docker pulls](https://img.shields.io/docker/pulls/darkalex17/managarr?label=Docker%20downloads) ![Docker pulls](https://img.shields.io/docker/pulls/darkalex17/managarr?label=Docker%20downloads)
[![Matrix](https://img.shields.io/matrix/managarr-room%3Amatrix.org?logo=matrix&server_fqdn=matrix.org&fetchMode=guest&style=social&label=Managarr%20Matrix%20Space&link=https%3A%2F%2Fmatrix.to%2F%23%2F%23managarr%3Amatrix.org)](https://matrix.to/#/#managarr:matrix.org) [![Matrix](https://img.shields.io/matrix/managarr-room%3Amatrix.org?logo=matrix&server_fqdn=matrix.org&fetchMode=guest&style=social&label=Managarr%20Matrix%20Space&link=https%3A%2F%2Fmatrix.to%2F%23%2F%23managarr%3Amatrix.org)](https://matrix.to/#/#managarr:matrix.org)
Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust! Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust!
![library](screenshots/sonarr/sonarr_library.png) ![library](screenshots/lidarr/lidarr_library.png)
## What Servarrs are supported? ## What Servarrs are supported?
- [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr) - [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr)
- [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr) - [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr)
- [x] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr)
- [ ] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr) - [ ] ![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) - [ ] ![prowlarr_logo](logos/prowlarr.png) [Prowlarr](https://wiki.servarr.com/en/prowlarr)
- [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr) - [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr)
- [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/) - [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/)
@@ -96,7 +95,7 @@ of Chocolatey packages take quite some time, and thus the package may not be ava
choco install managarr choco install managarr
# Some newer releases may require a version number, so you can specify it like so: # Some newer releases may require a version number, so you can specify it like so:
choco install managarr --version=0.5.0 choco install managarr --version=0.7.0
``` ```
To upgrade to the latest and greatest version of Managarr: To upgrade to the latest and greatest version of Managarr:
@@ -104,7 +103,7 @@ To upgrade to the latest and greatest version of Managarr:
choco upgrade managarr choco upgrade managarr
# To upgrade to a specific version: # To upgrade to a specific version:
choco upgrade managarr --version=0.5.0 choco upgrade managarr --version=0.7.0
``` ```
### Manual ### Manual
@@ -182,14 +181,30 @@ Key:
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | | ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks | | ✅ | ✅ | Manually trigger scheduled tasks |
### Lidarr
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, tracks |
| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add artists to your library |
| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files |
| ✅ | ✅ | Trigger automatic searches for artists or albums |
| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads |
| ✅ | ✅ | Manually search for full artist discographies or albums |
| ✅ | ✅ | Edit your artists and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Readarr ### Readarr
- [ ] Support for Readarr - [ ] Support for Readarr
### Lidarr
- [ ] Support for Lidarr
### Whisparr ### Whisparr
- [ ] Support for Whisparr - [ ] Support for Whisparr
@@ -231,7 +246,7 @@ To see all available commands, simply run `managarr --help`:
```shell ```shell
$ managarr --help $ managarr --help
managarr 0.5.1 managarr 0.7.0
Alex Clarke <alex.j.tusa@gmail.com> Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs A TUI and CLI to manage your Servarrs
@@ -241,20 +256,24 @@ Usage: managarr [OPTIONS] [COMMAND]
Commands: Commands:
radarr Commands for manging your Radarr instance radarr Commands for manging your Radarr instance
sonarr Commands for manging your Sonarr instance sonarr Commands for manging your Sonarr instance
lidarr Commands for manging your Lidarr instance
completions Generate shell completions for the Managarr CLI completions Generate shell completions for the Managarr CLI
tail-logs Tail Managarr logs tail-logs Tail Managarr logs
help Print this message or the help of the given subcommand(s) help Print this message or the help of the given subcommand(s)
Options: Options:
-h, --help Print help
-V, --version Print version
Global Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] --disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=] --config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=] --themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=] --theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use. --servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used. This is useful when you have multiple instances of the same Servarr defined in your config file.
-h, --help Print help By default, if left empty, the first configured Servarr instance listed in the config file will be used.
-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 Sonarr, 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:
@@ -283,12 +302,21 @@ Commands:
test-all-indexers Test all Sonarr indexers test-all-indexers Test all Sonarr indexers
toggle-episode-monitoring Toggle monitoring for the specified episode toggle-episode-monitoring Toggle monitoring for the specified episode
toggle-season-monitoring Toggle monitoring for the specified season that corresponds to the specified series ID toggle-season-monitoring Toggle monitoring for the specified season that corresponds to the specified series ID
toggle-series-monitoring Toggle monitoring for the specified series corresponding to the given series ID
help Print this message or the help of the given subcommand(s) help Print this message or the help of the given subcommand(s)
Options: Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] -h, --help Print help
--config <CONFIG> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
-h, --help Print help Global Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used.
``` ```
**Pro Tip:** The CLI is even more powerful and useful when used in conjunction with the `jq` CLI tool. This allows you to parse the JSON response from the Managarr CLI and use it in your scripts; For example, to extract the `movieId` of the movie "Ad Astra", you would run: **Pro Tip:** The CLI is even more powerful and useful when used in conjunction with the `jq` CLI tool. This allows you to parse the JSON response from the Managarr CLI and use it in your scripts; For example, to extract the `movieId` of the movie "Ad Astra", you would run:
@@ -428,9 +456,6 @@ Managarr supports using environment variables on startup so you don't have to al
| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | | `MANAGARR_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` | | `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` |
## Track What I'm Currently Working On
To see what feature(s) I'm currently working on, check out my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr).
## Screenshots ## Screenshots
### Radarr ### Radarr
@@ -446,6 +471,13 @@ To see what feature(s) I'm currently working on, check out my [Wekan Board](http
![season_details](screenshots/sonarr/season_details.png) ![season_details](screenshots/sonarr/season_details.png)
![manual_episode_search](screenshots/sonarr/manual_episode_search.png) ![manual_episode_search](screenshots/sonarr/manual_episode_search.png)
### Lidarr
![lidarr_library](screenshots/lidarr/lidarr_library.png)
![artist_details](screenshots/lidarr/artist_details.png)
![album_details](screenshots/lidarr/album_details.png)
![artist_discography_search](screenshots/lidarr/artist_discography_search.png)
![manual_album_search](screenshots/lidarr/manual_album_search.png)
### General ### General
![logs](screenshots/radarr/logs.png) ![logs](screenshots/radarr/logs.png)
![indexers](screenshots/radarr/indexers.png) ![indexers](screenshots/radarr/indexers.png)
@@ -461,8 +493,8 @@ To see what feature(s) I'm currently working on, check out my [Wekan Board](http
## Servarr Requirements ## Servarr Requirements
* [Radarr >= 5.3.6.8612](https://radarr.video/docs/api/) * [Radarr >= 5.3.6.8612](https://radarr.video/docs/api/)
* [Sonarr >= v4](https://sonarr.tv/docs/api/) * [Sonarr >= v4](https://sonarr.tv/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Lidarr v1](https://lidarr.audio/docs/api/) * [Lidarr v1](https://lidarr.audio/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Whisparr >= v3](https://whisparr.com/docs/api/) * [Whisparr >= v3](https://whisparr.com/docs/api/)
* [Prowlarr v1](https://prowlarr.com/docs/api/) * [Prowlarr v1](https://prowlarr.com/docs/api/)
* [Bazarr v1.1.4](http://localhost:6767/api) * [Bazarr v1.1.4](http://localhost:6767/api)
+1 -3
View File
@@ -1,7 +1,5 @@
VERSION := "latest" VERSION := "latest"
IMG_NAME := "darkalex17/managarr" IMG_NAME := "darkalex17/managarr"
IMAGE := "{{IMG_NAME}}:{{VERSION}}"
# List all recipes # List all recipes
default: default:
@@ -88,4 +86,4 @@ build build_type='debug':
# Build the docker image # Build the docker image
[group: 'build'] [group: 'build']
build-docker: build-docker:
@DOCKER_BUILDKIT=1 docker build --rm -t {{IMAGE}} @DOCKER_BUILDKIT=1 docker build --rm -t {{IMG_NAME}}:{{VERSION}} .
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

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