feat(yarr): Added download option
This commit is contained in:
261
src/tui.rs
261
src/tui.rs
@@ -22,7 +22,8 @@ use tokio::sync::mpsc;
|
||||
|
||||
use crate::config::{AppConfig, KeybindMode};
|
||||
use yarr_api::{
|
||||
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
|
||||
Episode, HealthResource, HistoryItem, QueueItem, ReleaseResource, Series, SonarrClient,
|
||||
SystemStatus,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -39,6 +40,8 @@ pub enum AppEvent {
|
||||
HealthLoaded(Vec<HealthResource>),
|
||||
CalendarLoaded(Vec<Episode>),
|
||||
SearchResults(Vec<Series>),
|
||||
ReleasesLoaded(Vec<ReleaseResource>),
|
||||
ReleaseDownloaded(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -95,6 +98,7 @@ pub struct App {
|
||||
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>,
|
||||
@@ -117,6 +121,10 @@ pub struct App {
|
||||
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 {
|
||||
@@ -131,6 +139,7 @@ impl Default for App {
|
||||
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(),
|
||||
@@ -152,9 +161,14 @@ impl Default for App {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -177,6 +191,11 @@ impl App {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -245,6 +264,11 @@ impl App {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -366,6 +390,55 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -721,6 +794,18 @@ async fn run_tui<B: Backend>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,14 +1024,61 @@ async fn handle_normal_mode(
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if app.current_tab == TabIndex::Settings {
|
||||
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 => {
|
||||
app.clear_error();
|
||||
if app.search_mode {
|
||||
app.exit_search_mode();
|
||||
if app.show_releases_popup {
|
||||
app.hide_releases_popup();
|
||||
} else {
|
||||
app.clear_error();
|
||||
if app.search_mode {
|
||||
app.exit_search_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1063,6 +1195,11 @@ fn ui(f: &mut Frame, app: &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);
|
||||
@@ -1397,7 +1534,7 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Search Series (Press '/' to search, Enter to execute)"),
|
||||
.title("Search Series (Press '/' to search, Enter to execute, Enter on result to view releases)"),
|
||||
);
|
||||
f.render_widget(input, chunks[0]);
|
||||
|
||||
@@ -1406,9 +1543,9 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
||||
let text = if app.loading {
|
||||
"Searching..."
|
||||
} else if app.search_input.is_empty() {
|
||||
"Enter a search term and press Enter"
|
||||
"Enter a search term and press Enter to search for series"
|
||||
} else {
|
||||
"No results found"
|
||||
"No results found - try a different search term"
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(text)
|
||||
@@ -1445,7 +1582,7 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Search Results"),
|
||||
.title("Search Results (Press Enter on a series to view available releases)"),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
@@ -1574,7 +1711,9 @@ fn render_settings_input(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
let help_text = if app.input_mode {
|
||||
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 {
|
||||
@@ -1582,6 +1721,8 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
} 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 => {
|
||||
@@ -1638,6 +1779,106 @@ fn render_error_popup(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user