1901 lines
60 KiB
Rust
1901 lines
60 KiB
Rust
use crossterm::{
|
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use ratatui::{
|
|
backend::{Backend, CrosstermBackend},
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{
|
|
Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState,
|
|
Tabs, Wrap,
|
|
},
|
|
Frame, Terminal,
|
|
};
|
|
use std::{
|
|
io,
|
|
time::{Duration, Instant},
|
|
};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::config::{AppConfig, KeybindMode};
|
|
use yarr_api::{
|
|
Episode, HealthResource, HistoryItem, QueueItem, ReleaseResource, Series, SonarrClient,
|
|
SystemStatus,
|
|
};
|
|
|
|
#[derive(Debug)]
|
|
pub enum AppEvent {
|
|
Tick,
|
|
Key(crossterm::event::KeyEvent),
|
|
Error(String),
|
|
SeriesLoaded(Vec<Series>),
|
|
#[allow(dead_code)]
|
|
EpisodesLoaded(Vec<Episode>),
|
|
QueueLoaded(Vec<QueueItem>),
|
|
HistoryLoaded(Vec<HistoryItem>),
|
|
SystemStatusLoaded(SystemStatus),
|
|
HealthLoaded(Vec<HealthResource>),
|
|
CalendarLoaded(Vec<Episode>),
|
|
SearchResults(Vec<Series>),
|
|
ReleasesLoaded(Vec<ReleaseResource>),
|
|
ReleaseDownloaded(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum TabIndex {
|
|
Series,
|
|
Search,
|
|
Calendar,
|
|
Queue,
|
|
History,
|
|
Health,
|
|
Settings,
|
|
}
|
|
|
|
impl TabIndex {
|
|
fn titles() -> Vec<&'static str> {
|
|
vec![
|
|
"Series", "Search", "Calendar", "Queue", "History", "Health", "Settings",
|
|
]
|
|
}
|
|
|
|
fn from_index(index: usize) -> Self {
|
|
match index {
|
|
0 => TabIndex::Series,
|
|
1 => TabIndex::Search,
|
|
2 => TabIndex::Calendar,
|
|
3 => TabIndex::Queue,
|
|
4 => TabIndex::History,
|
|
5 => TabIndex::Health,
|
|
6 => TabIndex::Settings,
|
|
_ => TabIndex::Series,
|
|
}
|
|
}
|
|
|
|
fn to_index(&self) -> usize {
|
|
match self {
|
|
TabIndex::Series => 0,
|
|
TabIndex::Search => 1,
|
|
TabIndex::Calendar => 2,
|
|
TabIndex::Queue => 3,
|
|
TabIndex::History => 4,
|
|
TabIndex::Health => 5,
|
|
TabIndex::Settings => 6,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct App {
|
|
pub should_quit: bool,
|
|
pub current_tab: TabIndex,
|
|
pub series_list_state: ListState,
|
|
pub episodes_list_state: ListState,
|
|
pub queue_table_state: TableState,
|
|
pub history_table_state: TableState,
|
|
pub health_list_state: ListState,
|
|
pub search_list_state: ListState,
|
|
pub settings_list_state: ListState,
|
|
pub releases_list_state: ListState,
|
|
pub series: Vec<Series>,
|
|
pub episodes: Vec<Episode>,
|
|
pub queue: Vec<QueueItem>,
|
|
pub history: Vec<HistoryItem>,
|
|
pub health: Vec<HealthResource>,
|
|
pub calendar: Vec<Episode>,
|
|
pub system_status: Option<SystemStatus>,
|
|
#[allow(dead_code)]
|
|
pub selected_series: Option<Series>,
|
|
pub loading: bool,
|
|
pub error_message: Option<String>,
|
|
pub search_input: String,
|
|
pub search_results: Vec<Series>,
|
|
pub search_mode: bool,
|
|
pub show_details: bool,
|
|
pub input_mode: bool,
|
|
pub config: AppConfig,
|
|
pub config_changed: bool,
|
|
pub editing_url: bool,
|
|
pub editing_api_key: bool,
|
|
pub url_input: String,
|
|
pub api_key_input: String,
|
|
pub show_releases_popup: bool,
|
|
pub releases: Vec<ReleaseResource>,
|
|
pub selected_search_series: Option<Series>,
|
|
pub loading_releases: bool,
|
|
}
|
|
|
|
impl Default for App {
|
|
fn default() -> Self {
|
|
let mut app = Self {
|
|
should_quit: false,
|
|
current_tab: TabIndex::Series,
|
|
series_list_state: ListState::default(),
|
|
episodes_list_state: ListState::default(),
|
|
queue_table_state: TableState::default(),
|
|
history_table_state: TableState::default(),
|
|
health_list_state: ListState::default(),
|
|
search_list_state: ListState::default(),
|
|
settings_list_state: ListState::default(),
|
|
releases_list_state: ListState::default(),
|
|
series: Vec::new(),
|
|
episodes: Vec::new(),
|
|
queue: Vec::new(),
|
|
history: Vec::new(),
|
|
health: Vec::new(),
|
|
calendar: Vec::new(),
|
|
system_status: None,
|
|
selected_series: None,
|
|
loading: false,
|
|
error_message: None,
|
|
search_input: String::new(),
|
|
search_results: Vec::new(),
|
|
search_mode: false,
|
|
show_details: false,
|
|
input_mode: false,
|
|
config: AppConfig::default(),
|
|
config_changed: false,
|
|
editing_url: false,
|
|
editing_api_key: false,
|
|
url_input: String::new(),
|
|
api_key_input: String::new(),
|
|
show_releases_popup: false,
|
|
releases: Vec::new(),
|
|
selected_search_series: None,
|
|
loading_releases: false,
|
|
};
|
|
app.series_list_state.select(Some(0));
|
|
app.settings_list_state.select(Some(0));
|
|
app.releases_list_state.select(Some(0));
|
|
app
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
pub fn next_tab(&mut self) {
|
|
let current_index = self.current_tab.to_index();
|
|
let next_index = (current_index + 1) % TabIndex::titles().len();
|
|
self.current_tab = TabIndex::from_index(next_index);
|
|
}
|
|
|
|
pub fn previous_tab(&mut self) {
|
|
let current_index = self.current_tab.to_index();
|
|
let prev_index = if current_index == 0 {
|
|
TabIndex::titles().len() - 1
|
|
} else {
|
|
current_index - 1
|
|
};
|
|
self.current_tab = TabIndex::from_index(prev_index);
|
|
}
|
|
|
|
pub fn next_item(&mut self) {
|
|
if self.show_releases_popup {
|
|
self.next_release();
|
|
return;
|
|
}
|
|
|
|
match self.current_tab {
|
|
TabIndex::Series => {
|
|
let len = self.series.len();
|
|
if len > 0 {
|
|
let i = match self.series_list_state.selected() {
|
|
Some(i) => (i + 1) % len,
|
|
None => 0,
|
|
};
|
|
self.series_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Calendar => {
|
|
let len = self.calendar.len();
|
|
if len > 0 {
|
|
let i = match self.episodes_list_state.selected() {
|
|
Some(i) => (i + 1) % len,
|
|
None => 0,
|
|
};
|
|
self.episodes_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Queue => {
|
|
let len = self.queue.len();
|
|
if len > 0 {
|
|
let i = match self.queue_table_state.selected() {
|
|
Some(i) => (i + 1) % len,
|
|
None => 0,
|
|
};
|
|
self.queue_table_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::History => {
|
|
let len = self.history.len();
|
|
if len > 0 {
|
|
let i = match self.history_table_state.selected() {
|
|
Some(i) => (i + 1) % len,
|
|
None => 0,
|
|
};
|
|
self.history_table_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Health => {
|
|
let len = self.health.len();
|
|
if len > 0 {
|
|
let i = match self.health_list_state.selected() {
|
|
Some(i) => (i + 1) % len,
|
|
None => 0,
|
|
};
|
|
self.health_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Search => {
|
|
let len = self.search_results.len();
|
|
if len > 0 {
|
|
let i = match self.search_list_state.selected() {
|
|
Some(i) => (i + 1) % len,
|
|
None => 0,
|
|
};
|
|
self.search_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Settings => {
|
|
self.next_settings_item();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn previous_item(&mut self) {
|
|
if self.show_releases_popup {
|
|
self.previous_release();
|
|
return;
|
|
}
|
|
|
|
match self.current_tab {
|
|
TabIndex::Series => {
|
|
let len = self.series.len();
|
|
if len > 0 {
|
|
let i = match self.series_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
len - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.series_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Calendar => {
|
|
let len = self.calendar.len();
|
|
if len > 0 {
|
|
let i = match self.episodes_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
len - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.episodes_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Queue => {
|
|
let len = self.queue.len();
|
|
if len > 0 {
|
|
let i = match self.queue_table_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
len - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.queue_table_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::History => {
|
|
let len = self.history.len();
|
|
if len > 0 {
|
|
let i = match self.history_table_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
len - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.history_table_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Health => {
|
|
let len = self.health.len();
|
|
if len > 0 {
|
|
let i = match self.health_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
len - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.health_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Search => {
|
|
let len = self.search_results.len();
|
|
if len > 0 {
|
|
let i = match self.search_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
len - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.search_list_state.select(Some(i));
|
|
}
|
|
}
|
|
TabIndex::Settings => {
|
|
self.previous_settings_item();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn get_selected_series(&self) -> Option<&Series> {
|
|
if let Some(index) = self.series_list_state.selected() {
|
|
self.series.get(index)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn get_selected_search_result(&self) -> Option<&Series> {
|
|
if let Some(index) = self.search_list_state.selected() {
|
|
self.search_results.get(index)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn get_selected_release(&self) -> Option<&ReleaseResource> {
|
|
if let Some(index) = self.releases_list_state.selected() {
|
|
self.releases.get(index)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn show_releases_popup(&mut self, series: Series) {
|
|
self.show_releases_popup = true;
|
|
self.selected_search_series = Some(series);
|
|
self.releases.clear();
|
|
self.releases_list_state.select(Some(0));
|
|
self.loading_releases = true;
|
|
}
|
|
|
|
pub fn hide_releases_popup(&mut self) {
|
|
self.show_releases_popup = false;
|
|
self.selected_search_series = None;
|
|
self.releases.clear();
|
|
self.loading_releases = false;
|
|
}
|
|
|
|
pub fn next_release(&mut self) {
|
|
if !self.releases.is_empty() {
|
|
let i = match self.releases_list_state.selected() {
|
|
Some(i) => (i + 1) % self.releases.len(),
|
|
None => 0,
|
|
};
|
|
self.releases_list_state.select(Some(i));
|
|
}
|
|
}
|
|
|
|
pub fn previous_release(&mut self) {
|
|
if !self.releases.is_empty() {
|
|
let i = match self.releases_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
self.releases.len() - 1
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.releases_list_state.select(Some(i));
|
|
}
|
|
}
|
|
|
|
pub fn enter_search_mode(&mut self) {
|
|
self.search_mode = true;
|
|
self.input_mode = true;
|
|
self.search_input.clear();
|
|
self.search_results.clear();
|
|
self.search_list_state.select(None);
|
|
}
|
|
|
|
pub fn exit_search_mode(&mut self) {
|
|
self.search_mode = false;
|
|
self.input_mode = false;
|
|
self.search_input.clear();
|
|
self.search_results.clear();
|
|
}
|
|
|
|
pub fn toggle_details(&mut self) {
|
|
self.show_details = !self.show_details;
|
|
}
|
|
|
|
pub fn clear_error(&mut self) {
|
|
self.error_message = None;
|
|
}
|
|
|
|
pub fn set_error(&mut self, error: String) {
|
|
self.error_message = Some(error);
|
|
}
|
|
|
|
pub fn go_to_first(&mut self) {
|
|
match self.current_tab {
|
|
TabIndex::Series => {
|
|
if !self.series.is_empty() {
|
|
self.series_list_state.select(Some(0));
|
|
}
|
|
}
|
|
TabIndex::Calendar => {
|
|
if !self.calendar.is_empty() {
|
|
self.episodes_list_state.select(Some(0));
|
|
}
|
|
}
|
|
TabIndex::Queue => {
|
|
if !self.queue.is_empty() {
|
|
self.queue_table_state.select(Some(0));
|
|
}
|
|
}
|
|
TabIndex::History => {
|
|
if !self.history.is_empty() {
|
|
self.history_table_state.select(Some(0));
|
|
}
|
|
}
|
|
TabIndex::Health => {
|
|
if !self.health.is_empty() {
|
|
self.health_list_state.select(Some(0));
|
|
}
|
|
}
|
|
TabIndex::Search => {
|
|
if !self.search_results.is_empty() {
|
|
self.search_list_state.select(Some(0));
|
|
}
|
|
}
|
|
TabIndex::Settings => {
|
|
self.settings_list_state.select(Some(0));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn go_to_last(&mut self) {
|
|
match self.current_tab {
|
|
TabIndex::Series => {
|
|
if !self.series.is_empty() {
|
|
self.series_list_state.select(Some(self.series.len() - 1));
|
|
}
|
|
}
|
|
TabIndex::Calendar => {
|
|
if !self.calendar.is_empty() {
|
|
self.episodes_list_state
|
|
.select(Some(self.calendar.len() - 1));
|
|
}
|
|
}
|
|
TabIndex::Queue => {
|
|
if !self.queue.is_empty() {
|
|
self.queue_table_state.select(Some(self.queue.len() - 1));
|
|
}
|
|
}
|
|
TabIndex::History => {
|
|
if !self.history.is_empty() {
|
|
self.history_table_state
|
|
.select(Some(self.history.len() - 1));
|
|
}
|
|
}
|
|
TabIndex::Health => {
|
|
if !self.health.is_empty() {
|
|
self.health_list_state.select(Some(self.health.len() - 1));
|
|
}
|
|
}
|
|
TabIndex::Search => {
|
|
if !self.search_results.is_empty() {
|
|
self.search_list_state
|
|
.select(Some(self.search_results.len() - 1));
|
|
}
|
|
}
|
|
TabIndex::Settings => {
|
|
self.settings_list_state.select(Some(4)); // Total settings options - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_keybind_mode(&mut self) {
|
|
self.config.ui.keybind_mode = match self.config.ui.keybind_mode {
|
|
KeybindMode::Normal => KeybindMode::Vim,
|
|
KeybindMode::Vim => KeybindMode::Normal,
|
|
};
|
|
self.config_changed = true;
|
|
}
|
|
|
|
pub fn toggle_help(&mut self) {
|
|
self.config.ui.show_help = !self.config.ui.show_help;
|
|
self.config_changed = true;
|
|
}
|
|
|
|
pub fn handle_settings_input(&mut self) {
|
|
if let Some(selected) = self.settings_list_state.selected() {
|
|
match selected {
|
|
0 => self.toggle_keybind_mode(),
|
|
1 => self.toggle_help(),
|
|
2 => self.start_editing_url(),
|
|
3 => self.start_editing_api_key(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn start_editing_url(&mut self) {
|
|
self.editing_url = true;
|
|
self.input_mode = true;
|
|
self.url_input = self.config.sonarr.url.clone();
|
|
}
|
|
|
|
pub fn start_editing_api_key(&mut self) {
|
|
self.editing_api_key = true;
|
|
self.input_mode = true;
|
|
self.api_key_input = self.config.sonarr.api_key.clone();
|
|
}
|
|
|
|
pub fn finish_editing_url(&mut self) {
|
|
let trimmed_url = self.url_input.trim();
|
|
if !trimmed_url.is_empty() {
|
|
// Basic URL validation
|
|
if trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://") {
|
|
self.config.sonarr.url = trimmed_url.to_string();
|
|
self.config_changed = true;
|
|
} else {
|
|
self.set_error("URL must start with http:// or https://".to_string());
|
|
}
|
|
}
|
|
self.editing_url = false;
|
|
self.input_mode = false;
|
|
self.url_input.clear();
|
|
}
|
|
|
|
pub fn finish_editing_api_key(&mut self) {
|
|
if !self.api_key_input.trim().is_empty() {
|
|
self.config.sonarr.api_key = self.api_key_input.trim().to_string();
|
|
self.config_changed = true;
|
|
}
|
|
self.editing_api_key = false;
|
|
self.input_mode = false;
|
|
self.api_key_input.clear();
|
|
}
|
|
|
|
pub fn cancel_editing(&mut self) {
|
|
self.editing_url = false;
|
|
self.editing_api_key = false;
|
|
self.input_mode = false;
|
|
self.url_input.clear();
|
|
self.api_key_input.clear();
|
|
}
|
|
|
|
pub fn next_settings_item(&mut self) {
|
|
let i = match self.settings_list_state.selected() {
|
|
Some(i) => {
|
|
if i >= 4 {
|
|
0
|
|
} else {
|
|
i + 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.settings_list_state.select(Some(i));
|
|
}
|
|
|
|
pub fn previous_settings_item(&mut self) {
|
|
let i = match self.settings_list_state.selected() {
|
|
Some(i) => {
|
|
if i == 0 {
|
|
4
|
|
} else {
|
|
i - 1
|
|
}
|
|
}
|
|
None => 0,
|
|
};
|
|
self.settings_list_state.select(Some(i));
|
|
}
|
|
|
|
pub fn save_config(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
|
if !self.config_changed {
|
|
return Ok(());
|
|
}
|
|
|
|
// Find a writable config path
|
|
let config_paths = crate::config::AppConfig::get_default_config_paths();
|
|
let config_path = if let Some(path) = config_paths.get(1) {
|
|
// Prefer user config directory
|
|
path.clone()
|
|
} else {
|
|
std::env::current_dir()?.join("yarr.toml")
|
|
};
|
|
|
|
// Create directory if it doesn't exist
|
|
if let Some(parent) = config_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
// Save the config
|
|
let toml_content = toml::to_string_pretty(&self.config)?;
|
|
std::fs::write(&config_path, toml_content)?;
|
|
|
|
self.config_changed = false;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub async fn run_app(
|
|
client: SonarrClient,
|
|
config: AppConfig,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
// Setup terminal
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
// Create app and run it
|
|
let mut app = App::default();
|
|
app.config = config;
|
|
let res = run_tui(&mut terminal, &mut app, client).await;
|
|
|
|
// Restore terminal
|
|
disable_raw_mode()?;
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
res
|
|
}
|
|
|
|
async fn run_tui<B: Backend>(
|
|
terminal: &mut Terminal<B>,
|
|
app: &mut App,
|
|
client: SonarrClient,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
// Spawn task to handle keyboard input
|
|
let tx_input = tx.clone();
|
|
tokio::spawn(async move {
|
|
let mut last_tick = Instant::now();
|
|
let tick_rate = Duration::from_millis(250);
|
|
|
|
loop {
|
|
let timeout = tick_rate
|
|
.checked_sub(last_tick.elapsed())
|
|
.unwrap_or_else(|| Duration::from_secs(0));
|
|
|
|
if event::poll(timeout).unwrap() {
|
|
if let Event::Key(key) = event::read().unwrap() {
|
|
if key.kind == KeyEventKind::Press {
|
|
if tx_input.send(AppEvent::Key(key)).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if last_tick.elapsed() >= tick_rate {
|
|
if tx_input.send(AppEvent::Tick).is_err() {
|
|
return;
|
|
}
|
|
last_tick = Instant::now();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load initial data
|
|
load_all_data(client.clone(), tx.clone()).await;
|
|
|
|
// Main event loop
|
|
while !app.should_quit {
|
|
terminal.draw(|f| ui(f, app))?;
|
|
|
|
if let Some(event) = rx.recv().await {
|
|
match event {
|
|
AppEvent::Key(key) => {
|
|
handle_input(app, key, client.clone(), tx.clone()).await;
|
|
}
|
|
AppEvent::Tick => {
|
|
// Handle periodic updates if needed
|
|
}
|
|
AppEvent::Error(err) => {
|
|
app.set_error(err);
|
|
app.loading = false;
|
|
}
|
|
AppEvent::SeriesLoaded(series) => {
|
|
app.series = series;
|
|
app.loading = false;
|
|
if !app.series.is_empty() && app.series_list_state.selected().is_none() {
|
|
app.series_list_state.select(Some(0));
|
|
}
|
|
}
|
|
AppEvent::EpisodesLoaded(episodes) => {
|
|
app.episodes = episodes;
|
|
app.loading = false;
|
|
}
|
|
AppEvent::QueueLoaded(queue) => {
|
|
app.queue = queue;
|
|
app.loading = false;
|
|
}
|
|
AppEvent::HistoryLoaded(history) => {
|
|
app.history = history;
|
|
app.loading = false;
|
|
}
|
|
AppEvent::SystemStatusLoaded(status) => {
|
|
app.system_status = Some(status);
|
|
app.loading = false;
|
|
}
|
|
AppEvent::HealthLoaded(health) => {
|
|
app.health = health;
|
|
app.loading = false;
|
|
}
|
|
AppEvent::CalendarLoaded(calendar) => {
|
|
app.calendar = calendar;
|
|
app.loading = false;
|
|
}
|
|
AppEvent::SearchResults(results) => {
|
|
app.search_results = results;
|
|
app.loading = false;
|
|
if !app.search_results.is_empty() {
|
|
app.search_list_state.select(Some(0));
|
|
}
|
|
}
|
|
AppEvent::ReleasesLoaded(releases) => {
|
|
app.releases = releases;
|
|
app.loading_releases = false;
|
|
if !app.releases.is_empty() {
|
|
app.releases_list_state.select(Some(0));
|
|
}
|
|
}
|
|
AppEvent::ReleaseDownloaded(title) => {
|
|
app.hide_releases_popup();
|
|
app.set_error(format!("Successfully started download: {}", title));
|
|
app.loading = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_all_data(client: SonarrClient, tx: mpsc::UnboundedSender<AppEvent>) {
|
|
// Load system status
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.get_system_status().await {
|
|
Ok(status) => {
|
|
let _ = tx_clone.send(AppEvent::SystemStatusLoaded(status));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!(
|
|
"Failed to load system status: {}",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load series
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.get_series().await {
|
|
Ok(series) => {
|
|
let _ = tx_clone.send(AppEvent::SeriesLoaded(series));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!("Failed to load series: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load queue
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.get_queue().await {
|
|
Ok(queue_data) => {
|
|
let _ = tx_clone.send(AppEvent::QueueLoaded(queue_data.records));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!("Failed to load queue: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load history
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.get_history().await {
|
|
Ok(history_data) => {
|
|
let _ = tx_clone.send(AppEvent::HistoryLoaded(history_data.records));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!("Failed to load history: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load health
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.get_health().await {
|
|
Ok(health) => {
|
|
let _ = tx_clone.send(AppEvent::HealthLoaded(health));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!("Failed to load health: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load calendar (upcoming episodes)
|
|
let tx_clone = tx;
|
|
tokio::spawn(async move {
|
|
let start = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
|
let end = (chrono::Utc::now() + chrono::Duration::days(7))
|
|
.format("%Y-%m-%d")
|
|
.to_string();
|
|
|
|
match client.get_calendar(Some(&start), Some(&end)).await {
|
|
Ok(calendar) => {
|
|
let _ = tx_clone.send(AppEvent::CalendarLoaded(calendar));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!("Failed to load calendar: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn handle_input(
|
|
app: &mut App,
|
|
key: crossterm::event::KeyEvent,
|
|
client: SonarrClient,
|
|
tx: mpsc::UnboundedSender<AppEvent>,
|
|
) {
|
|
if app.input_mode {
|
|
handle_input_mode(app, key, client, tx).await;
|
|
} else {
|
|
match app.config.ui.keybind_mode {
|
|
KeybindMode::Normal => handle_normal_mode(app, key, client, tx).await,
|
|
KeybindMode::Vim => handle_vim_mode(app, key, client, tx).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_input_mode(
|
|
app: &mut App,
|
|
key: crossterm::event::KeyEvent,
|
|
client: SonarrClient,
|
|
tx: mpsc::UnboundedSender<AppEvent>,
|
|
) {
|
|
match key.code {
|
|
KeyCode::Enter => {
|
|
if app.editing_url {
|
|
app.finish_editing_url();
|
|
} else if app.editing_api_key {
|
|
app.finish_editing_api_key();
|
|
} else if !app.search_input.is_empty() {
|
|
app.loading = true;
|
|
let search_term = app.search_input.clone();
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.search_series(&search_term).await {
|
|
Ok(results) => {
|
|
let _ = tx_clone.send(AppEvent::SearchResults(results));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
app.input_mode = false;
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
if app.editing_url || app.editing_api_key {
|
|
app.cancel_editing();
|
|
} else {
|
|
app.exit_search_mode();
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
if app.editing_url {
|
|
app.url_input.push(c);
|
|
} else if app.editing_api_key {
|
|
app.api_key_input.push(c);
|
|
} else {
|
|
app.search_input.push(c);
|
|
}
|
|
}
|
|
KeyCode::Backspace => {
|
|
if app.editing_url {
|
|
app.url_input.pop();
|
|
} else if app.editing_api_key {
|
|
app.api_key_input.pop();
|
|
} else {
|
|
app.search_input.pop();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
async fn handle_normal_mode(
|
|
app: &mut App,
|
|
key: crossterm::event::KeyEvent,
|
|
client: SonarrClient,
|
|
tx: mpsc::UnboundedSender<AppEvent>,
|
|
) {
|
|
match key.code {
|
|
KeyCode::Char('q') => app.should_quit = true,
|
|
KeyCode::Tab => app.next_tab(),
|
|
KeyCode::BackTab => app.previous_tab(),
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.current_tab == TabIndex::Settings {
|
|
app.next_settings_item();
|
|
} else {
|
|
app.next_item();
|
|
}
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
if app.current_tab == TabIndex::Settings {
|
|
app.previous_settings_item();
|
|
} else {
|
|
app.previous_item();
|
|
}
|
|
}
|
|
KeyCode::Char('d') => app.toggle_details(),
|
|
KeyCode::Char('r') => {
|
|
app.loading = true;
|
|
app.clear_error();
|
|
tokio::spawn(async move {
|
|
load_all_data(client, tx).await;
|
|
});
|
|
}
|
|
KeyCode::Char('/') => {
|
|
if app.current_tab == TabIndex::Search {
|
|
app.enter_search_mode();
|
|
}
|
|
}
|
|
KeyCode::Char('s') => {
|
|
if let Err(e) = app.save_config() {
|
|
app.set_error(format!("Failed to save config: {}", e));
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.show_releases_popup {
|
|
// Download the selected release
|
|
if let Some(release) = app.get_selected_release().cloned() {
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
match client_clone.download_release(&release).await {
|
|
Ok(()) => {
|
|
let title = release.title.unwrap_or_default();
|
|
let _ = tx_clone.send(AppEvent::ReleaseDownloaded(title));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone
|
|
.send(AppEvent::Error(format!("Download failed: {}", e)));
|
|
}
|
|
}
|
|
});
|
|
app.hide_releases_popup();
|
|
}
|
|
} else if app.current_tab == TabIndex::Search && !app.search_results.is_empty() {
|
|
// Show releases popup for selected search result
|
|
if let Some(selected_series) = app.get_selected_search_result().cloned() {
|
|
app.show_releases_popup(selected_series.clone());
|
|
let client_clone = client.clone();
|
|
let tx_clone = tx.clone();
|
|
let series_id = selected_series.id.unwrap_or(0);
|
|
tokio::spawn(async move {
|
|
match client_clone
|
|
.search_releases(Some(series_id), None, None)
|
|
.await
|
|
{
|
|
Ok(releases) => {
|
|
let _ = tx_clone.send(AppEvent::ReleasesLoaded(releases));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_clone.send(AppEvent::Error(format!(
|
|
"Failed to load releases: {}",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} else if app.current_tab == TabIndex::Settings {
|
|
app.handle_settings_input();
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
if app.show_releases_popup {
|
|
app.hide_releases_popup();
|
|
} else {
|
|
app.clear_error();
|
|
if app.search_mode {
|
|
app.exit_search_mode();
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
async fn handle_vim_mode(
|
|
app: &mut App,
|
|
key: crossterm::event::KeyEvent,
|
|
client: SonarrClient,
|
|
tx: mpsc::UnboundedSender<AppEvent>,
|
|
) {
|
|
match key.code {
|
|
// Vim-like quit
|
|
KeyCode::Char('q') => app.should_quit = true,
|
|
|
|
// Tab navigation
|
|
KeyCode::Char('w') | KeyCode::Tab => app.next_tab(),
|
|
KeyCode::Char('b') | KeyCode::BackTab => app.previous_tab(),
|
|
|
|
// Vim navigation
|
|
KeyCode::Char('j') | KeyCode::Down => {
|
|
if app.current_tab == TabIndex::Settings {
|
|
app.next_settings_item();
|
|
} else {
|
|
app.next_item();
|
|
}
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up => {
|
|
if app.current_tab == TabIndex::Settings {
|
|
app.previous_settings_item();
|
|
} else {
|
|
app.previous_item();
|
|
}
|
|
}
|
|
KeyCode::Char('h') | KeyCode::Left => app.previous_tab(),
|
|
KeyCode::Char('l') | KeyCode::Right => app.next_tab(),
|
|
|
|
// Go to first/last
|
|
KeyCode::Char('g') => {
|
|
// Handle gg for go to first (simplified - in real vim this would need state)
|
|
app.go_to_first();
|
|
}
|
|
KeyCode::Char('G') => app.go_to_last(),
|
|
|
|
// Other vim-like commands
|
|
KeyCode::Char('v') => app.toggle_details(), // visual/details mode
|
|
KeyCode::Char('u') => {
|
|
// undo/refresh
|
|
app.loading = true;
|
|
app.clear_error();
|
|
tokio::spawn(async move {
|
|
load_all_data(client, tx).await;
|
|
});
|
|
}
|
|
KeyCode::Char('/') => {
|
|
if app.current_tab == TabIndex::Search {
|
|
app.enter_search_mode();
|
|
}
|
|
}
|
|
KeyCode::Char('i') => {
|
|
// insert mode - enter search if on search tab
|
|
if app.current_tab == TabIndex::Search {
|
|
app.enter_search_mode();
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.current_tab == TabIndex::Settings {
|
|
app.handle_settings_input();
|
|
}
|
|
}
|
|
KeyCode::Char('s') => {
|
|
if let Err(e) = app.save_config() {
|
|
app.set_error(format!("Failed to save config: {}", e));
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
app.clear_error();
|
|
if app.search_mode {
|
|
app.exit_search_mode();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn ui(f: &mut Frame, app: &App) {
|
|
let size = f.area();
|
|
|
|
// Create main layout
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Header
|
|
Constraint::Min(0), // Main content
|
|
Constraint::Length(3), // Footer
|
|
])
|
|
.split(size);
|
|
|
|
// Render header
|
|
render_header(f, chunks[0], app);
|
|
|
|
// Render main content based on current tab
|
|
match app.current_tab {
|
|
TabIndex::Series => render_series_tab(f, chunks[1], app),
|
|
TabIndex::Calendar => render_calendar_tab(f, chunks[1], app),
|
|
TabIndex::Queue => render_queue_tab(f, chunks[1], app),
|
|
TabIndex::History => render_history_tab(f, chunks[1], app),
|
|
TabIndex::Health => render_health_tab(f, chunks[1], app),
|
|
TabIndex::Search => render_search_tab(f, chunks[1], app),
|
|
TabIndex::Settings => render_settings_tab(f, chunks[1], app),
|
|
}
|
|
|
|
// Render footer
|
|
render_footer(f, chunks[2], app);
|
|
|
|
// Render releases popup if it should be shown
|
|
if app.show_releases_popup {
|
|
render_releases_popup(f, app);
|
|
}
|
|
|
|
// Render error popup if there's an error
|
|
if app.error_message.is_some() {
|
|
render_error_popup(f, size, app);
|
|
}
|
|
}
|
|
|
|
fn render_header(f: &mut Frame, area: Rect, app: &App) {
|
|
let tabs = Tabs::new(TabIndex::titles())
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Yarr - Sonarr TUI"),
|
|
)
|
|
.style(Style::default().fg(Color::White))
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.select(app.current_tab.to_index());
|
|
|
|
f.render_widget(tabs, area);
|
|
}
|
|
|
|
fn render_series_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
if app.series.is_empty() {
|
|
let loading_text = if app.loading {
|
|
"Loading series..."
|
|
} else {
|
|
"No series found"
|
|
};
|
|
let paragraph = Paragraph::new(loading_text)
|
|
.block(Block::default().borders(Borders::ALL).title("Series"))
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(paragraph, area);
|
|
return;
|
|
}
|
|
|
|
let items: Vec<ListItem> = app
|
|
.series
|
|
.iter()
|
|
.map(|s| {
|
|
let title = s.title.as_deref().unwrap_or("Unknown");
|
|
let status = &s.status;
|
|
let monitored = if s.monitored { "✓" } else { "✗" };
|
|
|
|
let stats = s
|
|
.statistics
|
|
.as_ref()
|
|
.map(|stats| format!(" ({}/{})", stats.episode_file_count, stats.episode_count))
|
|
.unwrap_or_default();
|
|
|
|
ListItem::new(format!("{} {} [{}]{}", monitored, title, status, stats))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Series"))
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(list, area, &mut app.series_list_state.clone());
|
|
}
|
|
|
|
fn render_calendar_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
if app.calendar.is_empty() {
|
|
let loading_text = if app.loading {
|
|
"Loading calendar..."
|
|
} else {
|
|
"No upcoming episodes"
|
|
};
|
|
let paragraph = Paragraph::new(loading_text)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Upcoming Episodes"),
|
|
)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(paragraph, area);
|
|
return;
|
|
}
|
|
|
|
let items: Vec<ListItem> = app
|
|
.calendar
|
|
.iter()
|
|
.map(|e| {
|
|
let title = e.title.as_deref().unwrap_or("Unknown Episode");
|
|
let series_title = e
|
|
.series
|
|
.as_ref()
|
|
.and_then(|s| s.title.as_deref())
|
|
.unwrap_or("Unknown Series");
|
|
let air_date = e
|
|
.air_date_utc
|
|
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
|
.or_else(|| e.air_date.clone())
|
|
.unwrap_or_default();
|
|
|
|
ListItem::new(format!(
|
|
"S{:02}E{:02} {} - {} [{}]",
|
|
e.season_number, e.episode_number, series_title, title, air_date
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Upcoming Episodes (Next 7 Days)"),
|
|
)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(list, area, &mut app.episodes_list_state.clone());
|
|
}
|
|
|
|
fn render_queue_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
if app.queue.is_empty() {
|
|
let loading_text = if app.loading {
|
|
"Loading queue..."
|
|
} else {
|
|
"Queue is empty"
|
|
};
|
|
let paragraph = Paragraph::new(loading_text)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Download Queue"),
|
|
)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(paragraph, area);
|
|
return;
|
|
}
|
|
|
|
let header = Row::new(vec!["Title", "Status", "Progress", "Quality", "Size"]);
|
|
let rows: Vec<Row> = app
|
|
.queue
|
|
.iter()
|
|
.map(|q| {
|
|
let title = q.title.as_deref().unwrap_or("Unknown");
|
|
let status = &q.status;
|
|
let quality = q
|
|
.quality
|
|
.as_ref()
|
|
.and_then(|qual| qual.quality.name.as_deref())
|
|
.unwrap_or("Unknown");
|
|
let size = format!("{:.1} GB", q.size / 1_000_000_000.0);
|
|
|
|
// Simple progress calculation (this would need more sophisticated logic in real implementation)
|
|
let progress = "50%"; // Placeholder
|
|
|
|
Row::new(vec![
|
|
Cell::from(title),
|
|
Cell::from(status.as_str()),
|
|
Cell::from(progress),
|
|
Cell::from(quality),
|
|
Cell::from(size),
|
|
])
|
|
})
|
|
.collect();
|
|
|
|
let table = Table::new(
|
|
rows,
|
|
&[
|
|
Constraint::Percentage(40),
|
|
Constraint::Percentage(15),
|
|
Constraint::Percentage(15),
|
|
Constraint::Percentage(15),
|
|
Constraint::Percentage(15),
|
|
],
|
|
)
|
|
.header(header.style(Style::default().fg(Color::Yellow)))
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Download Queue"),
|
|
)
|
|
.row_highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(table, area, &mut app.queue_table_state.clone());
|
|
}
|
|
|
|
fn render_history_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
if app.history.is_empty() {
|
|
let loading_text = if app.loading {
|
|
"Loading history..."
|
|
} else {
|
|
"No history found"
|
|
};
|
|
let paragraph = Paragraph::new(loading_text)
|
|
.block(Block::default().borders(Borders::ALL).title("History"))
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(paragraph, area);
|
|
return;
|
|
}
|
|
|
|
let header = Row::new(vec!["Date", "Series", "Episode", "Event", "Quality"]);
|
|
let rows: Vec<Row> = app
|
|
.history
|
|
.iter()
|
|
.map(|h| {
|
|
let date = h.date.format("%Y-%m-%d %H:%M").to_string();
|
|
let series = h
|
|
.series
|
|
.as_ref()
|
|
.and_then(|s| s.title.as_deref())
|
|
.unwrap_or("Unknown");
|
|
let episode = h
|
|
.episode
|
|
.as_ref()
|
|
.map(|e| format!("S{:02}E{:02}", e.season_number, e.episode_number))
|
|
.unwrap_or_default();
|
|
let event = h.event_type.as_deref().unwrap_or("Unknown");
|
|
let quality = h
|
|
.quality
|
|
.as_ref()
|
|
.and_then(|q| q.quality.name.as_deref())
|
|
.unwrap_or("Unknown");
|
|
|
|
Row::new(vec![
|
|
Cell::from(date),
|
|
Cell::from(series),
|
|
Cell::from(episode),
|
|
Cell::from(event),
|
|
Cell::from(quality),
|
|
])
|
|
})
|
|
.collect();
|
|
|
|
let table = Table::new(
|
|
rows,
|
|
&[
|
|
Constraint::Percentage(20),
|
|
Constraint::Percentage(30),
|
|
Constraint::Percentage(15),
|
|
Constraint::Percentage(20),
|
|
Constraint::Percentage(15),
|
|
],
|
|
)
|
|
.header(header.style(Style::default().fg(Color::Yellow)))
|
|
.block(Block::default().borders(Borders::ALL).title("History"))
|
|
.row_highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(table, area, &mut app.history_table_state.clone());
|
|
}
|
|
|
|
fn render_health_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
if app.health.is_empty() {
|
|
let loading_text = if app.loading {
|
|
"Loading health status..."
|
|
} else {
|
|
"All systems healthy"
|
|
};
|
|
let paragraph = Paragraph::new(loading_text)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Health Status"),
|
|
)
|
|
.alignment(Alignment::Center)
|
|
.style(Style::default().fg(Color::Green));
|
|
f.render_widget(paragraph, area);
|
|
return;
|
|
}
|
|
|
|
let items: Vec<ListItem> = app
|
|
.health
|
|
.iter()
|
|
.map(|h| {
|
|
let source = h.source.as_deref().unwrap_or("System");
|
|
let message = h.message.as_deref().unwrap_or("No message");
|
|
let health_type = &h.health_type;
|
|
|
|
let color = match health_type.as_str() {
|
|
"error" => Color::Red,
|
|
"warning" => Color::Yellow,
|
|
"notice" => Color::Blue,
|
|
_ => Color::Green,
|
|
};
|
|
|
|
ListItem::new(format!(
|
|
"[{}] {}: {}",
|
|
health_type.to_uppercase(),
|
|
source,
|
|
message
|
|
))
|
|
.style(Style::default().fg(color))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Health Status"),
|
|
)
|
|
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
|
|
|
f.render_stateful_widget(list, area, &mut app.health_list_state.clone());
|
|
}
|
|
|
|
fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
// Search input
|
|
let input_style = if app.input_mode {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
let input = Paragraph::new(app.search_input.as_str())
|
|
.style(input_style)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Search Series (Press '/' to search, Enter to execute, Enter on result to view releases)"),
|
|
);
|
|
f.render_widget(input, chunks[0]);
|
|
|
|
// Search results
|
|
if app.search_results.is_empty() {
|
|
let text = if app.loading {
|
|
"Searching..."
|
|
} else if app.search_input.is_empty() {
|
|
"Enter a search term and press Enter to search for series"
|
|
} else {
|
|
"No results found - try a different search term"
|
|
};
|
|
|
|
let paragraph = Paragraph::new(text)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Search Results"),
|
|
)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(paragraph, chunks[1]);
|
|
} else {
|
|
let items: Vec<ListItem> = app
|
|
.search_results
|
|
.iter()
|
|
.map(|s| {
|
|
let title = s.title.as_deref().unwrap_or("Unknown");
|
|
let year = s.year;
|
|
let network = s.network.as_deref().unwrap_or("Unknown Network");
|
|
let overview = s.overview.as_deref().unwrap_or("");
|
|
let truncated_overview = if overview.len() > 80 {
|
|
format!("{}...", &overview[..77])
|
|
} else {
|
|
overview.to_string()
|
|
};
|
|
|
|
ListItem::new(format!(
|
|
"{} ({}) - {} | {}",
|
|
title, year, network, truncated_overview
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Search Results (Press Enter on a series to view available releases)"),
|
|
)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(list, chunks[1], &mut app.search_list_state.clone());
|
|
}
|
|
}
|
|
|
|
fn render_settings_tab(f: &mut Frame, area: Rect, app: &App) {
|
|
if app.editing_url || app.editing_api_key {
|
|
render_settings_input(f, area, app);
|
|
} else {
|
|
render_settings_list(f, area, app);
|
|
}
|
|
}
|
|
|
|
fn render_settings_list(f: &mut Frame, area: Rect, app: &App) {
|
|
let mut settings_items = vec![
|
|
ListItem::new(format!(
|
|
"Keybind Mode: {} (Press Enter to toggle)",
|
|
match app.config.ui.keybind_mode {
|
|
KeybindMode::Normal => "Normal",
|
|
KeybindMode::Vim => "Vim",
|
|
}
|
|
)),
|
|
ListItem::new(format!(
|
|
"Show Help: {} (Press Enter to toggle)",
|
|
if app.config.ui.show_help { "Yes" } else { "No" }
|
|
)),
|
|
ListItem::new(format!(
|
|
"Sonarr URL: {} (Press Enter to edit)",
|
|
if app.config.sonarr.url.is_empty() {
|
|
"Not set"
|
|
} else {
|
|
&app.config.sonarr.url
|
|
}
|
|
)),
|
|
ListItem::new(format!(
|
|
"API Key: {} (Press Enter to edit)",
|
|
if app.config.sonarr.api_key.is_empty() {
|
|
"Not set"
|
|
} else {
|
|
"***hidden***"
|
|
}
|
|
)),
|
|
ListItem::new(if app.config_changed {
|
|
"Settings changed - Press 's' to save"
|
|
} else {
|
|
"Press 's' to save settings"
|
|
}),
|
|
];
|
|
|
|
// Add keybind help based on current mode
|
|
settings_items.push(ListItem::new(""));
|
|
settings_items.push(ListItem::new("Keybind Modes:"));
|
|
settings_items.push(ListItem::new(
|
|
" Normal: Standard navigation (arrows, j/k, tab)",
|
|
));
|
|
settings_items.push(ListItem::new(
|
|
" Vim: Vim-like navigation (hjkl, gg/G, w/b)",
|
|
));
|
|
|
|
let title = if app.config_changed {
|
|
"Settings (Unsaved Changes)"
|
|
} else {
|
|
"Settings"
|
|
};
|
|
|
|
let list = List::new(settings_items)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(list, area, &mut app.settings_list_state.clone());
|
|
}
|
|
|
|
fn render_settings_input(f: &mut Frame, area: Rect, app: &App) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Input field
|
|
Constraint::Min(0), // Instructions
|
|
])
|
|
.split(area);
|
|
|
|
let (title, current_input, field_name) = if app.editing_url {
|
|
("Edit Sonarr URL", &app.url_input, "URL")
|
|
} else if app.editing_api_key {
|
|
("Edit API Key", &app.api_key_input, "API Key")
|
|
} else {
|
|
("Settings", &String::new(), "")
|
|
};
|
|
|
|
// Input field
|
|
let input = Paragraph::new(current_input.as_str())
|
|
.style(Style::default().fg(Color::Yellow))
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(format!("{} - {}", title, field_name)),
|
|
);
|
|
f.render_widget(input, chunks[0]);
|
|
|
|
// Instructions
|
|
let instructions = vec![
|
|
Line::from("Enter: Save and return"),
|
|
Line::from("Esc: Cancel and return"),
|
|
Line::from(""),
|
|
if app.editing_url {
|
|
Line::from("Enter the Sonarr server URL (must start with http:// or https://)")
|
|
} else {
|
|
Line::from("Enter your Sonarr API key (found in Sonarr Settings > General > Security)")
|
|
},
|
|
];
|
|
|
|
let help = Paragraph::new(instructions)
|
|
.block(Block::default().borders(Borders::ALL).title("Instructions"))
|
|
.wrap(Wrap { trim: true });
|
|
f.render_widget(help, chunks[1]);
|
|
}
|
|
|
|
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
|
let help_text = if app.show_releases_popup {
|
|
"Enter: Download | Esc: Close | ↑↓/jk: Navigate"
|
|
} else if app.input_mode {
|
|
if app.editing_url || app.editing_api_key {
|
|
"ESC: Cancel | Enter: Save | Type to enter value"
|
|
} else {
|
|
"ESC: Cancel | Enter: Search | Type to enter search term"
|
|
}
|
|
} else if !app.config.ui.show_help {
|
|
"" // Don't show help if disabled
|
|
} else if app.current_tab == TabIndex::Search && !app.search_results.is_empty() {
|
|
"Enter: Show Releases | ↑↓/jk: Navigate | /: Search | Other: Normal keys"
|
|
} else {
|
|
match app.config.ui.keybind_mode {
|
|
KeybindMode::Normal => {
|
|
"q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search"
|
|
}
|
|
KeybindMode::Vim => {
|
|
"q: Quit | w/b: Tabs | hjkl: Navigate | v: Details | u: Refresh | /: Search | gg/G: First/Last"
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut spans = vec![Span::raw(help_text)];
|
|
|
|
if let Some(status) = &app.system_status {
|
|
spans.push(Span::raw(" | "));
|
|
spans.push(Span::styled(
|
|
format!("Sonarr v{}", status.version.as_deref().unwrap_or("Unknown")),
|
|
Style::default().fg(Color::Green),
|
|
));
|
|
}
|
|
|
|
if app.loading {
|
|
spans.push(Span::raw(" | "));
|
|
spans.push(Span::styled(
|
|
"Loading...",
|
|
Style::default().fg(Color::Yellow),
|
|
));
|
|
}
|
|
|
|
let paragraph = Paragraph::new(Line::from(spans))
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.alignment(Alignment::Center);
|
|
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_error_popup(f: &mut Frame, area: Rect, app: &App) {
|
|
if let Some(error) = &app.error_message {
|
|
let popup_area = centered_rect(60, 20, area);
|
|
|
|
f.render_widget(Clear, popup_area);
|
|
|
|
let error_paragraph = Paragraph::new(error.as_str())
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Error")
|
|
.style(Style::default().fg(Color::Red)),
|
|
)
|
|
.wrap(Wrap { trim: true })
|
|
.alignment(Alignment::Center);
|
|
|
|
f.render_widget(error_paragraph, popup_area);
|
|
}
|
|
}
|
|
|
|
fn render_releases_popup(f: &mut Frame, app: &App) {
|
|
let area = centered_rect(80, 70, f.area());
|
|
|
|
f.render_widget(Clear, area);
|
|
|
|
let title = if let Some(ref series) = app.selected_search_series {
|
|
format!(
|
|
"Releases for: {}",
|
|
series.title.as_deref().unwrap_or("Unknown")
|
|
)
|
|
} else {
|
|
"Releases".to_string()
|
|
};
|
|
|
|
let block = Block::default()
|
|
.title(title)
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Yellow));
|
|
|
|
if app.loading_releases {
|
|
let loading_text = Paragraph::new("Loading releases...")
|
|
.block(block)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(loading_text, area);
|
|
} else if app.releases.is_empty() {
|
|
let no_releases_text = Paragraph::new("No releases found")
|
|
.block(block)
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(no_releases_text, area);
|
|
} else {
|
|
let inner_area = block.inner(area);
|
|
f.render_widget(block, area);
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(0), Constraint::Length(3)])
|
|
.split(inner_area);
|
|
|
|
let items: Vec<ListItem> = app
|
|
.releases
|
|
.iter()
|
|
.map(|release| {
|
|
let title = release.title.as_deref().unwrap_or("Unknown");
|
|
let size = format_size(release.size.unwrap_or(0));
|
|
let quality = release.quality_weight.unwrap_or(0);
|
|
let indexer = release.indexer.as_deref().unwrap_or("Unknown");
|
|
let seeds = release.seeders.unwrap_or(0);
|
|
let peers = release.leechers.unwrap_or(0);
|
|
|
|
let status = if release.download_allowed.unwrap_or(false) {
|
|
if release.rejected.unwrap_or(false) {
|
|
"❌ Rejected"
|
|
} else {
|
|
"✅ Available"
|
|
}
|
|
} else {
|
|
"⛔ Not Allowed"
|
|
};
|
|
|
|
ListItem::new(format!(
|
|
"{} | {} | Q:{} | {} | S:{} P:{} | {}",
|
|
status, size, quality, indexer, seeds, peers, title
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items).highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
f.render_stateful_widget(list, chunks[0], &mut app.releases_list_state.clone());
|
|
|
|
let help_text = Paragraph::new("Enter: Download | Esc: Close | ↑↓: Navigate")
|
|
.style(Style::default().fg(Color::Gray))
|
|
.alignment(Alignment::Center)
|
|
.block(Block::default().borders(Borders::TOP));
|
|
f.render_widget(help_text, chunks[1]);
|
|
}
|
|
}
|
|
|
|
fn format_size(bytes: i64) -> String {
|
|
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
|
|
|
if bytes == 0 {
|
|
return "0 B".to_string();
|
|
}
|
|
|
|
let mut size = bytes as f64;
|
|
let mut unit_index = 0;
|
|
|
|
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
|
|
size /= 1024.0;
|
|
unit_index += 1;
|
|
}
|
|
|
|
format!("{:.1} {}", size, UNITS[unit_index])
|
|
}
|
|
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let popup_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(r);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(popup_layout[1])[1]
|
|
}
|