Compare commits

...

25 Commits

Author SHA1 Message Date
5b42129f55 fix: Yet another typo in the release workflow [skip ci] 2024-11-06 17:15:17 -07:00
4f06b2b947 fix: Fixed typo in release name [skip ci] 2024-11-06 17:14:33 -07:00
0f98050a12 fix: Removed the release-patch workflow [skip ci] 2024-11-06 17:14:01 -07:00
14839642dc fix: Fixed a typo in the new release workflow name [skip ci] 2024-11-06 17:13:17 -07:00
eccc1a2df1 fix: Release-plz to perform the release and to use Commitizen for bumping and generating the CHANGELOG [skip ci] 2024-11-06 17:12:39 -07:00
9df929a8e3 fix: Updated the Commitizen config to also always commit the Cargo.lock when doing the version bump 2024-11-06 17:07:43 -07:00
1e008f9778 bump: version 0.2.1 → 0.2.2 2024-11-06 17:03:46 -07:00
Alex Clarke
fa811da5c2 Merge pull request #16 from Dark-Alex-17/commitizen-config
Added Commitizen to enforce commit styles
2024-11-06 16:53:40 -07:00
48ad17c6f1 style: Updated the contributing doc to also explain how to install commitizen 2024-11-06 16:48:27 -07:00
3cd15f34cd style: Test install for commitizen 2024-11-06 16:39:26 -07:00
53ca14e64d fix(handler): Fixed a bug in the movie details handler that would allow key events to be processed before the data was finished loading 2024-11-06 16:17:23 -07:00
0d8803d35d fix(ui): Fixed a bug that would freeze all user input while background network requests were running 2024-11-06 15:50:47 -07:00
8c90221a81 perf(network): Improved performance and reactiveness of the UI by speeding up network requests and clearing the channel whenever a request is cancelled/the UI is routing 2024-11-06 14:52:48 -07:00
a708f71d57 fix(radarr_ui): Fixed a race condition bug in the movie details UI that would panic if the user changes tabs too quickly 2024-11-06 11:29:49 -07:00
2a13f74a2b Updated the release workflows to use the correct flags and commands 2024-11-05 18:33:17 -07:00
2a97c49a8e Updated workflows to allow manual releases [skip ci] 2024-11-05 18:29:45 -07:00
8c155ce656 Updated release actions to only be executable by the repository owner [skip ci] 2024-11-05 18:25:26 -07:00
Alex Clarke
5245ba6d98 Merge pull request #14 from Dark-Alex-17/release-plz-2024-11-06T01-20-32Z
chore: release v0.2.1
2024-11-05 18:24:50 -07:00
github-actions[bot]
f9789ecc9b chore: release v0.2.1 2024-11-06 01:20:33 +00:00
9936ce1ab5 Removed the need for use_ssl to indicate SSL usage; instead just use the ssl_cert_path
Added the ability to specify either host/port, or uri for configuring Radarr
2024-11-05 18:16:01 -07:00
650c9783a6 Applied bug fix to the downloads tab as well as the context [skip ci] 2024-11-04 14:21:34 -07:00
b253a389eb Updated the README to not include the GitHub downloads badge since all binary releases are on crates.io [skip ci] 2024-11-04 10:12:45 -07:00
5023fbd3d1 Set all releases as manually triggered instead of automatic [skip ci] 2024-11-04 10:10:36 -07:00
fdb08fbd34 Added additional workflows for releasing minor and major releases, in addition to just patches so I can manually trigger them and update the Changelog dynamically. [skip ci] 2024-11-03 16:37:52 -07:00
b125d3341a Updated dockerfile to no longer use the --disable-terminal-size-checks flag [skip ci] 2024-11-03 16:20:32 -07:00
24 changed files with 488 additions and 248 deletions
+10
View File
@@ -0,0 +1,10 @@
[tool.commitizen]
name = "cz_conventional_commits"
tag_format = "v$version"
version_scheme = "semver"
version_provider = "cargo"
update_changelog_on_bump = true
major_version_zero = true
[tool.commitizen.hooks]
pre-commit = "git add Cargo.toml Cargo.lock"
+10 -4
View File
@@ -1,16 +1,14 @@
# Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml
# Thanks to joshka for permission to use this template!
name: Create Release PR and Publish Release
name: Create release
permissions:
pull-requests: write
contents: write
on:
push:
branches:
- main
workflow_dispatch:
jobs:
release-plz:
@@ -19,6 +17,12 @@ jobs:
name: Release-plz
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout
uses: actions/checkout@v4
with:
@@ -27,6 +31,8 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: MarcoIeni/release-plz-action@v0.5
with:
command: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+8
View File
@@ -0,0 +1,8 @@
repos:
- hooks:
- id: commitizen
- id: commitizen-branch
stages:
- pre-push
repo: https://github.com/commitizen-tools/commitizen
rev: v3.30.0
+23 -1
View File
@@ -5,7 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## v0.2.2 (2024-11-06)
### Fix
- **handler**: Fixed a bug in the movie details handler that would allow key events to be processed before the data was finished loading
- **ui**: Fixed a bug that would freeze all user input while background network requests were running
- **radarr_ui**: Fixed a race condition bug in the movie details UI that would panic if the user changes tabs too quickly
### Perf
- **network**: Improved performance and reactiveness of the UI by speeding up network requests and clearing the channel whenever a request is cancelled/the UI is routing
## v0.2.1 (2024-11-06)
## [0.2.1](https://github.com/Dark-Alex-17/managarr/compare/v0.2.0...v0.2.1) - 2024-11-06
### Other
- Removed the need for use_ssl to indicate SSL usage; instead just use the ssl_cert_path
- Applied bug fix to the downloads tab as well as the context [skip ci]
- Updated the README to not include the GitHub downloads badge since all binary releases are on crates.io [skip ci]
- Set all releases as manually triggered instead of automatic [skip ci]
- Updated dockerfile to no longer use the --disable-terminal-size-checks flag [skip ci]
## [0.1.5](https://github.com/Dark-Alex-17/managarr/compare/v0.1.4...v0.1.5) - 2024-11-03
+32
View File
@@ -1,6 +1,7 @@
# Contributing
Contributors are very welcome! **No contribution is too small and all contributions are valued.**
## Rust
You'll need to have the stable Rust toolchain installed in order to develop Managarr.
The Rust toolchain (stable) can be installed via rustup using the following command:
@@ -11,6 +12,37 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This will install `rustup`, `rustc` and `cargo`. For more information, refer to the [official Rust installation documentation](https://www.rust-lang.org/tools/install).
## Commitizen
[Commitizen](https://github.com/commitizen-tools/commitizen?tab=readme-ov-file) is a nifty tool that helps us write better commit messages. It ensures that our
commits have a consistent style and makes it easier to generate CHANGELOGS. Additionally,
Commitizen is used to run pre-commit checks to enforce style constraints.
To install `commitizen` and the `pre-commit` prerequisite, run the following command:
```shell
python3 -m pip install commitizen pre-commit
```
### Commitizen Quick Guide
To see an example commit to get an idea for the Commitizen style, run:
```shell
cz example
```
To see the allowed types of commits and their descriptions, run:
```shell
cz info
```
If you'd like to create a commit using Commitizen with an interactive prompt to help you get
comfortable with the style, use:
```shell
cz commit
```
## Setup workspace
1. Clone this repo
Generated
+1 -1
View File
@@ -1148,7 +1148,7 @@ dependencies = [
[[package]]
name = "managarr"
version = "0.2.0"
version = "0.2.2"
dependencies = [
"anyhow",
"assert_cmd",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "managarr"
version = "0.2.0"
version = "0.2.2"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
+1 -1
View File
@@ -23,4 +23,4 @@ FROM debian:stable-slim
# Copy the compiled binary from the builder container
COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin
ENTRYPOINT [ "/usr/local/bin/managarr", "--disable-terminal-size-checks" ]
ENTRYPOINT [ "/usr/local/bin/managarr" ]
+11 -20
View File
@@ -2,13 +2,11 @@
![check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/release.yml/badge.svg)
![License](https://img.shields.io/badge/license-MIT-blueviolet.svg)
![LOC](https://tokei.rs/b1/github/Dark-Alex-17/managarr?category=code)
[![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr)
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff)
[![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr)
[![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/managarr/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/managarr/releases)
![Crate.io downloads](https://img.shields.io/crates/d/managarr?label=Crate%20downloads)
Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust!
@@ -113,7 +111,7 @@ To see all available commands, simply run `managarr --help`:
```shell
$ managarr --help
managarr 0.1.5
managarr 0.2.1
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
@@ -201,45 +199,38 @@ managarr --config /path/to/config.yml
### Example Configuration:
```yaml
radarr:
host: 127.0.0.1
host: 192.168.0.78
port: 7878
api_token: someApiToken1234567890
use_ssl: true
ssl_cert_path: /path/to/radarr.crt
ssl_cert_path: /path/to/radarr.crt # Required to enable SSL
sonarr:
host: 127.0.0.1
port: 8989
uri: http://htpc.local/sonarr # Example of using the 'uri' key instead of 'host' and 'port'
api_token: someApiToken1234567890
readarr:
host: 127.0.0.1
host: 192.168.0.87
port: 8787
api_token: someApiToken1234567890
use_ssl: false
lidarr:
host: 127.0.0.1
host: 192.168.0.86
port: 8686
api_token: someApiToken1234567890
use_ssl: false
whisparr:
host: 127.0.0.1
host: 192.168.0.69
port: 6969
api_token: someApiToken1234567890
use_ssl: false
ssl_cert_path: /path/to/whisparr.crt
bazarr:
host: 127.0.0.1
host: 192.168.0.67
port: 6767
api_token: someApiToken1234567890
use_ssl: false
prowlarr:
host: 127.0.0.1
host: 192.168.0.96
port: 9696
api_token: someApiToken1234567890
use_ssl: false
tautulli:
host: 127.0.0.1
host: 192.168.0.81
port: 8181
api_token: someApiToken1234567890
use_ssl: false
```
## Environment Variables
+6
View File
@@ -0,0 +1,6 @@
{
"name": "managarr",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1
View File
@@ -0,0 +1 @@
{}
+38 -8
View File
@@ -1,7 +1,7 @@
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use pretty_assertions::{assert_eq, assert_str_eq};
use pretty_assertions::assert_eq;
use tokio::sync::mpsc;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
@@ -87,7 +87,11 @@ mod tests {
#[test]
fn test_reset_cancellation_token() {
let mut app = App::default();
let mut app = App {
is_loading: true,
should_refresh: false,
..App::default()
};
app.cancellation_token.cancel();
assert!(app.cancellation_token.is_cancelled());
@@ -96,6 +100,8 @@ mod tests {
assert!(!app.cancellation_token.is_cancelled());
assert!(!new_token.is_cancelled());
assert!(!app.is_loading);
assert!(app.should_refresh);
}
#[test]
@@ -145,6 +151,29 @@ mod tests {
assert_eq!(app.error.text, test_string);
}
#[tokio::test]
async fn test_dispatch_network_event() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App {
tick_until_poll: 2,
network_tx: Some(sync_network_tx),
..App::default()
};
assert_eq!(app.tick_count, 0);
app
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_on_tick_first_render() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
@@ -158,6 +187,7 @@ mod tests {
assert_eq!(app.tick_count, 0);
app.on_tick(true).await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into()
@@ -170,6 +200,10 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
@@ -182,10 +216,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetMovies.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(!app.is_routing);
assert!(!app.should_refresh);
assert_eq!(app.tick_count, 1);
@@ -221,10 +251,10 @@ mod tests {
fn test_radarr_config_default() {
let radarr_config = RadarrConfig::default();
assert_str_eq!(radarr_config.host, "localhost");
assert_eq!(radarr_config.host, Some("localhost".to_string()));
assert_eq!(radarr_config.port, Some(7878));
assert_eq!(radarr_config.uri, None);
assert!(radarr_config.api_token.is_empty());
assert!(!radarr_config.use_ssl);
assert_eq!(radarr_config.ssl_cert_path, None);
}
}
+37 -6
View File
@@ -1,4 +1,7 @@
use std::process;
use anyhow::anyhow;
use colored::Colorize;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::Sender;
@@ -53,7 +56,10 @@ impl<'a> App<'a> {
pub async fn dispatch_network_event(&mut self, action: NetworkEvent) {
debug!("Dispatching network event: {action:?}");
self.is_loading = true;
if !self.should_refresh {
self.is_loading = true;
}
if let Some(network_tx) = &self.network_tx {
if let Err(e) = network_tx.send(action).await {
self.is_loading = false;
@@ -110,6 +116,8 @@ impl<'a> App<'a> {
pub fn reset_cancellation_token(&mut self) -> CancellationToken {
self.cancellation_token = CancellationToken::new();
self.should_refresh = true;
self.is_loading = false;
self.cancellation_token.clone()
}
@@ -166,29 +174,52 @@ pub struct Data<'a> {
pub radarr_data: RadarrData<'a>,
}
pub trait ServarrConfig {
fn validate(&self);
}
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct AppConfig {
pub radarr: RadarrConfig,
}
impl ServarrConfig for AppConfig {
fn validate(&self) {
self.radarr.validate();
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RadarrConfig {
pub host: String,
pub host: Option<String>,
pub port: Option<u16>,
pub uri: Option<String>,
pub api_token: String,
#[serde(default)]
pub use_ssl: bool,
pub ssl_cert_path: Option<String>,
}
impl ServarrConfig for RadarrConfig {
fn validate(&self) {
if self.host.is_none() && self.uri.is_none() {
log_and_print_error("'host' or 'uri' is required for Radarr configuration".to_owned());
process::exit(1);
}
}
}
impl Default for RadarrConfig {
fn default() -> Self {
RadarrConfig {
host: "localhost".to_string(),
host: Some("localhost".to_string()),
port: Some(7878),
uri: None,
api_token: "".to_string(),
use_ssl: false,
ssl_cert_path: None,
}
}
}
pub fn log_and_print_error(error: String) {
error!("{}", error);
eprintln!("error: {}", error.red());
}
+12 -19
View File
@@ -142,35 +142,22 @@ impl<'a> App<'a> {
is_first_render: bool,
) {
if is_first_render {
self
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetOverview.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
self.refresh_metadata().await;
self.dispatch_by_radarr_block(&active_radarr_block).await;
}
if self.should_refresh {
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await;
}
if self.is_routing {
if self.is_loading && !self.should_refresh {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await;
}
self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await;
}
if self.tick_count % self.tick_until_poll == 0 {
@@ -191,6 +178,12 @@ impl<'a> App<'a> {
self
.dispatch_network_event(RadarrEvent::GetDownloads.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetOverview.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
}
async fn populate_movie_collection_table(&mut self) {
+14 -30
View File
@@ -508,6 +508,14 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert!(app.is_loading);
}
@@ -529,6 +537,10 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
@@ -537,10 +549,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
}
@@ -549,6 +557,7 @@ mod tests {
async fn test_radarr_on_tick_routing() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_routing = true;
app.should_refresh = true;
app
.radarr_on_tick(ActiveRadarrBlock::Downloads, false)
@@ -574,43 +583,19 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_radarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (mut app, mut sync_network_rx) = construct_app_unit();
let (mut app, _) = construct_app_unit();
app.is_routing = true;
app.is_loading = true;
app.should_refresh = false;
app
.radarr_on_tick(ActiveRadarrBlock::Downloads, false)
.await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
assert!(app.cancellation_token.is_cancelled());
}
@@ -627,7 +612,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(app.should_refresh);
assert!(!app.data.radarr_data.prompt_confirm);
}
@@ -47,18 +47,28 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<
}
fn is_ready(&self) -> bool {
let movie_details_modal_is_ready =
if let Some(movie_details_modal) = &self.app.data.radarr_data.movie_details_modal {
!movie_details_modal.movie_details.is_empty()
|| !movie_details_modal.movie_history.is_empty()
|| !movie_details_modal.movie_cast.is_empty()
|| !movie_details_modal.movie_crew.is_empty()
|| !movie_details_modal.movie_releases.is_empty()
} else {
false
};
!self.app.is_loading && movie_details_modal_is_ready
if let Some(movie_details_modal) = &self.app.data.radarr_data.movie_details_modal {
match self.active_radarr_block {
ActiveRadarrBlock::MovieDetails => {
!self.app.is_loading && !movie_details_modal.movie_details.is_empty()
}
ActiveRadarrBlock::MovieHistory => {
!self.app.is_loading && !movie_details_modal.movie_history.is_empty()
}
ActiveRadarrBlock::Cast => {
!self.app.is_loading && !movie_details_modal.movie_cast.is_empty()
}
ActiveRadarrBlock::Crew => {
!self.app.is_loading && !movie_details_modal.movie_crew.is_empty()
}
ActiveRadarrBlock::ManualSearch => {
!self.app.is_loading && !movie_details_modal.movie_releases.is_empty()
}
_ => !self.app.is_loading,
}
} else {
false
}
}
fn handle_scroll_up(&mut self) {
@@ -3,6 +3,7 @@ mod tests {
use std::cmp::Ordering;
use pretty_assertions::assert_str_eq;
use rstest::rstest;
use serde_json::Number;
use strum::IntoEnumIterator;
@@ -1245,10 +1246,12 @@ mod tests {
#[test]
fn test_manual_search_submit() {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default()
});
};
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into());
MovieDetailsHandler::with(
@@ -1486,10 +1489,17 @@ mod tests {
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.search.key,
@@ -1539,10 +1549,9 @@ mod tests {
#[test]
fn test_sort_key() {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default()
});
let mut modal = MovieDetailsModal::default();
modal.movie_releases.set_items(release_vec());
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.sort.key,
@@ -1670,10 +1679,17 @@ mod tests {
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.update.key,
@@ -1733,10 +1749,17 @@ mod tests {
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
});
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.refresh.key,
@@ -1994,15 +2017,37 @@ mod tests {
});
}
#[test]
fn test_movie_details_handler_is_not_ready_when_loading() {
#[rstest]
fn test_movie_details_handler_is_not_ready_when_loading(
#[values(
ActiveRadarrBlock::MovieDetails,
ActiveRadarrBlock::MovieHistory,
ActiveRadarrBlock::FileInfo,
ActiveRadarrBlock::Cast,
ActiveRadarrBlock::Crew,
ActiveRadarrBlock::ManualSearch,
ActiveRadarrBlock::ManualSearch
)]
movie_details_block: ActiveRadarrBlock,
) {
let mut app = App::default();
app.is_loading = true;
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
let handler = MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.esc.key,
&mut app,
&ActiveRadarrBlock::MovieDetails,
&movie_details_block,
&None,
);
+41 -31
View File
@@ -6,11 +6,12 @@ use std::panic::PanicHookInfo;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::{io, panic, process};
use anyhow::anyhow;
use anyhow::Result;
use app::AppConfig;
use app::{log_and_print_error, AppConfig, ServarrConfig};
use clap::{
command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser,
};
@@ -20,11 +21,12 @@ use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use log::error;
use log::{error, warn};
use network::NetworkTrait;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use reqwest::{Certificate, Client};
use tokio::select;
use tokio::sync::mpsc::Receiver;
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
@@ -88,6 +90,7 @@ async fn main() -> Result<()> {
} else {
confy::load("managarr", "config")?
};
config.validate();
let reqwest_client = build_network_client(&config);
let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
let cancellation_token = CancellationToken::new();
@@ -143,9 +146,20 @@ async fn start_networking(
) {
let mut network = Network::new(app, cancellation_token, client);
while let Some(network_event) = network_rx.recv().await {
if let Err(e) = network.handle_network_event(network_event).await {
error!("Encountered an error handling network event: {e:?}");
loop {
select! {
Some(network_event) = network_rx.recv() => {
if let Err(e) = network.handle_network_event(network_event).await {
error!("Encountered an error handling network event: {e:?}");
}
}
_ = network.cancellation_token.cancelled() => {
warn!("Clearing network channel");
while network_rx.try_recv().is_ok() {
// Discard the message
}
network.reset_cancellation_token().await;
}
}
}
}
@@ -227,10 +241,13 @@ fn load_config(path: &str) -> Result<AppConfig> {
}
fn build_network_client(config: &AppConfig) -> Client {
let mut client_builder = Client::builder();
let mut client_builder = Client::builder()
.pool_max_idle_per_host(10)
.http2_keep_alive_interval(Duration::from_secs(5))
.tcp_keepalive(Duration::from_secs(5));
if config.radarr.use_ssl {
let cert = create_cert(config.radarr.ssl_cert_path.clone(), "Radarr");
if let Some(ref cert_path) = config.radarr.ssl_cert_path {
let cert = create_cert(cert_path, "Radarr");
client_builder = client_builder.add_root_certificate(cert);
}
@@ -244,32 +261,25 @@ fn build_network_client(config: &AppConfig) -> Client {
}
}
fn create_cert(cert_path: Option<String>, servarr_name: &str) -> Certificate {
let err = |error: String| {
error!("{}", error);
eprintln!("error: {}", error.red());
process::exit(1);
};
if cert_path.is_none() {
err(format!(
"A {} cert path is required when 'use_ssl' is 'true'",
servarr_name
));
}
match fs::read(cert_path.unwrap()) {
fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate {
match fs::read(cert_path) {
Ok(cert) => match Certificate::from_pem(&cert) {
Ok(certificate) => certificate,
Err(_) => err(format!(
"Unable to read the specified {} SSL certificate",
servarr_name
)),
Err(_) => {
log_and_print_error(format!(
"Unable to read the specified {} SSL certificate",
servarr_name
));
process::exit(1);
}
},
Err(_) => err(format!(
"Unable to open specified {} SSL certificate",
servarr_name
)),
Err(_) => {
log_and_print_error(format!(
"Unable to open specified {} SSL certificate",
servarr_name
));
process::exit(1);
}
}
}
+5 -4
View File
@@ -40,7 +40,7 @@ pub trait NetworkTrait {
#[derive(Clone)]
pub struct Network<'a, 'b> {
client: Client,
cancellation_token: CancellationToken,
pub cancellation_token: CancellationToken,
pub app: &'a Arc<Mutex<App<'b>>>,
}
@@ -74,6 +74,10 @@ impl<'a, 'b> Network<'a, 'b> {
}
}
pub(super) async fn reset_cancellation_token(&mut self) {
self.cancellation_token = self.app.lock().await.reset_cancellation_token();
}
async fn handle_request<B, R>(
&mut self,
request_props: RequestProps<B>,
@@ -89,9 +93,6 @@ impl<'a, 'b> Network<'a, 'b> {
select! {
_ = self.cancellation_token.cancelled() => {
warn!("Received Cancel request. Cancelling request to: {request_uri}");
let mut app = self.app.lock().await;
self.cancellation_token = app.reset_cancellation_token();
app.is_loading = false;
Ok(R::default())
}
resp = self.call_api(request_props).await.send() => {
+23 -3
View File
@@ -26,7 +26,7 @@ mod tests {
.with_body("{}")
.create_async()
.await;
let host = server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned();
let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse()
@@ -38,8 +38,8 @@ mod tests {
host,
api_token: String::new(),
port,
use_ssl: false,
ssl_cert_path: None,
..RadarrConfig::default()
};
app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app));
@@ -181,11 +181,31 @@ mod tests {
assert!(!async_server.matched_async().await);
assert!(app_arc.lock().await.error.text.is_empty());
assert!(!network.cancellation_token.is_cancelled());
assert!(resp.is_ok());
assert_eq!(resp.unwrap(), Test::default());
}
#[tokio::test]
async fn test_reset_cancellation_token() {
let cancellation_token = CancellationToken::new();
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let app_arc = Arc::new(Mutex::new(App::new(
tx,
AppConfig::default(),
cancellation_token.clone(),
)));
app_arc.lock().await.should_refresh = false;
app_arc.lock().await.is_loading = true;
let mut network = Network::new(&app_arc, cancellation_token, Client::new());
network.cancellation_token.cancel();
network.reset_cancellation_token().await;
assert!(!network.cancellation_token.is_cancelled());
assert!(app_arc.lock().await.should_refresh);
assert!(!app_arc.lock().await.is_loading);
}
#[tokio::test]
async fn test_handle_request_get_invalid_body() {
let mut server = Server::new_async().await;
+16 -7
View File
@@ -2261,15 +2261,24 @@ impl<'a, 'b> Network<'a, 'b> {
let RadarrConfig {
host,
port,
uri,
api_token,
use_ssl,
..
ssl_cert_path,
} = &app.config.radarr;
let protocol = if *use_ssl { "https" } else { "http" };
let uri = format!(
"{protocol}://{host}:{}/api/v3{resource}",
port.unwrap_or(7878)
);
let uri = if let Some(radarr_uri) = uri {
format!("{radarr_uri}/api/v3{resource}")
} else {
let protocol = if ssl_cert_path.is_some() {
"https"
} else {
"http"
};
let host = host.as_ref().unwrap();
format!(
"{protocol}://{host}:{}/api/v3{resource}",
port.unwrap_or(7878)
)
};
RequestProps {
uri,
+31 -12
View File
@@ -794,7 +794,7 @@ mod test {
.match_header("X-Api-Key", "test1234");
async_server = async_server.expect_at_most(0).create_async().await;
let host = server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned();
let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse()
@@ -805,8 +805,7 @@ mod test {
host,
port,
api_token: "test1234".to_owned(),
use_ssl: false,
ssl_cert_path: None,
..RadarrConfig::default()
};
app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app));
@@ -4876,11 +4875,10 @@ mod test {
assert!(request_props.api_token.is_empty());
app_arc.lock().await.config.radarr = RadarrConfig {
host: "192.168.0.123".to_owned(),
host: Some("192.168.0.123".to_owned()),
port: Some(8080),
api_token: "testToken1234".to_owned(),
use_ssl: false,
ssl_cert_path: None,
..RadarrConfig::default()
};
}
@@ -4889,11 +4887,33 @@ mod test {
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
app_arc.lock().await.config.radarr = RadarrConfig {
host: "192.168.0.123".to_owned(),
host: Some("192.168.0.123".to_owned()),
port: Some(8080),
api_token: api_token.clone(),
use_ssl: true,
ssl_cert_path: None,
ssl_cert_path: Some("/test/cert.crt".to_owned()),
..RadarrConfig::default()
};
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let request_props = network
.radarr_request_props_from("/test", RequestMethod::Get, None::<()>)
.await;
assert_str_eq!(request_props.uri, "https://192.168.0.123:8080/api/v3/test");
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
}
#[tokio::test]
async fn test_radarr_request_props_from_custom_radarr_config_using_uri_instead_of_host_and_port()
{
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
app_arc.lock().await.config.radarr = RadarrConfig {
uri: Some("https://192.168.0.123:8080".to_owned()),
api_token: api_token.clone(),
..RadarrConfig::default()
};
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
@@ -4986,7 +5006,7 @@ mod test {
async_server = async_server.create_async().await;
let host = server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned();
let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse()
@@ -4997,8 +5017,7 @@ mod test {
host,
port,
api_token: "test1234".to_owned(),
use_ssl: false,
ssl_cert_path: None,
..RadarrConfig::default()
};
app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app));
+5 -1
View File
@@ -92,7 +92,11 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
);
}
let percent = 1f64 - (*sizeleft as f64 / *size as f64);
let percent = if *size == 0 {
0.0
} else {
1f64 - (*sizeleft as f64 / *size as f64)
};
let file_size: f64 = convert_to_gb(*size);
Row::new(vec![
+77 -69
View File
@@ -166,6 +166,7 @@ fn draw_file_info(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let block = layout_block_top_border();
let unknown_download_status = "Status: Unknown".to_owned();
match app.data.radarr_data.movie_details_modal.as_ref() {
Some(movie_details_modal) if !app.is_loading => {
@@ -182,7 +183,7 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
.items
.iter()
.find(|&line| line.starts_with("Status: "))
.unwrap()
.unwrap_or(&unknown_download_status)
.split(": ")
.collect::<Vec<&str>>()[1];
let text = Text::from(
@@ -285,81 +286,88 @@ fn draw_movie_history(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
}
fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let cast_row_mapping = |cast_member: &Credit| {
let Credit {
person_name,
character,
..
} = cast_member;
match app.data.radarr_data.movie_details_modal.as_mut() {
Some(movie_details_modal) if !app.is_loading => {
let cast_row_mapping = |cast_member: &Credit| {
let Credit {
person_name,
character,
..
} = cast_member;
Row::new(vec![
Cell::from(person_name.to_owned()),
Cell::from(character.clone().unwrap_or_default()),
])
.success()
};
let content = Some(
&mut app
.data
.radarr_data
.movie_details_modal
.as_mut()
.unwrap()
.movie_cast,
);
let help_footer = app
.data
.radarr_data
.movie_info_tabs
.get_active_tab_contextual_help();
let cast_table = ManagarrTable::new(content, cast_row_mapping)
.block(layout_block_top_border())
.footer(help_footer)
.loading(app.is_loading)
.headers(["Cast Member", "Character"])
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]);
Row::new(vec![
Cell::from(person_name.to_owned()),
Cell::from(character.clone().unwrap_or_default()),
])
.success()
};
let content = Some(&mut movie_details_modal.movie_cast);
let help_footer = app
.data
.radarr_data
.movie_info_tabs
.get_active_tab_contextual_help();
let cast_table = ManagarrTable::new(content, cast_row_mapping)
.block(layout_block_top_border())
.footer(help_footer)
.loading(app.is_loading)
.headers(["Cast Member", "Character"])
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]);
f.render_widget(cast_table, area);
f.render_widget(cast_table, area);
}
_ => f.render_widget(
LoadingBlock::new(
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
layout_block_top_border(),
),
area,
),
}
}
fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let crew_row_mapping = |crew_member: &Credit| {
let Credit {
person_name,
job,
department,
..
} = crew_member;
match app.data.radarr_data.movie_details_modal.as_mut() {
Some(movie_details_modal) if !app.is_loading => {
let crew_row_mapping = |crew_member: &Credit| {
let Credit {
person_name,
job,
department,
..
} = crew_member;
Row::new(vec![
Cell::from(person_name.to_owned()),
Cell::from(job.clone().unwrap_or_default()),
Cell::from(department.clone().unwrap_or_default()),
])
.success()
};
let content = Some(
&mut app
.data
.radarr_data
.movie_details_modal
.as_mut()
.unwrap()
.movie_crew,
);
let help_footer = app
.data
.radarr_data
.movie_info_tabs
.get_active_tab_contextual_help();
let crew_table = ManagarrTable::new(content, crew_row_mapping)
.block(layout_block_top_border())
.loading(app.is_loading)
.headers(["Crew Member", "Job", "Department"])
.constraints(iter::repeat(Constraint::Ratio(1, 3)).take(3))
.footer(help_footer);
Row::new(vec![
Cell::from(person_name.to_owned()),
Cell::from(job.clone().unwrap_or_default()),
Cell::from(department.clone().unwrap_or_default()),
])
.success()
};
let content = Some(&mut movie_details_modal.movie_crew);
let help_footer = app
.data
.radarr_data
.movie_info_tabs
.get_active_tab_contextual_help();
let crew_table = ManagarrTable::new(content, crew_row_mapping)
.block(layout_block_top_border())
.loading(app.is_loading)
.headers(["Crew Member", "Job", "Department"])
.constraints(iter::repeat(Constraint::Ratio(1, 3)).take(3))
.footer(help_footer);
f.render_widget(crew_table, area);
f.render_widget(crew_table, area);
}
_ => f.render_widget(
LoadingBlock::new(
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
layout_block_top_border(),
),
area,
),
}
}
fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {