Compare commits

..

21 Commits

Author SHA1 Message Date
Dark-Alex-17 6df68b8a66 tests: Addressed additional CR comments and added tests for tail-logs 2026-06-25 14:21:57 -06:00
Dark-Alex-17 cbca6bd916 fix: addressed code review comments 2026-06-25 14:11:18 -06:00
Dark-Alex-17 92187c5f16 fix: tail-logs subcommand follows log rollovers and sleeps to minimize idle CPU loops 2026-06-25 13:51:32 -06:00
Dark-Alex-17 366809d8c6 fmt: applied formatting 2026-06-25 13:32:31 -06:00
Dark-Alex-17 dd93fe117d feat: Implemented log rolling so the log file doesn't just grow exponentially [#60] 2026-06-25 13:29:56 -06:00
Dark-Alex-17 10e18af1bf build: upgraded to openssl 0.10.79 to fix security vulnerabilities 2026-06-25 13:20:14 -06:00
Dark-Alex-17 a4d93692a9 build: Upgraded to Rust v1.95.0 2026-06-25 13:19:27 -06:00
Dark-Alex-17 03d134bec9 refactor: Refactored several usages of sort_by_key and match guards to utilize newer Rust version APIs 2026-06-25 13:18:43 -06:00
Dark-Alex-17 4cad9e1755 docs: Improved the README to demonstrate what a truly minimal configuration looks like for each service and explain the "reasonable defaults" [#61] 2026-06-25 12:35:51 -06:00
github-actions[bot] adb76bb603 bump: version 0.7.1 → 0.7.2 [skip ci] 2026-04-20 22:37:15 +00:00
Alex Clarke d9a2b1c6c4 Merge pull request #59 from Dark-Alex-17/develop
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 1m12s
Check / stable / clippy (push) Failing after 1m11s
Check / nightly / doc (push) Successful in 1m3s
Check / 1.89.0 / check (push) Successful in 1m6s
Test Suite / ubuntu / beta (push) Successful in 1m46s
Test Suite / ubuntu / stable (push) Successful in 1m47s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m19s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
merge develop into main for release
2026-04-20 16:18:55 -06:00
Dark-Alex-17 47a2fbf0cc docs: removed the lines-of-code counter from the README since the link is dead 2026-04-20 16:02:18 -06:00
Dark-Alex-17 7067902752 build: updated reqwest and build dependencies 2026-04-20 15:53:42 -06:00
Dark-Alex-17 ba1cf0182b build: Updated the docker image so that it ships with trusted CA root certs from trusted providers like LetsEncrypt, DigiCert, etc. for docker SSL users 2026-04-02 09:58:30 -06:00
Dark-Alex-17 5cccec88c9 docs: Updated the README to have a more detailed section on how to acquire SSL certificate information 2026-03-30 10:13:51 -06:00
Dark-Alex-17 bbcd3f00a9 feat: Created a separate 'ssl' property for the config so users don't have to specify an ssl_cert_path to use SSL or use the uri workaround for HTTPS API access 2026-03-29 12:39:26 -06:00
Dark-Alex-17 2e339dd73b docs: Created an authorship policy and PR template that requires explicit acknowledgement of AI assistance 2026-02-24 17:41:34 -07:00
Dark-Alex-17 f988cf0f26 docs: Fixed some typos found in the README
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 11m0s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 1m2s
Check / 1.89.0 / check (push) Successful in 1m9s
Test Suite / ubuntu / beta (push) Successful in 1m47s
Test Suite / ubuntu / stable (push) Successful in 1m43s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m52s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-05 18:50:42 -07:00
Dark-Alex-17 ff82dc2012 style: Upgraded rustfmt edition to 2024
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 11m0s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 57s
Check / 1.89.0 / check (push) Successful in 1m1s
Test Suite / ubuntu / beta (push) Successful in 1m43s
Test Suite / ubuntu / stable (push) Successful in 1m42s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m52s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-05 10:47:35 -07:00
github-actions[bot] 89a692ad90 chore: bump Cargo.toml to 0.7.1
Check / stable / fmt (push) Successful in 9m55s
Check / beta / clippy (push) Successful in 10m59s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 59s
Check / 1.89.0 / check (push) Successful in 1m2s
Test Suite / ubuntu / beta (push) Successful in 1m42s
Test Suite / ubuntu / stable (push) Successful in 1m42s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m51s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-04 18:01:02 +00:00
github-actions[bot] d77ec5fb34 bump: version 0.7.0 → 0.7.1 [skip ci] 2026-02-04 18:01:00 +00:00
65 changed files with 1572 additions and 880 deletions
@@ -0,0 +1,11 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented below
+4 -4
View File
@@ -76,15 +76,15 @@ jobs:
RUSTDOCFLAGS: --cfg docsrs RUSTDOCFLAGS: --cfg docsrs
msrv: msrv:
# check that we can build using the minimal rust version that is specified by this crate # check that we can build using the minimal rust version that is specified by this crate
name: 1.89.0 / check name: 1.95.0 / check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install 1.89.0 - name: Install 1.95.0
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: 1.89.0 toolchain: 1.95.0
- name: cargo +1.89.0 check - name: cargo +1.95.0 check
run: cargo check run: cargo check
+29
View File
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v0.7.2 (2026-04-20)
### Feat
- Created a separate 'ssl' property for the config so users don't have to specify an ssl_cert_path to use SSL or use the uri workaround for HTTPS API access
## v0.7.1 (2026-02-04)
### Feat
- Added support for a system-wide notification popup mechanism that works across Servarrs
- Implemented a 'config-path' command to print out the default Managarr configuration file path to help address #54
- Full support for filtering disks and aggregating root folders in the UI's 'Stats' block
- proper collapsing of root folder paths in the stats layer of the UI
- Added config option to filter for specific disk space paths to display in the UI (CLI is unaffected)
- Improved disk-space UI and CLI that shows the actual path being monitored instead of just a disk number
- Implemented the forgotten lidarr list disk-space command
### Fix
- Improved the system notification feature so it can persist between modals
- Sonarr API updated to somtimes allow either seeders or leechers to be null
- Improved the first-time run behavior so that it outputs the default configuration file it tries to load to help users locate the file on first-runs
- 'managarr config-path' should work without a pre-existing config already in place [#54]
### Refactor
- Removed the filtering of monitored_storage_paths from the networking module and migrated all of it to the UI
## v0.7.0 (2026-01-21) ## v0.7.0 (2026-01-21)
### Feat ### Feat
+7
View File
@@ -91,5 +91,12 @@ Then, you can run workflows locally without having to commit and see if the GitH
act -W .github/workflows/release.yml --input_type bump=minor act -W .github/workflows/release.yml --input_type bump=minor
``` ```
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
Submissions must certify that the contributor understands and can maintain the code they submit.
## Questions? Reach out to me! ## Questions? Reach out to me!
If you encounter any questions while developing Managarr, please don't hesitate to reach out to me at alex.j.tusa@gmail.com. I'm happy to help contributors, new and experienced in any way I can! If you encounter any questions while developing Managarr, please don't hesitate to reach out to me at alex.j.tusa@gmail.com. I'm happy to help contributors, new and experienced in any way I can!
Generated
+898 -584
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "managarr" name = "managarr"
version = "0.7.0" version = "0.7.2"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs" description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"] keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
@@ -10,7 +10,7 @@ homepage = "https://github.com/Dark-Alex-17/managarr"
readme = "README.md" readme = "README.md"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
rust-version = "1.89.0" rust-version = "1.95.0"
exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"] exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"]
[workspace] [workspace]
@@ -32,9 +32,9 @@ derivative = "2.2.0"
human-panic = "2.0.6" human-panic = "2.0.6"
indoc = "2.0.7" indoc = "2.0.7"
log = "0.4.29" log = "0.4.29"
log4rs = { version = "1.4.0", features = ["file_appender"] } log4rs = { version = "1.4.0", features = ["rolling_file_appender", "compound_policy", "size_trigger", "fixed_window_roller"] }
regex = "1.12.2" regex = "1.12.2"
reqwest = { version = "0.12.28", features = ["json"] } reqwest = { version = "0.13.2", features = ["json"] }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
serde_json = "1.0.149" serde_json = "1.0.149"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
@@ -63,7 +63,7 @@ managarr-tree-widget = "0.25.0"
indicatif = "0.17.11" indicatif = "0.17.11"
derive_setters = "0.1.9" derive_setters = "0.1.9"
deunicode = "1.6.2" deunicode = "1.6.2"
openssl = { version = "0.10.75", features = ["vendored"] } openssl = { version = "0.10.79", features = ["vendored"] }
veil = "0.2.0" veil = "0.2.0"
validate_theme_derive = "0.1.0" validate_theme_derive = "0.1.0"
enum_display_style_derive = "0.1.0" enum_display_style_derive = "0.1.0"
+2
View File
@@ -21,6 +21,8 @@ RUN mv target/release/managarr .
FROM debian:stable-slim FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy the compiled binary from the builder container # Copy the compiled binary from the builder container
COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin
+97 -5
View File
@@ -3,7 +3,6 @@
![Check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg) ![Check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg)
![Test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg) ![Test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg)
![License](https://img.shields.io/badge/license-MIT-blueviolet.svg) ![License](https://img.shields.io/badge/license-MIT-blueviolet.svg)
![LOC](https://tokei.rs/b1/github/Dark-Alex-17/managarr?category=code)
[![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr) [![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr)
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff) ![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff)
[![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr) [![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr)
@@ -55,9 +54,9 @@ Run Managarr as a docker container by mounting your `config.yml` file to `/root/
docker run --rm -it -v /home/aclarke/.config/managarr/config.yml:/root/.config/managarr/config.yml darkalex17/managarr:latest docker run --rm -it -v /home/aclarke/.config/managarr/config.yml:/root/.config/managarr/config.yml darkalex17/managarr:latest
``` ```
You can also clone this repo and run `make docker` to build a docker image locally and run it using the above command. You can also clone this repo and run `just build-docker` to build a docker image locally and run it using the above command.
Please note that you will need to create and popular your configuration file first before starting the container. Otherwise, the container will fail to start. Please note that you will need to create and populate your configuration file first before starting the container. Otherwise, the container will fail to start.
**Note:** If you run into errors using relative file paths when mounting the volume with the configuration file, try using an absolute path. **Note:** If you run into errors using relative file paths when mounting the volume with the configuration file, try using an absolute path.
@@ -339,7 +338,38 @@ $ managarr radarr list movies | jq '.[] | select(.title == "Ad Astra") | .id'
# Configuration # Configuration
Managarr assumes reasonable defaults to connect to each service (i.e. Radarr is on localhost:7878), Managarr assumes reasonable defaults to connect to each service (i.e. Radarr is on localhost:7878),
but all servers will require you to input the API token. but all servers will require you to input the API token. This means that for each Servarr you configure,
if you define only the `api_token`, Managarr will assume the Servarr is running on `localhost` and on the
default port for that respective service. That is:
| Servarr | Default Host | Default Port |
|---------|--------------|--------------|
| Radarr | `localhost` | 7878 |
| Sonarr | `localhost` | 8989 |
| Lidarr | `localhost` | 8686 |
> [!TIP]
> In general, all Servarrs store their API tokens under Settings -> General -> Security -> API Key in their web UIs.
## Minimum Configuration Requirements
The following configuration file will connect to each Servarr running on localhost with their default ports. The only
requirement for each is the specification of an API token.
```yaml
radarr:
# Connect to Radarr running on localhost:7878
- api_token: <your-radarr-api-token-here>
sonarr:
# Connect to sonarr running on localhost:8989
- api_token: <your-sonarr-api-token-here>
lidarr:
# Connect to lidarr running on localhost:8686
- api_token: <your-lidarr-api-token-here>
```
## Configuration File Location
The configuration file is located somewhere different for each OS, so run the following command to print out the default The configuration file is located somewhere different for each OS, so run the following command to print out the default
location of the `managarr` configuration file for your system: location of the `managarr` configuration file for your system:
@@ -364,7 +394,16 @@ radarr:
- host: 192.168.0.78 - host: 192.168.0.78
port: 7878 port: 7878
api_token: someApiToken1234567890 api_token: someApiToken1234567890
ssl_cert_path: /path/to/radarr.crt # Required to enable SSL ssl_cert_path: /path/to/radarr.crt # Use the specified SSL certificate to connect to this Servarr
# Enables SSL regardless of the value of the 'ssl'
# See the SSL Configuration section below for more information
- host: 192.168.0.79
port: 7878
api_token: someApiToken1234567890
ssl: true # Use SSL to connect to this Servarr
# This will assume that you have the SSL certificate installed to your system trust store
# See the SSL Configuration section below for more information
- uri: http://htpc.local/radarr # Example of using the 'uri' key instead of 'host' and 'port' - uri: http://htpc.local/radarr # Example of using the 'uri' key instead of 'host' and 'port'
api_token: someApiToken1234567890 api_token: someApiToken1234567890
@@ -400,6 +439,59 @@ lidarr:
SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE} SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE}
``` ```
### SSL Configuration
If your Servarr is using SSL or self-signed certificates, you may need to specify additional configuration options to connect without issues.
**If your Servarr's domain CA is installed in the system's trust store:**
Then you can simply specify `ssl: true` and Managarr will be able to connect to your Servarr:
```yaml
radarr:
- host: 192.168.0.78
port: 7878
api_token: yourApiTokenHere
ssl: true
```
**If your Servarr's domain CA is not installed:**
You'll either need to specify the path to the certificate via the `ssl_cert_path` property, or you'll need to install the certificate into your system store.
To acquire the cert for your Servarr's domain, you can use the following command:
```shell
openssl s_client -show-certs -connect <your-servarr-domain.com>:<port> </dev/null |\
sed -n -e '/-.BEGIN/,/-.END/ p' > /path/to/your/servarr.pem
```
Now, you can either specify `ssl_cert_path: /path/to/your/servarr.pem`:
Example configuration with a certificate that's not installed to the system trust store:
```yaml
radarr:
- host: 192.168.0.78
port: 7878
api_token: yourApiTokenHere
ssl_cert_path: /path/to/your/certificate.crt
```
Or install the certificate into your system's trust store.
For example, if you're on a Debian-based system and have `ca-certificates` installed (`sudo apt install ca-certificates`):
```shell
sudo mv /path/to/your/servarr.pem /usr/local/share/ca-certificates/servarr.pem
sudo update-ca-certificates
```
Example configuration with a certificate that is installed to the system trust store:
```yaml
radarr:
- host: 192.168.0.78
port: 7878
api_token: yourApiTokenHere
ssl: true
```
### Example Multi-Instance Configuration: ### Example Multi-Instance Configuration:
```yaml ```yaml
theme: default theme: default
+2 -2
View File
@@ -85,5 +85,5 @@ build build_type='debug':
# Build the docker image # Build the docker image
[group: 'build'] [group: 'build']
build-docker: build-docker version=VERSION:
@DOCKER_BUILDKIT=1 docker build --rm -t {{IMG_NAME}}:{{VERSION}} . @DOCKER_BUILDKIT=1 docker build --rm -t {{IMG_NAME}}:{{version}} .
+1 -1
View File
@@ -1,5 +1,5 @@
tab_spaces=2 tab_spaces=2
edition = "2021" edition = "2024"
reorder_imports = true reorder_imports = true
imports_granularity = "Crate" imports_granularity = "Crate"
group_imports = "StdExternalCrate" group_imports = "StdExternalCrate"
+74 -1
View File
@@ -447,6 +447,78 @@ mod tests {
assert_none!(config.port); assert_none!(config.port);
} }
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_is_bool() {
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: true
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &true);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_is_string() {
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: "true"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &true);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_BOOL", "true") };
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: ${TEST_VAR_DESERIALIZE_OPTION_BOOL}
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &true);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_BOOL") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_defaults_to_false() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_BOOL_FALSEY", "test") };
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: ${TEST_VAR_DESERIALIZE_OPTION_BOOL_FALSEY}
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &false);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_BOOL_FALSEY") };
}
#[test]
fn test_deserialize_optional_env_var_bool_empty() {
let yaml_data = r#"
host: localhost
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.ssl);
}
#[test] #[test]
#[serial] #[serial]
fn test_deserialize_optional_env_var_header_map_is_present() { fn test_deserialize_optional_env_var_header_map_is_present() {
@@ -674,7 +746,7 @@ mod tests {
let mut custom_headers = HeaderMap::new(); let mut custom_headers = HeaderMap::new();
custom_headers.insert("X-Custom-Header", "value".parse().unwrap()); custom_headers.insert("X-Custom-Header", "value".parse().unwrap());
let expected_str = format!( let expected_str = format!(
"ServarrConfig {{ name: Some(\"{name}\"), host: Some(\"{host}\"), port: Some({port}), uri: Some(\"{uri}\"), weight: Some({weight}), api_token: Some(\"***********\"), api_token_file: Some(\"{api_token_file}\"), ssl_cert_path: Some(\"{ssl_cert_path}\"), custom_headers: Some({{\"x-custom-header\": \"value\"}}), monitored_storage_paths: Some([\"/path1\", \"/path2\"]) }}" "ServarrConfig {{ name: Some(\"{name}\"), host: Some(\"{host}\"), port: Some({port}), uri: Some(\"{uri}\"), weight: Some({weight}), api_token: Some(\"***********\"), api_token_file: Some(\"{api_token_file}\"), ssl: Some(true), ssl_cert_path: Some(\"{ssl_cert_path}\"), custom_headers: Some({{\"x-custom-header\": \"value\"}}), monitored_storage_paths: Some([\"/path1\", \"/path2\"]) }}"
); );
let servarr_config = ServarrConfig { let servarr_config = ServarrConfig {
name: Some(name), name: Some(name),
@@ -685,6 +757,7 @@ mod tests {
api_token: Some(api_token), api_token: Some(api_token),
api_token_file: Some(api_token_file), api_token_file: Some(api_token_file),
ssl_cert_path: Some(ssl_cert_path), ssl_cert_path: Some(ssl_cert_path),
ssl: Some(true),
custom_headers: Some(custom_headers), custom_headers: Some(custom_headers),
monitored_storage_paths: Some(monitored_storage), monitored_storage_paths: Some(monitored_storage),
}; };
+26
View File
@@ -431,6 +431,8 @@ pub struct ServarrConfig {
pub api_token: Option<String>, pub api_token: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")] #[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub api_token_file: Option<String>, pub api_token_file: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var_bool")]
pub ssl: Option<bool>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")] #[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub ssl_cert_path: Option<String>, pub ssl_cert_path: Option<String>,
#[serde( #[serde(
@@ -486,6 +488,7 @@ impl Default for ServarrConfig {
api_token: Some(String::new()), api_token: Some(String::new()),
api_token_file: None, api_token_file: None,
ssl_cert_path: None, ssl_cert_path: None,
ssl: None,
custom_headers: None, custom_headers: None,
monitored_storage_paths: None, monitored_storage_paths: None,
} }
@@ -532,6 +535,29 @@ where
} }
} }
fn deserialize_optional_env_var_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrBool {
Bool(bool),
String(String),
}
match StringOrBool::deserialize(deserializer)? {
StringOrBool::Bool(b) => Ok(Some(b)),
StringOrBool::String(s) => {
let val = interpolate_env_vars(&s)
.to_lowercase()
.parse()
.unwrap_or(false);
Ok(Some(val))
}
}
}
fn deserialize_optional_env_var_header_map<'de, D>( fn deserialize_optional_env_var_header_map<'de, D>(
deserializer: D, deserializer: D,
) -> Result<Option<HeaderMap>, D::Error> ) -> Result<Option<HeaderMap>, D::Error>
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{ArgAction, Subcommand, arg}; use clap::{ArgAction, Subcommand};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::LidarrCommand; use super::LidarrCommand;
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{Subcommand, arg}; use clap::Subcommand;
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
+1 -1
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler}; use add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use anyhow::Result; use anyhow::Result;
use clap::{Subcommand, arg}; use clap::Subcommand;
use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler};
use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler}; use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler};
use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}; use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler};
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{Subcommand, command}; use clap::Subcommand;
use clap_complete::Shell; use clap_complete::Shell;
use indoc::indoc; use indoc::indoc;
use lidarr::{LidarrCliHandler, LidarrCommand}; use lidarr::{LidarrCliHandler, LidarrCommand};
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{ArgAction, Subcommand, arg, command}; use clap::{ArgAction, Subcommand};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::RadarrCommand; use super::RadarrCommand;
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{Subcommand, command}; use clap::Subcommand;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{ use crate::{
+1 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{Subcommand, command}; use clap::Subcommand;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{ use crate::{
+4 -4
View File
@@ -231,8 +231,8 @@ mod tests {
let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES) let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter() .iter()
.map(|(key, desc)| { .map(|(key, desc)| {
let (key, alt_key) = if key.alt.is_some() { let (key, alt_key) = if let Some(key1) = key.alt {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string()) (key.key.to_string(), key1.to_string())
} else { } else {
(key.key.to_string(), String::new()) (key.key.to_string(), String::new())
}; };
@@ -338,8 +338,8 @@ mod tests {
} }
fn context_clue_to_keybinding_item(key: &KeyBinding, desc: &&str) -> KeybindingItem { fn context_clue_to_keybinding_item(key: &KeyBinding, desc: &&str) -> KeybindingItem {
let (key, alt_key) = if key.alt.is_some() { let (key, alt_key) = if let Some(key1) = key.alt {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string()) (key.key.to_string(), key1.to_string())
} else { } else {
(key.key.to_string(), String::new()) (key.key.to_string(), String::new())
}; };
@@ -506,18 +506,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditIndexerHandler<'
.tags .tags
); );
} }
ActiveLidarrBlock::EditIndexerPrompt => { ActiveLidarrBlock::EditIndexerPrompt
if self.app.data.lidarr_data.selected_block.get_active_block() if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::EditIndexerConfirmPrompt == ActiveLidarrBlock::EditIndexerConfirmPrompt
&& matches_key!(confirm, self.key) && matches_key!(confirm, self.key) =>
{ {
self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditIndexer(self.build_edit_indexer_params())); Some(LidarrEvent::EditIndexer(self.build_edit_indexer_params()));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -106,10 +106,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for IndexerSettingsHandl
indexer_settings.maximum_size -= 1; indexer_settings.maximum_size -= 1;
} }
} }
ActiveLidarrBlock::IndexerSettingsRssSyncIntervalInput => { ActiveLidarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 { if indexer_settings.rss_sync_interval > 0 =>
indexer_settings.rss_sync_interval -= 1; {
} indexer_settings.rss_sync_interval -= 1;
} }
_ => (), _ => (),
} }
@@ -591,16 +591,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddArtistHandler<'a,
.tags .tags
) )
} }
ActiveLidarrBlock::AddArtistPrompt => { ActiveLidarrBlock::AddArtistPrompt
if self.app.data.lidarr_data.selected_block.get_active_block() if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::AddArtistConfirmPrompt == ActiveLidarrBlock::AddArtistConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::AddArtist(self.build_add_artist_body())); Some(LidarrEvent::AddArtist(self.build_add_artist_body()));
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -293,16 +293,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler
self.app.data.lidarr_data.selected_block = self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS);
} }
_ if matches_key!(toggle_monitoring, key) => { _ if matches_key!(toggle_monitoring, key)
if !self.app.data.lidarr_data.albums.is_empty() { && !self.app.data.lidarr_data.albums.is_empty() =>
self.app.data.lidarr_data.prompt_confirm = true; {
self.app.data.lidarr_data.prompt_confirm_action = self.app.data.lidarr_data.prompt_confirm = true;
Some(LidarrEvent::ToggleAlbumMonitoring(self.extract_album_id())); self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::ToggleAlbumMonitoring(self.extract_album_id()));
self self
.app .app
.pop_and_push_navigation_stack(self.active_lidarr_block.into()); .pop_and_push_navigation_stack(self.active_lidarr_block.into());
}
} }
_ => (), _ => (),
}, },
@@ -428,18 +428,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditArtistHandler<'a
.tags .tags
) )
} }
ActiveLidarrBlock::EditArtistPrompt => { ActiveLidarrBlock::EditArtistPrompt
if self.app.data.lidarr_data.selected_block.get_active_block() if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::EditArtistConfirmPrompt == ActiveLidarrBlock::EditArtistConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditArtist(self.build_edit_artist_params())); Some(LidarrEvent::EditArtist(self.build_edit_artist_params()));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -505,19 +505,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddRootFolderHandler
.tags .tags
) )
} }
ActiveLidarrBlock::AddRootFolderPrompt => { ActiveLidarrBlock::AddRootFolderPrompt
if self.app.data.lidarr_data.selected_block.get_active_block() if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::AddRootFolderConfirmPrompt == ActiveLidarrBlock::AddRootFolderConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::AddRootFolder( self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::AddRootFolder(
self.build_add_root_folder_body(), self.build_add_root_folder_body(),
)); ));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
+2 -2
View File
@@ -139,8 +139,8 @@ pub fn handle_events(key: Key, app: &mut App<'_>) {
pub fn populate_keymapping_table(app: &mut App<'_>) { pub fn populate_keymapping_table(app: &mut App<'_>) {
let context_clue_to_keybinding_item = |key: &KeyBinding, desc: &&str| { let context_clue_to_keybinding_item = |key: &KeyBinding, desc: &&str| {
let (key, alt_key) = if key.alt.is_some() { let (key, alt_key) = if let Some(key1) = key.alt {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string()) (key.key.to_string(), key1.to_string())
} else { } else {
(key.key.to_string(), String::new()) (key.key.to_string(), String::new())
}; };
@@ -354,19 +354,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
.path .path
) )
} }
ActiveRadarrBlock::EditCollectionPrompt => { ActiveRadarrBlock::EditCollectionPrompt
if self.app.data.radarr_data.selected_block.get_active_block() if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::EditCollectionConfirmPrompt == ActiveRadarrBlock::EditCollectionConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection( self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(
self.build_edit_collection_params(), self.build_edit_collection_params(),
)); ));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -507,18 +507,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<'
.tags .tags
); );
} }
ActiveRadarrBlock::EditIndexerPrompt => { ActiveRadarrBlock::EditIndexerPrompt
if self.app.data.radarr_data.selected_block.get_active_block() if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::EditIndexerConfirmPrompt == ActiveRadarrBlock::EditIndexerConfirmPrompt
&& matches_key!(confirm, self.key) && matches_key!(confirm, self.key) =>
{ {
self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditIndexer(self.build_edit_indexer_params())); Some(RadarrEvent::EditIndexer(self.build_edit_indexer_params()));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -114,10 +114,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput => { ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput => {
indexer_settings.availability_delay -= 1; indexer_settings.availability_delay -= 1;
} }
ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput => { ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 { if indexer_settings.rss_sync_interval > 0 =>
indexer_settings.rss_sync_interval -= 1; {
} indexer_settings.rss_sync_interval -= 1;
} }
_ => (), _ => (),
} }
@@ -272,19 +272,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
.whitelisted_hardcoded_subs .whitelisted_hardcoded_subs
) )
} }
ActiveRadarrBlock::AllIndexerSettingsPrompt => { ActiveRadarrBlock::AllIndexerSettingsPrompt
if self.app.data.radarr_data.selected_block.get_active_block() if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::IndexerSettingsConfirmPrompt == ActiveRadarrBlock::IndexerSettingsConfirmPrompt
&& matches_key!(confirm, self.key) && matches_key!(confirm, self.key) =>
{ {
self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some( self.app.data.radarr_data.prompt_confirm_action = Some(
RadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_body()), RadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_body()),
); );
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -539,16 +539,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a,
.tags .tags
) )
} }
ActiveRadarrBlock::AddMoviePrompt => { ActiveRadarrBlock::AddMoviePrompt
if self.app.data.radarr_data.selected_block.get_active_block() if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::AddMovieConfirmPrompt == ActiveRadarrBlock::AddMovieConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::AddMovie(self.build_add_movie_body())); Some(RadarrEvent::AddMovie(self.build_add_movie_body()));
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -376,18 +376,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a,
.tags .tags
) )
} }
ActiveRadarrBlock::EditMoviePrompt => { ActiveRadarrBlock::EditMoviePrompt
if self.app.data.radarr_data.selected_block.get_active_block() if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::EditMovieConfirmPrompt == ActiveRadarrBlock::EditMovieConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.radarr_data.prompt_confirm = true; self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditMovie(self.build_edit_movie_params())); Some(RadarrEvent::EditMovie(self.build_edit_movie_params()));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -506,18 +506,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditIndexerHandler<'
.tags .tags
); );
} }
ActiveSonarrBlock::EditIndexerPrompt => { ActiveSonarrBlock::EditIndexerPrompt
if self.app.data.sonarr_data.selected_block.get_active_block() if self.app.data.sonarr_data.selected_block.get_active_block()
== ActiveSonarrBlock::EditIndexerConfirmPrompt == ActiveSonarrBlock::EditIndexerConfirmPrompt
&& matches_key!(confirm, self.key) && matches_key!(confirm, self.key) =>
{ {
self.app.data.sonarr_data.prompt_confirm = true; self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action = self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::EditIndexer(self.build_edit_indexer_params())); Some(SonarrEvent::EditIndexer(self.build_edit_indexer_params()));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -106,10 +106,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexerSettingsHandl
indexer_settings.maximum_size -= 1; indexer_settings.maximum_size -= 1;
} }
} }
ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => { ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 { if indexer_settings.rss_sync_interval > 0 =>
indexer_settings.rss_sync_interval -= 1; {
} indexer_settings.rss_sync_interval -= 1;
} }
_ => (), _ => (),
} }
@@ -606,16 +606,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a,
.tags .tags
) )
} }
ActiveSonarrBlock::AddSeriesPrompt => { ActiveSonarrBlock::AddSeriesPrompt
if self.app.data.sonarr_data.selected_block.get_active_block() if self.app.data.sonarr_data.selected_block.get_active_block()
== ActiveSonarrBlock::AddSeriesConfirmPrompt == ActiveSonarrBlock::AddSeriesConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.sonarr_data.prompt_confirm = true; self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action = self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::AddSeries(self.build_add_series_body())); Some(SonarrEvent::AddSeries(self.build_add_series_body()));
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
@@ -450,18 +450,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a
.tags .tags
) )
} }
ActiveSonarrBlock::EditSeriesPrompt => { ActiveSonarrBlock::EditSeriesPrompt
if self.app.data.sonarr_data.selected_block.get_active_block() if self.app.data.sonarr_data.selected_block.get_active_block()
== ActiveSonarrBlock::EditSeriesConfirmPrompt == ActiveSonarrBlock::EditSeriesConfirmPrompt
&& matches_key!(confirm, key) && matches_key!(confirm, key) =>
{ {
self.app.data.sonarr_data.prompt_confirm = true; self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action = self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::EditSeries(self.build_edit_series_params())); Some(SonarrEvent::EditSeries(self.build_edit_series_params()));
self.app.should_refresh = true; self.app.should_refresh = true;
self.app.pop_navigation_stack(); self.app.pop_navigation_stack();
}
} }
_ => (), _ => (),
} }
+1 -1
View File
@@ -893,7 +893,7 @@ mod tests {
app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into()); app.push_navigation_stack(ActiveRadarrBlock::MoviesSortPrompt.into());
let mut expected_vec = movies_vec(); let mut expected_vec = movies_vec();
expected_vec.sort_by(|a, b| a.id.cmp(&b.id)); expected_vec.sort_by_key(|a| a.id);
expected_vec.reverse(); expected_vec.reverse();
TableHandlerUnit::new( TableHandlerUnit::new(
+1 -1
View File
@@ -82,7 +82,7 @@ impl Network<'_, '_> {
Route::Lidarr(ActiveLidarrBlock::BlocklistSortPrompt, _) Route::Lidarr(ActiveLidarrBlock::BlocklistSortPrompt, _)
) { ) {
let mut blocklist_vec: Vec<BlocklistItem> = blocklist_resp.records; let mut blocklist_vec: Vec<BlocklistItem> = blocklist_resp.records;
blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); blocklist_vec.sort_by_key(|a| a.id);
app.data.lidarr_data.blocklist.set_items(blocklist_vec); app.data.lidarr_data.blocklist.set_items(blocklist_vec);
app.data.lidarr_data.blocklist.apply_sorting_toggle(false); app.data.lidarr_data.blocklist.apply_sorting_toggle(false);
} }
+1 -1
View File
@@ -31,7 +31,7 @@ impl Network<'_, '_> {
Route::Lidarr(ActiveLidarrBlock::HistorySortPrompt, _) Route::Lidarr(ActiveLidarrBlock::HistorySortPrompt, _)
) { ) {
let mut history_vec = history_response.records; let mut history_vec = history_response.records;
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
app.data.lidarr_data.history.set_items(history_vec); app.data.lidarr_data.history.set_items(history_vec);
app.data.lidarr_data.history.apply_sorting_toggle(false); app.data.lidarr_data.history.apply_sorting_toggle(false);
} }
@@ -33,7 +33,7 @@ impl Network<'_, '_> {
self self
.handle_request::<(), Vec<Album>>(request_props, |mut albums_vec, mut app| { .handle_request::<(), Vec<Album>>(request_props, |mut albums_vec, mut app| {
albums_vec.sort_by(|a, b| a.id.cmp(&b.id)); albums_vec.sort_by_key(|a| a.id);
app.data.lidarr_data.albums.set_items(albums_vec); app.data.lidarr_data.albums.set_items(albums_vec);
}) })
.await .await
@@ -89,7 +89,7 @@ impl Network<'_, '_> {
.get_or_insert_default(); .get_or_insert_default();
let mut history_vec = history_items; let mut history_vec = history_items;
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
album_details_modal.album_history.set_items(history_vec); album_details_modal.album_history.set_items(history_vec);
album_details_modal album_details_modal
.album_history .album_history
@@ -64,7 +64,7 @@ impl Network<'_, '_> {
app.get_current_route(), app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::ArtistsSortPrompt, _) Route::Lidarr(ActiveLidarrBlock::ArtistsSortPrompt, _)
) { ) {
artists_vec.sort_by(|a, b| a.id.cmp(&b.id)); artists_vec.sort_by_key(|a| a.id);
app.data.lidarr_data.artists.set_items(artists_vec); app.data.lidarr_data.artists.set_items(artists_vec);
app.data.lidarr_data.artists.apply_sorting_toggle(false); app.data.lidarr_data.artists.apply_sorting_toggle(false);
} }
@@ -309,7 +309,7 @@ impl Network<'_, '_> {
let artist_history = &mut app.data.lidarr_data.artist_history; let artist_history = &mut app.data.lidarr_data.artist_history;
if !is_sorting { if !is_sorting {
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
artist_history.set_items(history_vec); artist_history.set_items(history_vec);
artist_history.apply_sorting_toggle(false); artist_history.apply_sorting_toggle(false);
} }
@@ -55,7 +55,7 @@ impl Network<'_, '_> {
self self
.handle_request::<(), Vec<Track>>(request_props, |mut track_vec, mut app| { .handle_request::<(), Vec<Track>>(request_props, |mut track_vec, mut app| {
track_vec.sort_by(|a, b| a.id.cmp(&b.id)); track_vec.sort_by_key(|a| a.id);
let album_details_modal = app let album_details_modal = app
.data .data
.lidarr_data .lidarr_data
@@ -238,7 +238,7 @@ impl Network<'_, '_> {
.into_iter() .into_iter()
.filter(|it| it.track_id == track_id) .filter(|it| it.track_id == track_id)
.collect(); .collect();
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
track_details_modal.track_history.set_items(history_vec); track_details_modal.track_history.set_items(history_vec);
track_details_modal track_details_modal
.track_history .track_history
+2 -5
View File
@@ -50,7 +50,7 @@ impl Network<'_, '_> {
let log_lines = logs let log_lines = logs
.into_iter() .into_iter()
.map(|log| { .map(|log| {
if log.exception.is_some() { if let Some(exception) = log.exception {
HorizontallyScrollableText::from(format!( HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}", "{}|{}|{}|{}|{}",
log.time, log.time,
@@ -63,10 +63,7 @@ impl Network<'_, '_> {
.exception_type .exception_type
.as_ref() .as_ref()
.expect("exception_type must exist when exception is present"), .expect("exception_type must exist when exception is present"),
log exception
.exception
.as_ref()
.expect("exception must exist in this branch")
)) ))
} else { } else {
HorizontallyScrollableText::from(format!( HorizontallyScrollableText::from(format!(
+2 -1
View File
@@ -229,6 +229,7 @@ impl<'a, 'b> Network<'a, 'b> {
uri, uri,
api_token, api_token,
ssl_cert_path, ssl_cert_path,
ssl,
custom_headers: custom_headers_option, custom_headers: custom_headers_option,
.. ..
} = app } = app
@@ -245,7 +246,7 @@ impl<'a, 'b> Network<'a, 'b> {
let mut uri = if let Some(servarr_uri) = uri { let mut uri = if let Some(servarr_uri) = uri {
format!("{servarr_uri}/api/{api_version}{resource}") format!("{servarr_uri}/api/{api_version}{resource}")
} else { } else {
let protocol = if ssl_cert_path.is_some() { let protocol = if ssl_cert_path.is_some() || ssl.unwrap_or(false) {
"https" "https"
} else { } else {
"http" "http"
+77 -1
View File
@@ -409,7 +409,7 @@ mod tests {
#[tokio::test] #[tokio::test]
#[should_panic(expected = "Servarr config is undefined")] #[should_panic(expected = "Servarr config is undefined")]
#[rstest] #[rstest]
async fn test_request_props_from_requires_radarr_config_to_be_present_for_all_network_events( async fn test_request_props_from_requires_config_to_be_present_for_all_network_events(
#[values(RadarrEvent::HealthCheck, SonarrEvent::HealthCheck)] network_event: impl Into<NetworkEvent> #[values(RadarrEvent::HealthCheck, SonarrEvent::HealthCheck)] network_event: impl Into<NetworkEvent>
+ NetworkResource, + NetworkResource,
) { ) {
@@ -492,6 +492,82 @@ mod tests {
assert!(request_props.custom_headers.is_empty()); assert!(request_props.custom_headers.is_empty());
} }
#[rstest]
#[tokio::test]
async fn test_request_props_from_custom_config_ssl_doesnt_affect_ssl_cert_path(
#[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into<NetworkEvent>
+ NetworkResource,
#[values(Some(true), Some(false), None)] ssl_option: Option<bool>,
) {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::test_default()));
let resource = network_event.resource();
let servarr_config = ServarrConfig {
host: Some("192.168.0.123".to_owned()),
port: Some(8080),
api_token: Some(api_token.clone()),
ssl_cert_path: Some("/test/cert.crt".to_owned()),
ssl: ssl_option,
..ServarrConfig::default()
};
{
let mut app = app_arc.lock().await;
app.server_tabs.tabs[0].config = Some(servarr_config.clone());
app.server_tabs.tabs[1].config = Some(servarr_config);
}
let network = test_network(&app_arc);
let request_props = network
.request_props_from(network_event, RequestMethod::Get, None::<()>, None, None)
.await;
assert_str_eq!(
request_props.uri,
format!("https://192.168.0.123:8080/api/v3{resource}")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
assert!(request_props.custom_headers.is_empty());
}
#[rstest]
#[tokio::test]
async fn test_request_props_uses_ssl_property(
#[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into<NetworkEvent>
+ NetworkResource,
) {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::test_default()));
let resource = network_event.resource();
let servarr_config = ServarrConfig {
host: Some("192.168.0.123".to_owned()),
port: Some(8080),
api_token: Some(api_token.clone()),
ssl: Some(true),
..ServarrConfig::default()
};
{
let mut app = app_arc.lock().await;
app.server_tabs.tabs[0].config = Some(servarr_config.clone());
app.server_tabs.tabs[1].config = Some(servarr_config);
}
let network = test_network(&app_arc);
let request_props = network
.request_props_from(network_event, RequestMethod::Get, None::<()>, None, None)
.await;
assert_str_eq!(
request_props.uri,
format!("https://192.168.0.123:8080/api/v3{resource}")
);
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
assert!(request_props.custom_headers.is_empty());
}
#[rstest] #[rstest]
#[tokio::test] #[tokio::test]
async fn test_request_props_from_custom_config_custom_headers( async fn test_request_props_from_custom_config_custom_headers(
+1 -1
View File
@@ -83,7 +83,7 @@ impl Network<'_, '_> {
Route::Radarr(ActiveRadarrBlock::BlocklistSortPrompt, _) Route::Radarr(ActiveRadarrBlock::BlocklistSortPrompt, _)
) { ) {
let mut blocklist_vec = blocklist_resp.records; let mut blocklist_vec = blocklist_resp.records;
blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); blocklist_vec.sort_by_key(|a| a.id);
app.data.radarr_data.blocklist.set_items(blocklist_vec); app.data.radarr_data.blocklist.set_items(blocklist_vec);
app.data.radarr_data.blocklist.apply_sorting_toggle(false); app.data.radarr_data.blocklist.apply_sorting_toggle(false);
} }
@@ -128,7 +128,7 @@ impl Network<'_, '_> {
app.get_current_route(), app.get_current_route(),
Route::Radarr(ActiveRadarrBlock::CollectionsSortPrompt, _) Route::Radarr(ActiveRadarrBlock::CollectionsSortPrompt, _)
) { ) {
collections_vec.sort_by(|a, b| a.id.cmp(&b.id)); collections_vec.sort_by_key(|a| a.id);
app.data.radarr_data.collections.set_items(collections_vec); app.data.radarr_data.collections.set_items(collections_vec);
app.data.radarr_data.collections.apply_sorting_toggle(false); app.data.radarr_data.collections.apply_sorting_toggle(false);
} }
+1 -1
View File
@@ -31,7 +31,7 @@ impl Network<'_, '_> {
Route::Radarr(ActiveRadarrBlock::HistorySortPrompt, _) Route::Radarr(ActiveRadarrBlock::HistorySortPrompt, _)
) { ) {
let mut history_vec = history_response.records; let mut history_vec = history_response.records;
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
app.data.radarr_data.history.set_items(history_vec); app.data.radarr_data.history.set_items(history_vec);
app.data.radarr_data.history.apply_sorting_toggle(false); app.data.radarr_data.history.apply_sorting_toggle(false);
} }
+1 -1
View File
@@ -270,7 +270,7 @@ impl Network<'_, '_> {
app.get_current_route(), app.get_current_route(),
Route::Radarr(ActiveRadarrBlock::MoviesSortPrompt, _) Route::Radarr(ActiveRadarrBlock::MoviesSortPrompt, _)
) { ) {
movie_vec.sort_by(|a, b| a.id.cmp(&b.id)); movie_vec.sort_by_key(|a| a.id);
app.data.radarr_data.movies.set_items(movie_vec); app.data.radarr_data.movies.set_items(movie_vec);
app.data.radarr_data.movies.apply_sorting_toggle(false); app.data.radarr_data.movies.apply_sorting_toggle(false);
} }
+2 -5
View File
@@ -67,7 +67,7 @@ impl Network<'_, '_> {
let log_lines = logs let log_lines = logs
.into_iter() .into_iter()
.map(|log| { .map(|log| {
if log.exception.is_some() { if let Some(exception) = log.exception {
HorizontallyScrollableText::from(format!( HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}", "{}|{}|{}|{}|{}",
log.time, log.time,
@@ -80,10 +80,7 @@ impl Network<'_, '_> {
.exception_type .exception_type
.as_ref() .as_ref()
.expect("exception_type must exist when exception is present"), .expect("exception_type must exist when exception is present"),
log exception
.exception
.as_ref()
.expect("exception must exist in this branch")
)) ))
} else { } else {
HorizontallyScrollableText::from(format!( HorizontallyScrollableText::from(format!(
+1 -1
View File
@@ -102,7 +102,7 @@ impl Network<'_, '_> {
} }
}) })
.collect(); .collect();
blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); blocklist_vec.sort_by_key(|a| a.id);
app.data.sonarr_data.blocklist.set_items(blocklist_vec); app.data.sonarr_data.blocklist.set_items(blocklist_vec);
app.data.sonarr_data.blocklist.apply_sorting_toggle(false); app.data.sonarr_data.blocklist.apply_sorting_toggle(false);
} }
+1 -1
View File
@@ -31,7 +31,7 @@ impl Network<'_, '_> {
Route::Sonarr(ActiveSonarrBlock::HistorySortPrompt, _) Route::Sonarr(ActiveSonarrBlock::HistorySortPrompt, _)
) { ) {
let mut history_vec = history_response.records; let mut history_vec = history_response.records;
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
app.data.sonarr_data.history.set_items(history_vec); app.data.sonarr_data.history.set_items(history_vec);
app.data.sonarr_data.history.apply_sorting_toggle(false); app.data.sonarr_data.history.apply_sorting_toggle(false);
} }
@@ -60,7 +60,7 @@ impl Network<'_, '_> {
self self
.handle_request::<(), Vec<Episode>>(request_props, |mut episode_vec, mut app| { .handle_request::<(), Vec<Episode>>(request_props, |mut episode_vec, mut app| {
episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); episode_vec.sort_by_key(|a| a.id);
if !matches!( if !matches!(
app.get_current_route(), app.get_current_route(),
Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _) Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _)
@@ -151,7 +151,7 @@ impl Network<'_, '_> {
.get_or_insert_default(); .get_or_insert_default();
let mut history_vec = history_response.records; let mut history_vec = history_response.records;
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
episode_details_modal.episode_history.set_items(history_vec); episode_details_modal.episode_history.set_items(history_vec);
episode_details_modal episode_details_modal
.episode_history .episode_history
@@ -158,7 +158,7 @@ impl Network<'_, '_> {
if !is_sorting { if !is_sorting {
let mut history_vec = history_items; let mut history_vec = history_items;
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
season_details_modal.season_history.set_items(history_vec); season_details_modal.season_history.set_items(history_vec);
season_details_modal season_details_modal
.season_history .season_history
@@ -315,7 +315,7 @@ impl Network<'_, '_> {
let series_history = app.data.sonarr_data.series_history.get_or_insert_default(); let series_history = app.data.sonarr_data.series_history.get_or_insert_default();
if !is_sorting { if !is_sorting {
history_vec.sort_by(|a, b| a.id.cmp(&b.id)); history_vec.sort_by_key(|a| a.id);
series_history.set_items(history_vec); series_history.set_items(history_vec);
series_history.apply_sorting_toggle(false); series_history.apply_sorting_toggle(false);
} }
@@ -337,7 +337,7 @@ impl Network<'_, '_> {
app.get_current_route(), app.get_current_route(),
Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _) Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _)
) { ) {
series_vec.sort_by(|a, b| a.id.cmp(&b.id)); series_vec.sort_by_key(|a| a.id);
app.data.sonarr_data.series.set_items(series_vec); app.data.sonarr_data.series.set_items(series_vec);
app.data.sonarr_data.series.apply_sorting_toggle(false); app.data.sonarr_data.series.apply_sorting_toggle(false);
} }
+2 -5
View File
@@ -50,7 +50,7 @@ impl Network<'_, '_> {
let log_lines = logs let log_lines = logs
.into_iter() .into_iter()
.map(|log| { .map(|log| {
if log.exception.is_some() { if let Some(exception) = log.exception {
HorizontallyScrollableText::from(format!( HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}", "{}|{}|{}|{}|{}",
log.time, log.time,
@@ -63,10 +63,7 @@ impl Network<'_, '_> {
.exception_type .exception_type
.as_ref() .as_ref()
.expect("exception_type must exist when exception is present"), .expect("exception_type must exist when exception is present"),
log exception
.exception
.as_ref()
.expect("exception must exist in this branch")
)) ))
} else { } else {
HorizontallyScrollableText::from(format!( HorizontallyScrollableText::from(format!(
+18 -21
View File
@@ -52,33 +52,30 @@ impl DrawUi for IndexersUi {
_ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area),
Route::Lidarr(active_lidarr_block, _) => match active_lidarr_block { Route::Lidarr(active_lidarr_block, _) => match active_lidarr_block {
ActiveLidarrBlock::TestIndexer => { ActiveLidarrBlock::TestIndexer => {
if app.is_loading || app.data.lidarr_data.indexer_test_errors.is_none() { if let Some(result) = app
.data
.lidarr_data
.indexer_test_errors
.as_ref()
.filter(|_| !app.is_loading)
{
let popup = if !result.is_empty() {
Popup::new(Message::new(result.clone())).size(Size::LargeMessage)
} else {
let message = Message::new("Indexer test succeeded!")
.title("Success")
.style(success_style().bold());
Popup::new(message).size(Size::Message)
};
f.render_widget(popup, f.area());
} else {
let loading_popup = Popup::new(LoadingBlock::new( let loading_popup = Popup::new(LoadingBlock::new(
app.is_loading || app.data.lidarr_data.indexer_test_errors.is_none(), app.is_loading || app.data.lidarr_data.indexer_test_errors.is_none(),
title_block("Testing Indexer"), title_block("Testing Indexer"),
)) ))
.size(Size::LargeMessage); .size(Size::LargeMessage);
f.render_widget(loading_popup, f.area()); f.render_widget(loading_popup, f.area());
} else {
let popup = {
let result = app
.data
.lidarr_data
.indexer_test_errors
.as_ref()
.expect("Test result is unpopulated");
if !result.is_empty() {
Popup::new(Message::new(result.clone())).size(Size::LargeMessage)
} else {
let message = Message::new("Indexer test succeeded!")
.title("Success")
.style(success_style().bold());
Popup::new(message).size(Size::Message)
}
};
f.render_widget(popup, f.area());
} }
} }
ActiveLidarrBlock::DeleteIndexerPrompt => { ActiveLidarrBlock::DeleteIndexerPrompt => {
+4 -5
View File
@@ -107,9 +107,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
} else { } else {
queued queued
}; };
let started_string = if event.started.is_some() { let started_string = if let Some(date_time) = event.started {
let started = let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes());
convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes());
if started != "now" { if started != "now" {
format!("{started} ago") format!("{started} ago")
@@ -120,8 +119,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
String::new() String::new()
}; };
let duration = if event.duration.is_some() { let duration = if let Some(dur) = &event.duration {
&event.duration.as_ref().unwrap()[..8] &dur[..8]
} else { } else {
"" ""
}; };
+18 -21
View File
@@ -52,33 +52,30 @@ impl DrawUi for IndexersUi {
_ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area),
Route::Radarr(active_radarr_block, _) => match active_radarr_block { Route::Radarr(active_radarr_block, _) => match active_radarr_block {
ActiveRadarrBlock::TestIndexer => { ActiveRadarrBlock::TestIndexer => {
if app.is_loading || app.data.radarr_data.indexer_test_errors.is_none() { if let Some(result) = app
.data
.radarr_data
.indexer_test_errors
.as_ref()
.filter(|_| !app.is_loading)
{
let popup = if !result.is_empty() {
Popup::new(Message::new(result.clone())).size(Size::LargeMessage)
} else {
let message = Message::new("Indexer test succeeded!")
.title("Success")
.style(success_style().bold());
Popup::new(message).size(Size::Message)
};
f.render_widget(popup, f.area());
} else {
let loading_popup = Popup::new(LoadingBlock::new( let loading_popup = Popup::new(LoadingBlock::new(
app.is_loading || app.data.radarr_data.indexer_test_errors.is_none(), app.is_loading || app.data.radarr_data.indexer_test_errors.is_none(),
title_block("Testing Indexer"), title_block("Testing Indexer"),
)) ))
.size(Size::LargeMessage); .size(Size::LargeMessage);
f.render_widget(loading_popup, f.area()); f.render_widget(loading_popup, f.area());
} else {
let popup = {
let result = app
.data
.radarr_data
.indexer_test_errors
.as_ref()
.expect("Test result is unpopulated");
if !result.is_empty() {
Popup::new(Message::new(result.clone())).size(Size::LargeMessage)
} else {
let message = Message::new("Indexer test succeeded!")
.title("Success")
.style(success_style().bold());
Popup::new(message).size(Size::Message)
}
};
f.render_widget(popup, f.area());
} }
} }
ActiveRadarrBlock::DeleteIndexerPrompt => { ActiveRadarrBlock::DeleteIndexerPrompt => {
+2 -3
View File
@@ -114,9 +114,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
} else { } else {
queued queued
}; };
let started_string = if event.started.is_some() { let started_string = if let Some(date_time) = event.started {
let started = let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes());
convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes());
if started != "now" { if started != "now" {
format!("{started} ago") format!("{started} ago")
+18 -21
View File
@@ -52,33 +52,30 @@ impl DrawUi for IndexersUi {
_ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area),
Route::Sonarr(active_sonarr_block, _) => match active_sonarr_block { Route::Sonarr(active_sonarr_block, _) => match active_sonarr_block {
ActiveSonarrBlock::TestIndexer => { ActiveSonarrBlock::TestIndexer => {
if app.is_loading || app.data.sonarr_data.indexer_test_errors.is_none() { if let Some(result) = app
.data
.sonarr_data
.indexer_test_errors
.as_ref()
.filter(|_| !app.is_loading)
{
let popup = if !result.is_empty() {
Popup::new(Message::new(result.clone())).size(Size::LargeMessage)
} else {
let message = Message::new("Indexer test succeeded!")
.title("Success")
.style(success_style().bold());
Popup::new(message).size(Size::Message)
};
f.render_widget(popup, f.area());
} else {
let loading_popup = Popup::new(LoadingBlock::new( let loading_popup = Popup::new(LoadingBlock::new(
app.is_loading || app.data.sonarr_data.indexer_test_errors.is_none(), app.is_loading || app.data.sonarr_data.indexer_test_errors.is_none(),
title_block("Testing Indexer"), title_block("Testing Indexer"),
)) ))
.size(Size::LargeMessage); .size(Size::LargeMessage);
f.render_widget(loading_popup, f.area()); f.render_widget(loading_popup, f.area());
} else {
let popup = {
let result = app
.data
.sonarr_data
.indexer_test_errors
.as_ref()
.expect("Test result is unpopulated");
if !result.is_empty() {
Popup::new(Message::new(result.clone())).size(Size::LargeMessage)
} else {
let message = Message::new("Indexer test succeeded!")
.title("Success")
.style(success_style().bold());
Popup::new(message).size(Size::Message)
}
};
f.render_widget(popup, f.area());
} }
} }
ActiveSonarrBlock::DeleteIndexerPrompt => { ActiveSonarrBlock::DeleteIndexerPrompt => {
+4 -5
View File
@@ -107,9 +107,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
} else { } else {
queued queued
}; };
let started_string = if event.started.is_some() { let started_string = if let Some(date_time) = event.started {
let started = let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes());
convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes());
if started != "now" { if started != "now" {
format!("{started} ago") format!("{started} ago")
@@ -120,8 +119,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
String::new() String::new()
}; };
let duration = if event.duration.is_some() { let duration = if let Some(dur) = &event.duration {
&event.duration.as_ref().unwrap()[..8] &dur[..8]
} else { } else {
"" ""
}; };
+7 -9
View File
@@ -125,11 +125,8 @@ where
if let Some(content) = self.content if let Some(content) = self.content
&& !self.is_loading && !self.is_loading
{ {
let (table_contents, table_state) = if content.filtered_items.is_some() { let (table_contents, table_state) = if let Some(items) = &content.filtered_items {
( (items, content.filtered_state.as_mut().unwrap())
content.filtered_items.as_ref().unwrap(),
content.filtered_state.as_mut().unwrap(),
)
} else { } else {
(&content.items, &mut content.state) (&content.items, &mut content.state)
}; };
@@ -153,10 +150,11 @@ where
StatefulWidget::render(table, table_area, buf, table_state); StatefulWidget::render(table, table_area, buf, table_state);
if content.sort.is_some() && self.is_sorting { if let Some(sort) = &mut content.sort
let selectable_list = SelectableList::new(content.sort.as_mut().unwrap(), |item| { && self.is_sorting
ListItem::new(Text::from(item.name)) {
}); let selectable_list =
SelectableList::new(sort, |item| ListItem::new(Text::from(item.name)));
Popup::new(selectable_list) Popup::new(selectable_list)
.dimensions(20, 50) .dimensions(20, 50)
.render(table_area, buf); .render(table_area, buf);
+55 -13
View File
@@ -10,7 +10,10 @@ use anyhow::{Context, anyhow};
use colored::Colorize; use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use log::{LevelFilter, error}; use log::{LevelFilter, error};
use log4rs::append::file::FileAppender; use log4rs::append::rolling_file::RollingFileAppender;
use log4rs::append::rolling_file::policy::compound::CompoundPolicy;
use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller;
use log4rs::append::rolling_file::policy::compound::trigger::size::SizeTrigger;
use log4rs::config::{Appender, Root}; use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder; use log4rs::encode::pattern::PatternEncoder;
use regex::Regex; use regex::Regex;
@@ -47,12 +50,24 @@ pub fn get_log_path() -> PathBuf {
} }
pub fn init_logging_config() -> log4rs::Config { pub fn init_logging_config() -> log4rs::Config {
let logfile = FileAppender::builder() let log_path = get_log_path();
let archive_pattern = log_path
.with_file_name("managarr.{}.log")
.to_string_lossy()
.into_owned();
let trigger = SizeTrigger::new(10 * 1024 * 1024);
let roller = FixedWindowRoller::builder()
.build(&archive_pattern, 3)
.expect("Failed to build log roller");
let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
let logfile = RollingFileAppender::builder()
.encoder(Box::new(PatternEncoder::new( .encoder(Box::new(PatternEncoder::new(
"{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}", "{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}",
))) )))
.build(get_log_path()) .build(log_path, Box::new(policy))
.unwrap(); .expect("Failed to build rolling file appender");
log4rs::Config::builder() log4rs::Config::builder()
.appender(Appender::builder().build("logfile", Box::new(logfile))) .appender(Appender::builder().build("logfile", Box::new(logfile)))
@@ -89,16 +104,29 @@ pub async fn tail_logs(no_color: bool) -> Result<()> {
.seek(SeekFrom::End(0)) .seek(SeekFrom::End(0))
.with_context(|| "Unable to tail log file")?; .with_context(|| "Unable to tail log file")?;
let mut lines = reader.lines(); tokio::task::spawn_blocking(move || {
let mut line_buf = String::new();
tokio::spawn(async move {
loop { loop {
if let Some(Ok(line)) = lines.next() { line_buf.clear();
if no_color { match reader.read_line(&mut line_buf) {
println!("{line}"); Ok(0) => {
} else { if was_log_rotated(&file_path, &mut reader) {
let colored_line = colorize_log_line(&line, &re); continue;
println!("{colored_line}"); }
std::thread::sleep(Duration::from_millis(100));
}
Ok(_) => {
let line = line_buf.trim_end();
if no_color {
println!("{line}");
} else {
let colored_line = colorize_log_line(line, &re);
println!("{colored_line}");
}
}
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
} }
} }
} }
@@ -106,6 +134,20 @@ pub async fn tail_logs(no_color: bool) -> Result<()> {
.await? .await?
} }
pub(crate) fn was_log_rotated(file_path: &PathBuf, reader: &mut BufReader<File>) -> bool {
let current_pos = reader.stream_position().unwrap_or(0);
let file_len = fs::metadata(file_path).map(|m| m.len()).unwrap_or(0);
if file_len < current_pos
&& let Ok(new_file) = File::open(file_path)
{
*reader = BufReader::new(new_file);
return true;
}
false
}
fn colorize_log_line(line: &str, re: &Regex) -> String { fn colorize_log_line(line: &str, re: &Regex) -> String {
if let Some(caps) = re.captures(line) { if let Some(caps) = re.captures(line) {
let level = &caps["level"]; let level = &caps["level"];
+55 -1
View File
@@ -1,8 +1,11 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use crate::utils::{convert_f64_to_gb, convert_runtime, convert_to_gb}; use crate::utils::{convert_f64_to_gb, convert_runtime, convert_to_gb, was_log_rotated};
#[test] #[test]
fn test_convert_to_gb() { fn test_convert_to_gb() {
@@ -23,4 +26,55 @@ mod tests {
assert_eq!(hours, 2); assert_eq!(hours, 2);
assert_eq!(minutes, 34); assert_eq!(minutes, 34);
} }
#[test]
fn test_was_log_rotated_returns_false_when_file_has_not_rotated() {
let path = std::env::temp_dir().join("managarr_test_no_rotation.log");
fs::write(&path, "line one\nline two\n").unwrap();
let file = File::open(&path).unwrap();
let mut reader = BufReader::new(file);
reader.seek(SeekFrom::End(0)).unwrap();
assert!(!was_log_rotated(&path, &mut reader));
fs::remove_file(&path).unwrap();
}
#[test]
fn test_was_log_rotated_returns_true_and_reopens_reader_after_rotation() {
let path = std::env::temp_dir().join("managarr_test_rotation.log");
fs::write(&path, "original content that is long enough\n").unwrap();
let file = File::open(&path).unwrap();
let mut reader = BufReader::new(file);
reader.seek(SeekFrom::End(0)).unwrap();
fs::write(&path, "new\n").unwrap();
assert!(was_log_rotated(&path, &mut reader));
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(line, "new\n");
fs::remove_file(&path).unwrap();
}
#[test]
fn test_was_log_rotated_returns_false_when_file_grows() {
let path = std::env::temp_dir().join("managarr_test_growing.log");
fs::write(&path, "initial\n").unwrap();
let file = File::open(&path).unwrap();
let mut reader = BufReader::new(file);
reader.seek(SeekFrom::End(0)).unwrap();
let mut appender = fs::OpenOptions::new().append(true).open(&path).unwrap();
appender.write_all(b"more data\n").unwrap();
assert!(!was_log_rotated(&path, &mut reader));
fs::remove_file(&path).unwrap();
}
} }