Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions[bot] a26444006b chore: bump Cargo.toml to 0.7.3
Check / stable / fmt (push) Failing after 27s
Check / beta / clippy (push) Failing after 1m13s
Check / stable / clippy (push) Failing after 1m15s
Check / nightly / doc (push) Successful in 1m3s
Check / 1.95.0 / check (push) Successful in 1m9s
Test Suite / ubuntu / beta (push) Successful in 1m47s
Test Suite / ubuntu / stable (push) Successful in 1m47s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m24s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-06-25 20:58:11 +00:00
github-actions[bot] ccf3e28323 bump: version 0.7.2 → 0.7.3 [skip ci] 2026-06-25 20:58:10 +00:00
Alex Clarke 6eea6b92fb Merge pull request #62 from Dark-Alex-17/develop
Security fixes, rolling logs, and Rust version upgrade
2026-06-25 14:37:16 -06:00
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
49 changed files with 755 additions and 805 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
+21
View File
@@ -5,6 +5,27 @@ 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.3 (2026-06-25)
### Feat
- Implemented log rolling so the log file doesn't just grow exponentially [#60]
### Fix
- addressed code review comments
- tail-logs subcommand follows log rollovers and sleeps to minimize idle CPU loops
### Refactor
- Refactored several usages of sort_by_key and match guards to utilize newer Rust version APIs
## 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
+364 -527
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.3"
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,10 +506,10 @@ 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)
&& matches_key!(confirm, self.key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
@@ -518,7 +518,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditIndexerHandler<'
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -106,11 +106,11 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for IndexerSettingsHandl
indexer_settings.maximum_size -= 1;
}
}
ActiveLidarrBlock::IndexerSettingsRssSyncIntervalInput => {
if indexer_settings.rss_sync_interval > 0 {
ActiveLidarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 =>
{
indexer_settings.rss_sync_interval -= 1;
}
}
_ => (),
}
}
@@ -591,17 +591,16 @@ 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)
&& 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,8 +293,9 @@ 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() {
_ 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()));
@@ -303,7 +304,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into());
}
}
_ => (),
},
ActiveLidarrBlock::ArtistHistory | ActiveLidarrBlock::ManualArtistSearch => match self.key {
@@ -428,10 +428,10 @@ 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)
&& matches_key!(confirm, key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
@@ -440,7 +440,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditArtistHandler<'a
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -505,10 +505,10 @@ 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)
&& matches_key!(confirm, key) =>
{
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::AddRootFolder(
@@ -518,7 +518,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddRootFolderHandler
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,10 +354,10 @@ 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)
&& matches_key!(confirm, key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(
@@ -367,7 +367,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -507,10 +507,10 @@ 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)
&& matches_key!(confirm, self.key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
@@ -519,7 +519,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<'
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -114,11 +114,11 @@ 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 {
ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 =>
{
indexer_settings.rss_sync_interval -= 1;
}
}
_ => (),
}
}
@@ -272,10 +272,10 @@ 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)
&& matches_key!(confirm, self.key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(
@@ -285,7 +285,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -539,17 +539,16 @@ 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)
&& 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,10 +376,10 @@ 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)
&& matches_key!(confirm, key) =>
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
@@ -388,7 +388,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a,
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -506,10 +506,10 @@ 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)
&& matches_key!(confirm, self.key) =>
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
@@ -518,7 +518,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditIndexerHandler<'
self.app.pop_navigation_stack();
}
}
_ => (),
}
}
@@ -106,11 +106,11 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexerSettingsHandl
indexer_settings.maximum_size -= 1;
}
}
ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput => {
if indexer_settings.rss_sync_interval > 0 {
ActiveSonarrBlock::IndexerSettingsRssSyncIntervalInput
if indexer_settings.rss_sync_interval > 0 =>
{
indexer_settings.rss_sync_interval -= 1;
}
}
_ => (),
}
}
@@ -606,17 +606,16 @@ 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)
&& 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,10 +450,10 @@ 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)
&& matches_key!(confirm, key) =>
{
self.app.data.sonarr_data.prompt_confirm = true;
self.app.data.sonarr_data.prompt_confirm_action =
@@ -462,7 +462,6 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a
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!(
+11 -14
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() {
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
if let Some(result) = app
.data
.lidarr_data
.indexer_test_errors
.as_ref()
.expect("Test result is unpopulated");
if !result.is_empty() {
.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());
}
}
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 {
""
};
+11 -14
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() {
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
if let Some(result) = app
.data
.radarr_data
.indexer_test_errors
.as_ref()
.expect("Test result is unpopulated");
if !result.is_empty() {
.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());
}
}
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")
+11 -14
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() {
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
if let Some(result) = app
.data
.sonarr_data
.indexer_test_errors
.as_ref()
.expect("Test result is unpopulated");
if !result.is_empty() {
.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());
}
}
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);
+51 -9
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,23 +104,50 @@ 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() {
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);
let colored_line = colorize_log_line(line, &re);
println!("{colored_line}");
}
}
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
}
}
}
})
.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();
}
}