Files
managarr/src/main.rs
T
Dark-Alex-17 29f043f5c7 feat: scaffold Readarr support across the state, app, handler, UI, and CLI layers
Promote Route::Readarr from a unit stub to a tuple variant and wire Readarr
through every layer, mirroring the Lidarr tree. Readarr appears as a fourth
servarr tab and its library view renders the Authors table.

Adds ReadarrData and its modals, the tick and context clue modules, the
top-level ReadarrHandler, ReadarrUi with the Authors library view, and an
empty ReadarrCommand behind ReadarrCliHandler. The default port is 8787.

Promoting the route variant compiles cleanly without any of the arms that
give it behaviour, so all six of those sites are wired explicitly here:
App::on_tick, handlers::handle_events, handle_prompt_toggle,
ServarrContextClueProvider, ui(), and verify_config_present_for_cli. Each was
checked by removing the arm and confirming its covering assertion then fails.

ReadarrEvent and ReadarrCommand are both declared empty. They gain variants
alongside the network calls and CLI commands that use them, which keeps each
later change to a single function.

Adding the tab changes the rendered tab bar, so 13 existing snapshots
regenerate. The only delta in each is that single tab bar line.

This scaffold adds 24 dead_code warnings, all inside the Readarr tree and
none outside it. They clear as the handlers, UI, network events, and CLI
commands that consume these items land: block arrays awaiting their accepts
implementations and BlockSelectionState seeding, modal and state fields
awaiting their handlers, the CLI handler fields awaiting an inhabited command
enum, and the top-level handler fields awaiting sub-handlers. The count must
strictly decrease from here, so a later increase is a defect rather than
transient noise. The 12 pre-existing collapsible_match warnings are unrelated
and untouched.
2026-08-20 15:28:49 -06:00

350 lines
9.4 KiB
Rust

#[cfg(test)]
#[macro_use]
extern crate assertables;
use anyhow::{Context, Result};
use clap::{
Args, CommandFactory, Parser, crate_authors, crate_description, crate_name, crate_version,
};
use clap_complete::generate;
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use indoc::indoc;
use log::{debug, error, warn};
use network::NetworkTrait;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use reqwest::Client;
use std::panic::PanicHookInfo;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{io, panic, process};
use tokio::select;
use tokio::sync::mpsc::Receiver;
use tokio::sync::{Mutex, mpsc};
use tokio_util::sync::CancellationToken;
use utils::{
build_network_client, load_config, start_cli_no_spinner, start_cli_with_spinner, tail_logs,
};
use crate::app::{App, log_and_print_error};
use crate::cli::Command;
use crate::event::Key;
use crate::event::input_event::{Events, InputEvent};
use crate::network::{Network, NetworkEvent};
use crate::ui::theme::{Theme, ThemeDefinitionsWrapper};
use crate::ui::{THEME, ui};
use crate::utils::load_theme_config;
mod app;
mod cli;
mod event;
mod handlers;
mod logos;
mod models;
mod network;
mod ui;
mod utils;
#[derive(Debug, Parser)]
#[command(
name = crate_name!(),
author = crate_authors!(),
version = crate_version!(),
about = crate_description!(),
help_template = "\
{before-help}{name} {version}
{author-with-newline}
{about-with-newline}
{usage-heading} {usage}
{all-args}{after-help}
"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
global: GlobalOpts,
}
#[derive(Args, Debug)]
#[command(next_help_heading = "Global Options")]
struct GlobalOpts {
#[arg(
long,
global = true,
env = "MANAGARR_DISABLE_SPINNER",
help = "Disable the spinner (can sometimes make parsing output challenging)"
)]
disable_spinner: bool,
#[arg(
long,
global = true,
value_parser,
env = "MANAGARR_CONFIG_FILE",
help = "The Managarr configuration file to use; defaults to the path shown by 'managarr config-path'"
)]
config_file: Option<PathBuf>,
#[arg(
long,
global = true,
value_parser,
env = "MANAGARR_THEMES_FILE",
help = "The Managarr themes file to use"
)]
themes_file: Option<PathBuf>,
#[arg(
long,
global = true,
value_parser,
env = "MANAGARR_THEME",
help = "The name of the Managarr theme to use"
)]
theme: Option<String>,
#[arg(
long,
global = true,
help = indoc!{"
For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used.
"}
)]
servarr_name: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
log4rs::init_config(utils::init_logging_config())?;
panic::set_hook(Box::new(|info| {
panic_hook(info);
}));
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
let args = Cli::parse();
let config_file_path = confy::get_configuration_file_path("managarr", "config")?;
let default_config_path = config_file_path.display().to_string();
if matches!(args.command, Some(Command::ConfigPath)) {
println!("{default_config_path}");
return Ok(());
}
let (mut config, config_path) = if let Some(ref config_file) = args.global.config_file {
(
load_config(config_file.to_str().expect("Invalid config file specified"))?,
config_file.display().to_string(),
)
} else {
(
confy::load("managarr", "config")
.with_context(|| format!("Config file at '{default_config_path}' is invalid"))?,
default_config_path,
)
};
let theme_name = config.theme.clone();
let spinner_disabled = args.global.disable_spinner;
debug!("Managarr loaded using config: {config:?}");
config.validate(&config_path);
config.post_process_initialization();
let reqwest_client = build_network_client(&config);
let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
let cancellation_token = CancellationToken::new();
let ctrlc_cancellation_token = cancellation_token.clone();
ctrlc::set_handler(move || {
ctrlc_cancellation_token.cancel();
r.store(false, Ordering::SeqCst);
process::exit(1);
})
.expect("Error setting Ctrl-C handler");
let app = Arc::new(Mutex::new(App::new(
sync_network_tx,
config.clone(),
cancellation_token.clone(),
)));
match args.command {
Some(command) => match command {
Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) | Command::Readarr(_) => {
if spinner_disabled {
start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await;
} else {
start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await;
}
}
Command::Completions { shell } => {
let mut cli = Cli::command();
generate(shell, &mut cli, "managarr", &mut io::stdout())
}
Command::TailLogs { no_color } => tail_logs(no_color).await?,
Command::ConfigPath => {
unreachable!(
"ConfigPath command is handled before this match and should be unreachable here"
);
}
},
None => {
let app_nw = Arc::clone(&app);
std::thread::spawn(move || {
start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client)
});
start_ui(
&app,
&args.global.themes_file,
args.global.theme.unwrap_or(theme_name.unwrap_or_default()),
)
.await?;
}
}
Ok(())
}
#[tokio::main]
async fn start_networking(
mut network_rx: Receiver<NetworkEvent>,
app: &Arc<Mutex<App<'_>>>,
cancellation_token: CancellationToken,
client: Client,
) {
let mut network = Network::new(app, cancellation_token, client);
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;
}
}
}
}
async fn start_ui(
app: &Arc<Mutex<App<'_>>>,
themes_file_arg: &Option<PathBuf>,
theme_name: String,
) -> Result<()> {
let theme_definitions_wrapper = if let Some(theme_file) = themes_file_arg {
load_theme_config(theme_file.to_str().expect("Invalid theme file specified"))?
} else {
confy::load("managarr", "themes").unwrap_or_else(|_| ThemeDefinitionsWrapper::default())
};
let theme = if !theme_name.is_empty() {
let theme_definition = theme_definitions_wrapper
.theme_definitions
.iter()
.find(|t| t.name == theme_name);
if theme_definition.is_none() {
log_and_print_error(format!("The specified theme was not found: {theme_name}"));
process::exit(1);
}
theme_definition.unwrap().theme
} else {
debug!("No theme specified, using default theme");
Theme::default()
};
debug!("Managarr loaded using theme: {theme:?}");
theme.validate();
THEME.set(theme);
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
terminal.hide_cursor()?;
let input_events = Events::new();
loop {
{
let mut app = app.lock().await;
terminal.draw(|f| ui(f, &mut app))?;
}
match input_events.next()? {
Some(InputEvent::KeyEvent(key)) => {
let mut app = app.lock().await;
if key == Key::Char('q') && !app.ignore_special_keys_for_textbox_input {
break;
}
handlers::handle_events(key, &mut app);
}
Some(InputEvent::Tick) => {
let mut app = app.lock().await;
app.on_tick().await;
}
None => break,
}
}
terminal.show_cursor()?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
#[cfg(debug_assertions)]
fn panic_hook(info: &PanicHookInfo<'_>) {
use backtrace::Backtrace;
use crossterm::style::Print;
let location = info.location().unwrap();
let msg = match info.payload().downcast_ref::<&'static str>() {
Some(s) => *s,
None => match info.payload().downcast_ref::<String>() {
Some(s) => &s[..],
None => "Box<Any>",
},
};
let stacktrace: String = format!("{:?}", Backtrace::new()).replace('\n', "\n\r");
disable_raw_mode().unwrap();
execute!(
io::stdout(),
LeaveAlternateScreen,
Print(format!(
"thread '<unnamed>' panicked at '{msg}', {location}\n\r{stacktrace}"
)),
)
.unwrap();
}
#[cfg(not(debug_assertions))]
fn panic_hook(info: &PanicHookInfo<'_>) {
use human_panic::{handle_dump, metadata, print_msg};
let meta = metadata!();
let file_path = handle_dump(&meta, info);
disable_raw_mode().unwrap();
execute!(io::stdout(), LeaveAlternateScreen).unwrap();
print_msg(file_path, &meta).expect("human-panic: printing error message to console failed");
}