Compare commits

...

10 Commits

49 changed files with 738 additions and 803 deletions
+4 -4
View File
@@ -76,15 +76,15 @@ jobs:
RUSTDOCFLAGS: --cfg docsrs
msrv:
# 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
steps:
- uses: actions/checkout@v4
- name: Install 1.89.0
- name: Install 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.89.0
toolchain: 1.95.0
- name: cargo +1.89.0 check
- name: cargo +1.95.0 check
run: cargo check
+6
View File
@@ -5,6 +5,12 @@ 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).
## 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
Generated
+362 -525
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "managarr"
version = "0.7.1"
version = "0.7.2"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
@@ -10,7 +10,7 @@ homepage = "https://github.com/Dark-Alex-17/managarr"
readme = "README.md"
edition = "2024"
license = "MIT"
rust-version = "1.89.0"
rust-version = "1.95.0"
exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"]
[workspace]
@@ -32,7 +32,7 @@ derivative = "2.2.0"
human-panic = "2.0.6"
indoc = "2.0.7"
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"
reqwest = { version = "0.13.2", features = ["json"] }
serde_yaml = "0.9.34"
@@ -63,7 +63,7 @@ managarr-tree-widget = "0.25.0"
indicatif = "0.17.11"
derive_setters = "0.1.9"
deunicode = "1.6.2"
openssl = { version = "0.10.75", features = ["vendored"] }
openssl = { version = "0.10.79", features = ["vendored"] }
veil = "0.2.0"
validate_theme_derive = "0.1.0"
enum_display_style_derive = "0.1.0"
+32 -1
View File
@@ -338,7 +338,38 @@ $ managarr radarr list movies | jq '.[] | select(.title == "Ad Astra") | .id'
# Configuration
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
location of the `managarr` configuration file for your system:
+4 -4
View File
@@ -231,8 +231,8 @@ mod tests {
let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
.map(|(key, desc)| {
let (key, alt_key) = if key.alt.is_some() {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string())
let (key, alt_key) = if let Some(key1) = key.alt {
(key.key.to_string(), key1.to_string())
} else {
(key.key.to_string(), String::new())
};
@@ -338,8 +338,8 @@ mod tests {
}
fn context_clue_to_keybinding_item(key: &KeyBinding, desc: &&str) -> KeybindingItem {
let (key, alt_key) = if key.alt.is_some() {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string())
let (key, alt_key) = if let Some(key1) = key.alt {
(key.key.to_string(), key1.to_string())
} else {
(key.key.to_string(), String::new())
};
@@ -506,18 +506,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditIndexerHandler<'
.tags
);
}
ActiveLidarrBlock::EditIndexerPrompt => {
ActiveLidarrBlock::EditIndexerPrompt
if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::EditIndexerConfirmPrompt
&& matches_key!(confirm, self.key)
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditIndexer(self.build_edit_indexer_params()));
self.app.should_refresh = true;
&& matches_key!(confirm, self.key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditIndexer(self.build_edit_indexer_params()));
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;
}
}
ActiveLidarrBlock::IndexerSettingsRssSyncIntervalInput => {
if indexer_settings.rss_sync_interval > 0 {
indexer_settings.rss_sync_interval -= 1;
}
ActiveLidarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 =>
{
indexer_settings.rss_sync_interval -= 1;
}
_ => (),
}
@@ -591,16 +591,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddArtistHandler<'a,
.tags
)
}
ActiveLidarrBlock::AddArtistPrompt => {
ActiveLidarrBlock::AddArtistPrompt
if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::AddArtistConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::AddArtist(self.build_add_artist_body()));
self.app.pop_navigation_stack();
}
&& matches_key!(confirm, key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::AddArtist(self.build_add_artist_body()));
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 =
BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS);
}
_ if matches_key!(toggle_monitoring, key) => {
if !self.app.data.lidarr_data.albums.is_empty() {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::ToggleAlbumMonitoring(self.extract_album_id()));
_ if matches_key!(toggle_monitoring, key)
&& !self.app.data.lidarr_data.albums.is_empty() =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::ToggleAlbumMonitoring(self.extract_album_id()));
self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into());
}
self
.app
.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
)
}
ActiveLidarrBlock::EditArtistPrompt => {
ActiveLidarrBlock::EditArtistPrompt
if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::EditArtistConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditArtist(self.build_edit_artist_params()));
self.app.should_refresh = true;
&& matches_key!(confirm, key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::EditArtist(self.build_edit_artist_params()));
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
)
}
ActiveLidarrBlock::AddRootFolderPrompt => {
ActiveLidarrBlock::AddRootFolderPrompt
if self.app.data.lidarr_data.selected_block.get_active_block()
== ActiveLidarrBlock::AddRootFolderConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::AddRootFolder(
self.build_add_root_folder_body(),
));
self.app.should_refresh = true;
&& matches_key!(confirm, key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::AddRootFolder(
self.build_add_root_folder_body(),
));
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<'_>) {
let context_clue_to_keybinding_item = |key: &KeyBinding, desc: &&str| {
let (key, alt_key) = if key.alt.is_some() {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string())
let (key, alt_key) = if let Some(key1) = key.alt {
(key.key.to_string(), key1.to_string())
} else {
(key.key.to_string(), String::new())
};
@@ -354,19 +354,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
.path
)
}
ActiveRadarrBlock::EditCollectionPrompt => {
ActiveRadarrBlock::EditCollectionPrompt
if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::EditCollectionConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(
self.build_edit_collection_params(),
));
self.app.should_refresh = true;
&& matches_key!(confirm, key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(
self.build_edit_collection_params(),
));
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
);
}
ActiveRadarrBlock::EditIndexerPrompt => {
ActiveRadarrBlock::EditIndexerPrompt
if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::EditIndexerConfirmPrompt
&& matches_key!(confirm, self.key)
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditIndexer(self.build_edit_indexer_params()));
self.app.should_refresh = true;
&& matches_key!(confirm, self.key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditIndexer(self.build_edit_indexer_params()));
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 => {
indexer_settings.availability_delay -= 1;
}
ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput => {
if indexer_settings.rss_sync_interval > 0 {
indexer_settings.rss_sync_interval -= 1;
}
ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 =>
{
indexer_settings.rss_sync_interval -= 1;
}
_ => (),
}
@@ -272,19 +272,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
.whitelisted_hardcoded_subs
)
}
ActiveRadarrBlock::AllIndexerSettingsPrompt => {
ActiveRadarrBlock::AllIndexerSettingsPrompt
if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::IndexerSettingsConfirmPrompt
&& matches_key!(confirm, self.key)
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(
RadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_body()),
);
self.app.should_refresh = true;
&& matches_key!(confirm, self.key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(
RadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_body()),
);
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
)
}
ActiveRadarrBlock::AddMoviePrompt => {
ActiveRadarrBlock::AddMoviePrompt
if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::AddMovieConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::AddMovie(self.build_add_movie_body()));
self.app.pop_navigation_stack();
}
&& matches_key!(confirm, key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::AddMovie(self.build_add_movie_body()));
self.app.pop_navigation_stack();
}
_ => (),
}
@@ -376,18 +376,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a,
.tags
)
}
ActiveRadarrBlock::EditMoviePrompt => {
ActiveRadarrBlock::EditMoviePrompt
if self.app.data.radarr_data.selected_block.get_active_block()
== ActiveRadarrBlock::EditMovieConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditMovie(self.build_edit_movie_params()));
self.app.should_refresh = true;
&& matches_key!(confirm, key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditMovie(self.build_edit_movie_params()));
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
);
}
ActiveSonarrBlock::EditIndexerPrompt => {
ActiveSonarrBlock::EditIndexerPrompt
if self.app.data.sonarr_data.selected_block.get_active_block()
== ActiveSonarrBlock::EditIndexerConfirmPrompt
&& matches_key!(confirm, self.key)
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::EditIndexer(self.build_edit_indexer_params()));
self.app.should_refresh = true;
&& matches_key!(confirm, self.key) =>
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::EditIndexer(self.build_edit_indexer_params()));
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;
}
}
ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => {
if indexer_settings.rss_sync_interval > 0 {
indexer_settings.rss_sync_interval -= 1;
}
ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 =>
{
indexer_settings.rss_sync_interval -= 1;
}
_ => (),
}
@@ -606,16 +606,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a,
.tags
)
}
ActiveSonarrBlock::AddSeriesPrompt => {
ActiveSonarrBlock::AddSeriesPrompt
if self.app.data.sonarr_data.selected_block.get_active_block()
== ActiveSonarrBlock::AddSeriesConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::AddSeries(self.build_add_series_body()));
self.app.pop_navigation_stack();
}
&& matches_key!(confirm, key) =>
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::AddSeries(self.build_add_series_body()));
self.app.pop_navigation_stack();
}
_ => (),
}
@@ -450,18 +450,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a
.tags
)
}
ActiveSonarrBlock::EditSeriesPrompt => {
ActiveSonarrBlock::EditSeriesPrompt
if self.app.data.sonarr_data.selected_block.get_active_block()
== ActiveSonarrBlock::EditSeriesConfirmPrompt
&& matches_key!(confirm, key)
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::EditSeries(self.build_edit_series_params()));
self.app.should_refresh = true;
&& matches_key!(confirm, key) =>
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
Some(SonarrEvent::EditSeries(self.build_edit_series_params()));
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());
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();
TableHandlerUnit::new(
+1 -1
View File
@@ -82,7 +82,7 @@ impl Network<'_, '_> {
Route::Lidarr(ActiveLidarrBlock::BlocklistSortPrompt, _)
) {
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.apply_sorting_toggle(false);
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Network<'_, '_> {
Route::Lidarr(ActiveLidarrBlock::HistorySortPrompt, _)
) {
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.apply_sorting_toggle(false);
}
@@ -33,7 +33,7 @@ impl Network<'_, '_> {
self
.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);
})
.await
@@ -89,7 +89,7 @@ impl Network<'_, '_> {
.get_or_insert_default();
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
@@ -64,7 +64,7 @@ impl Network<'_, '_> {
app.get_current_route(),
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.apply_sorting_toggle(false);
}
@@ -309,7 +309,7 @@ impl Network<'_, '_> {
let artist_history = &mut app.data.lidarr_data.artist_history;
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.apply_sorting_toggle(false);
}
@@ -55,7 +55,7 @@ impl Network<'_, '_> {
self
.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
.data
.lidarr_data
@@ -238,7 +238,7 @@ impl Network<'_, '_> {
.into_iter()
.filter(|it| it.track_id == track_id)
.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
+2 -5
View File
@@ -50,7 +50,7 @@ impl Network<'_, '_> {
let log_lines = logs
.into_iter()
.map(|log| {
if log.exception.is_some() {
if let Some(exception) = log.exception {
HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}",
log.time,
@@ -63,10 +63,7 @@ impl Network<'_, '_> {
.exception_type
.as_ref()
.expect("exception_type must exist when exception is present"),
log
.exception
.as_ref()
.expect("exception must exist in this branch")
exception
))
} else {
HorizontallyScrollableText::from(format!(
+1 -1
View File
@@ -83,7 +83,7 @@ impl Network<'_, '_> {
Route::Radarr(ActiveRadarrBlock::BlocklistSortPrompt, _)
) {
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.apply_sorting_toggle(false);
}
@@ -128,7 +128,7 @@ impl Network<'_, '_> {
app.get_current_route(),
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.apply_sorting_toggle(false);
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Network<'_, '_> {
Route::Radarr(ActiveRadarrBlock::HistorySortPrompt, _)
) {
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.apply_sorting_toggle(false);
}
+1 -1
View File
@@ -270,7 +270,7 @@ impl Network<'_, '_> {
app.get_current_route(),
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.apply_sorting_toggle(false);
}
+2 -5
View File
@@ -67,7 +67,7 @@ impl Network<'_, '_> {
let log_lines = logs
.into_iter()
.map(|log| {
if log.exception.is_some() {
if let Some(exception) = log.exception {
HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}",
log.time,
@@ -80,10 +80,7 @@ impl Network<'_, '_> {
.exception_type
.as_ref()
.expect("exception_type must exist when exception is present"),
log
.exception
.as_ref()
.expect("exception must exist in this branch")
exception
))
} else {
HorizontallyScrollableText::from(format!(
+1 -1
View File
@@ -102,7 +102,7 @@ impl Network<'_, '_> {
}
})
.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.apply_sorting_toggle(false);
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Network<'_, '_> {
Route::Sonarr(ActiveSonarrBlock::HistorySortPrompt, _)
) {
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.apply_sorting_toggle(false);
}
@@ -60,7 +60,7 @@ impl Network<'_, '_> {
self
.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!(
app.get_current_route(),
Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _)
@@ -151,7 +151,7 @@ impl Network<'_, '_> {
.get_or_insert_default();
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
@@ -158,7 +158,7 @@ impl Network<'_, '_> {
if !is_sorting {
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
@@ -315,7 +315,7 @@ impl Network<'_, '_> {
let series_history = app.data.sonarr_data.series_history.get_or_insert_default();
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.apply_sorting_toggle(false);
}
@@ -337,7 +337,7 @@ impl Network<'_, '_> {
app.get_current_route(),
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.apply_sorting_toggle(false);
}
+2 -5
View File
@@ -50,7 +50,7 @@ impl Network<'_, '_> {
let log_lines = logs
.into_iter()
.map(|log| {
if log.exception.is_some() {
if let Some(exception) = log.exception {
HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}",
log.time,
@@ -63,10 +63,7 @@ impl Network<'_, '_> {
.exception_type
.as_ref()
.expect("exception_type must exist when exception is present"),
log
.exception
.as_ref()
.expect("exception must exist in this branch")
exception
))
} else {
HorizontallyScrollableText::from(format!(
+18 -21
View File
@@ -52,33 +52,30 @@ impl DrawUi for IndexersUi {
_ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area),
Route::Lidarr(active_lidarr_block, _) => match active_lidarr_block {
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(
app.is_loading || app.data.lidarr_data.indexer_test_errors.is_none(),
title_block("Testing Indexer"),
))
.size(Size::LargeMessage);
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 => {
+4 -5
View File
@@ -107,9 +107,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
} else {
queued
};
let started_string = if event.started.is_some() {
let started =
convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes());
let started_string = if let Some(date_time) = event.started {
let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes());
if started != "now" {
format!("{started} ago")
@@ -120,8 +119,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
String::new()
};
let duration = if event.duration.is_some() {
&event.duration.as_ref().unwrap()[..8]
let duration = if let Some(dur) = &event.duration {
&dur[..8]
} else {
""
};
+18 -21
View File
@@ -52,33 +52,30 @@ impl DrawUi for IndexersUi {
_ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area),
Route::Radarr(active_radarr_block, _) => match active_radarr_block {
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(
app.is_loading || app.data.radarr_data.indexer_test_errors.is_none(),
title_block("Testing Indexer"),
))
.size(Size::LargeMessage);
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 => {
+2 -3
View File
@@ -114,9 +114,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
} else {
queued
};
let started_string = if event.started.is_some() {
let started =
convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes());
let started_string = if let Some(date_time) = event.started {
let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes());
if started != "now" {
format!("{started} ago")
+18 -21
View File
@@ -52,33 +52,30 @@ impl DrawUi for IndexersUi {
_ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area),
Route::Sonarr(active_sonarr_block, _) => match active_sonarr_block {
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(
app.is_loading || app.data.sonarr_data.indexer_test_errors.is_none(),
title_block("Testing Indexer"),
))
.size(Size::LargeMessage);
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 => {
+4 -5
View File
@@ -107,9 +107,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
} else {
queued
};
let started_string = if event.started.is_some() {
let started =
convert_to_minutes_hours_days(Utc::now().sub(event.started.unwrap()).num_minutes());
let started_string = if let Some(date_time) = event.started {
let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes());
if started != "now" {
format!("{started} ago")
@@ -120,8 +119,8 @@ pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rec
String::new()
};
let duration = if event.duration.is_some() {
&event.duration.as_ref().unwrap()[..8]
let duration = if let Some(dur) = &event.duration {
&dur[..8]
} else {
""
};
+7 -9
View File
@@ -125,11 +125,8 @@ where
if let Some(content) = self.content
&& !self.is_loading
{
let (table_contents, table_state) = if content.filtered_items.is_some() {
(
content.filtered_items.as_ref().unwrap(),
content.filtered_state.as_mut().unwrap(),
)
let (table_contents, table_state) = if let Some(items) = &content.filtered_items {
(items, content.filtered_state.as_mut().unwrap())
} else {
(&content.items, &mut content.state)
};
@@ -153,10 +150,11 @@ where
StatefulWidget::render(table, table_area, buf, table_state);
if content.sort.is_some() && self.is_sorting {
let selectable_list = SelectableList::new(content.sort.as_mut().unwrap(), |item| {
ListItem::new(Text::from(item.name))
});
if let Some(sort) = &mut content.sort
&& self.is_sorting
{
let selectable_list =
SelectableList::new(sort, |item| ListItem::new(Text::from(item.name)));
Popup::new(selectable_list)
.dimensions(20, 50)
.render(table_area, buf);
+55 -13
View File
@@ -10,7 +10,10 @@ use anyhow::{Context, anyhow};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
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::encode::pattern::PatternEncoder;
use regex::Regex;
@@ -47,12 +50,24 @@ pub fn get_log_path() -> PathBuf {
}
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(
"{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}",
)))
.build(get_log_path())
.unwrap();
.build(log_path, Box::new(policy))
.expect("Failed to build rolling file appender");
log4rs::Config::builder()
.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))
.with_context(|| "Unable to tail log file")?;
let mut lines = reader.lines();
tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let mut line_buf = String::new();
loop {
if let Some(Ok(line)) = lines.next() {
if no_color {
println!("{line}");
} else {
let colored_line = colorize_log_line(&line, &re);
println!("{colored_line}");
line_buf.clear();
match reader.read_line(&mut line_buf) {
Ok(0) => {
if was_log_rotated(&file_path, &mut reader) {
continue;
}
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?
}
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 {
if let Some(caps) = re.captures(line) {
let level = &caps["level"];
+55 -1
View File
@@ -1,8 +1,11 @@
#[cfg(test)]
mod tests {
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
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]
fn test_convert_to_gb() {
@@ -23,4 +26,55 @@ mod tests {
assert_eq!(hours, 2);
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();
}
}