use std::{collections::HashSet, time::Duration}; use anyhow::Result; use crossterm::{ execute, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{Terminal, backend::CrosstermBackend}; use crate::{ client::{ ApiClient, AuditEntryResponse, BookMetadataResponse, ReadingProgressResponse, }, event::{ApiResult, AppEvent, EventHandler}, input::{self, Action}, ui, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum View { Library, Search, Detail, Tags, Collections, Audit, Import, Settings, Duplicates, Database, MetadataEdit, Queue, Statistics, Tasks, Books, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BooksSubView { List, Series, Authors, } #[expect( clippy::struct_excessive_bools, reason = "TUI state struct accumulates boolean flags naturally" )] pub struct AppState { pub current_view: View, pub media_list: Vec, pub selected_index: Option, pub selected_media: Option, pub search_input: String, pub search_results: Vec, pub search_selected: Option, pub search_total_count: u64, pub tags: Vec, pub all_tags: Vec, pub tag_selected: Option, pub collections: Vec, pub collection_selected: Option, pub audit_log: Vec, pub audit_selected: Option, pub input_mode: bool, pub import_input: String, pub status_message: Option, pub should_quit: bool, pub page_offset: u64, pub page_size: u64, pub total_media_count: u64, pub server_url: String, // Multi-select support pub selected_items: HashSet, pub selection_mode: bool, pub pending_batch_delete: bool, // Duplicates view pub duplicate_groups: Vec, pub duplicates_selected: Option, // Database view pub database_stats: Option>, // Metadata edit view pub edit_title: String, pub edit_artist: String, pub edit_album: String, pub edit_genre: String, pub edit_year: String, pub edit_description: String, pub edit_field_index: Option, // Queue view pub play_queue: Vec, pub queue_current_index: Option, pub queue_selected: Option, pub queue_repeat: u8, pub queue_shuffle: bool, // Statistics view pub library_stats: Option, // Scheduled tasks view pub scheduled_tasks: Vec, pub scheduled_tasks_selected: Option, // Books view pub books_list: Vec, pub books_series: Vec, pub books_authors: Vec, pub books_selected: Option, pub books_sub_view: BooksSubView, // Book detail metadata pub book_metadata: Option, pub reading_progress: Option, // Reading progress input (page number) pub page_input: String, pub entering_page: bool, } #[derive(Clone)] pub struct QueueItem { pub media_id: String, pub title: String, pub artist: Option, pub media_type: String, } impl AppState { fn new(server_url: &str) -> Self { Self { current_view: View::Library, media_list: Vec::new(), selected_index: None, selected_media: None, search_input: String::new(), search_results: Vec::new(), search_selected: None, search_total_count: 0, tags: Vec::new(), all_tags: Vec::new(), tag_selected: None, collections: Vec::new(), collection_selected: None, audit_log: Vec::new(), audit_selected: None, input_mode: false, import_input: String::new(), status_message: None, should_quit: false, duplicate_groups: Vec::new(), duplicates_selected: None, database_stats: None, edit_title: String::new(), edit_artist: String::new(), edit_album: String::new(), edit_genre: String::new(), edit_year: String::new(), edit_description: String::new(), edit_field_index: None, play_queue: Vec::new(), queue_current_index: None, queue_selected: None, queue_repeat: 0, queue_shuffle: false, library_stats: None, scheduled_tasks: Vec::new(), scheduled_tasks_selected: None, // Books view books_list: Vec::new(), books_series: Vec::new(), books_authors: Vec::new(), books_selected: None, books_sub_view: BooksSubView::List, book_metadata: None, reading_progress: None, page_input: String::new(), entering_page: false, page_offset: 0, page_size: 50, total_media_count: 0, server_url: server_url.to_string(), // Multi-select selected_items: HashSet::new(), selection_mode: false, pending_batch_delete: false, } } } pub async fn run(server_url: &str, api_key: Option<&str>) -> Result<()> { let client = ApiClient::new(server_url, api_key); let mut state = AppState::new(server_url); // Initial data load match client.list_media(0, state.page_size).await { Ok(items) => { state.total_media_count = items.len() as u64; if !items.is_empty() { state.selected_index = Some(0); } state.media_list = items; }, Err(e) => { state.status_message = Some(format!("Failed to connect: {e}")); }, } // Setup terminal terminal::enable_raw_mode()?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let mut events = EventHandler::new(Duration::from_millis(250)); let event_sender = events.sender(); // Main loop while !state.should_quit { terminal.draw(|f| ui::render(f, &state))?; if let Some(event) = events.next().await { match event { AppEvent::Key(key) => { // Intercept input when entering reading progress page number if state.entering_page { use crossterm::event::KeyCode; match key.code { KeyCode::Char(c) if c.is_ascii_digit() => { state.page_input.push(c); state.status_message = Some(format!("Page number: {}", state.page_input)); }, KeyCode::Backspace => { state.page_input.pop(); state.status_message = if state.page_input.is_empty() { Some( "Enter page number (Enter to confirm, Esc to cancel)" .into(), ) } else { Some(format!("Page number: {}", state.page_input)) }; }, KeyCode::Enter => { state.entering_page = false; if let Ok(page) = state.page_input.parse::() { if let Some(ref media) = state.selected_media { let media_id = media.id.clone(); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client .update_reading_progress(&media_id, page) .await { Ok(()) => { let _ = tx.send(AppEvent::ApiResult( ApiResult::ReadingProgressUpdated, )); }, Err(e) => { tracing::error!( "Failed to update reading progress: {e}" ); }, } }); } } else { state.status_message = Some("Invalid page number".into()); } state.page_input.clear(); }, KeyCode::Esc => { state.entering_page = false; state.page_input.clear(); state.status_message = None; }, _ => {}, } // Intercept y/n when batch delete confirmation is pending } else if state.pending_batch_delete { use crossterm::event::KeyCode; state.pending_batch_delete = false; if let KeyCode::Char('y' | 'Y') = key.code { let action = Action::ConfirmBatchDelete; handle_action(&client, &mut state, action, &event_sender).await; } else { state.status_message = Some("Batch delete cancelled".into()); } } else { let action = input::handle_key(key, state.input_mode, state.current_view); handle_action(&client, &mut state, action, &event_sender).await; } }, AppEvent::Tick => {}, AppEvent::ApiResult(result) => { handle_api_result(&mut state, result); }, } } } // Restore terminal terminal::disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) } fn handle_api_result(state: &mut AppState, result: ApiResult) { match result { ApiResult::MediaList(items) => { if !items.is_empty() && state.selected_index.is_none() { state.selected_index = Some(0); } state.total_media_count = state.page_offset + items.len() as u64; state.media_list = items; }, ApiResult::SearchResults(resp) => { state.search_total_count = resp.total_count; state.search_results = resp.items; if !state.search_results.is_empty() { state.search_selected = Some(0); } }, ApiResult::AllTags(tags) => { // All tags in the system (for Tags view) state.tags = tags; if !state.tags.is_empty() { state.tag_selected = Some(0); } }, ApiResult::Collections(cols) => { state.collections = cols; if !state.collections.is_empty() { state.collection_selected = Some(0); } }, ApiResult::ImportDone(resp) => { if resp.was_duplicate { state.status_message = Some(format!("Import: file already exists ({})", resp.media_id)); } else { state.status_message = Some(format!("Imported: {}", resp.media_id)); } }, ApiResult::ScanDone(results) => { let total: usize = results.iter().map(|r| r.files_processed).sum(); let found: usize = results.iter().map(|r| r.files_found).sum(); let errors: Vec = results.into_iter().flat_map(|r| r.errors).collect(); if errors.is_empty() { state.status_message = Some(format!("Scan complete: {total}/{found} files processed")); } else { state.status_message = Some(format!( "Scan complete: {total}/{found} files, {} errors", errors.len() )); } }, ApiResult::AuditLog(entries) => { state.audit_log = entries; if !state.audit_log.is_empty() { state.audit_selected = Some(0); } }, ApiResult::Duplicates(groups) => { if !groups.is_empty() { state.duplicates_selected = Some(0); } state.status_message = Some(format!("Found {} duplicate groups", groups.len())); state.duplicate_groups = groups; }, ApiResult::DatabaseStats(stats) => { state.database_stats = Some(vec![ ("Media".to_string(), stats.media_count.to_string()), ("Tags".to_string(), stats.tag_count.to_string()), ( "Collections".to_string(), stats.collection_count.to_string(), ), ("Audit entries".to_string(), stats.audit_count.to_string()), ( "Database size".to_string(), crate::ui::format_size(stats.database_size_bytes), ), ("Backend".to_string(), stats.backend_name), ]); state.status_message = None; }, ApiResult::Statistics(stats) => { state.library_stats = Some(stats); state.status_message = None; }, ApiResult::ScheduledTasks(tasks) => { if !tasks.is_empty() && state.scheduled_tasks_selected.is_none() { state.scheduled_tasks_selected = Some(0); } state.scheduled_tasks = tasks; state.status_message = None; }, ApiResult::BooksList(items) => { if !items.is_empty() && state.books_selected.is_none() { state.books_selected = Some(0); } state.books_list = items; state.status_message = None; }, ApiResult::BookSeries(series) => { state.books_series = series; state.status_message = None; }, ApiResult::BookAuthors(authors) => { state.books_authors = authors; state.status_message = None; }, ApiResult::MediaUpdated => { state.status_message = Some("Media updated".into()); }, ApiResult::ReadingProgressUpdated => { state.status_message = Some("Reading progress updated".into()); }, ApiResult::Error(msg) => { state.status_message = Some(format!("Error: {msg}")); }, } } async fn handle_action( client: &ApiClient, state: &mut AppState, action: Action, event_sender: &tokio::sync::mpsc::UnboundedSender, ) { match action { Action::Quit => state.should_quit = true, Action::NavigateDown => { let len = match state.current_view { View::Search => state.search_results.len(), View::Tags => state.tags.len(), View::Collections => state.collections.len(), View::Audit => state.audit_log.len(), View::Books => { match state.books_sub_view { BooksSubView::List => state.books_list.len(), BooksSubView::Series => state.books_series.len(), BooksSubView::Authors => state.books_authors.len(), } }, _ => state.media_list.len(), }; if len > 0 { let idx = match state.current_view { View::Search => &mut state.search_selected, View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; *idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1))); } }, Action::NavigateUp => { let idx = match state.current_view { View::Search => &mut state.search_selected, View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; *idx = Some(idx.map_or(0, |i| i.saturating_sub(1))); }, Action::GoTop => { let idx = match state.current_view { View::Search => &mut state.search_selected, View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; *idx = Some(0); }, Action::GoBottom => { let len = match state.current_view { View::Search => state.search_results.len(), View::Tags => state.tags.len(), View::Collections => state.collections.len(), View::Audit => state.audit_log.len(), View::Books => { match state.books_sub_view { BooksSubView::List => state.books_list.len(), BooksSubView::Series => state.books_series.len(), BooksSubView::Authors => state.books_authors.len(), } }, _ => state.media_list.len(), }; if len > 0 { let idx = match state.current_view { View::Search => &mut state.search_selected, View::Tags => &mut state.tag_selected, View::Collections => &mut state.collection_selected, View::Audit => &mut state.audit_selected, View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; *idx = Some(len - 1); } }, Action::Select => { if state.input_mode { state.input_mode = false; match state.current_view { View::Search => { let query = state.search_input.clone(); state.status_message = Some("Searching...".into()); let client = client.clone(); let tx = event_sender.clone(); let page_size = state.page_size; tokio::spawn(async move { match client.search(&query, 0, page_size).await { Ok(results) => { if let Err(e) = tx.send(AppEvent::ApiResult( ApiResult::SearchResults(results), )) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult( ApiResult::Error(format!("Search: {e}")), )) { tracing::warn!("failed to send event: {e}"); } }, } }); }, View::Import => { let path = state.import_input.clone(); if !path.is_empty() { state.status_message = Some("Importing...".into()); let client = client.clone(); let tx = event_sender.clone(); let page_size = state.page_size; tokio::spawn(async move { match client.import_file(&path).await { Ok(resp) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::ImportDone(resp))) { tracing::warn!("failed to send event: {e}"); } // Also refresh the media list if let Ok(items) = client.list_media(0, page_size).await && let Err(e) = tx .send(AppEvent::ApiResult(ApiResult::MediaList(items))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult( ApiResult::Error(format!("Import: {e}")), )) { tracing::warn!("failed to send event: {e}"); } }, } }); state.import_input.clear(); } state.current_view = View::Library; }, View::Tags => { // Create a new tag using the entered name let name = state.search_input.clone(); if !name.is_empty() { match client.create_tag(&name, None).await { Ok(tag) => { state.tags.push(tag); state.status_message = Some(format!("Created tag: {name}")); }, Err(e) => { state.status_message = Some(format!("Create tag error: {e}")); }, } state.search_input.clear(); } }, _ => {}, } } else { // Open detail view for the selected item let item = match state.current_view { View::Search => { state .search_selected .and_then(|i| state.search_results.get(i)) .cloned() }, _ => { state .selected_index .and_then(|i| state.media_list.get(i)) .cloned() }, }; if let Some(media) = item { if let Ok(full_media) = client.get_media(&media.id).await { // Fetch tags for this media item let media_tags = client.get_media_tags(&full_media.id).await.ok(); // Also fetch all tags for tag/untag operations let all_tags = client.list_tags().await.ok(); // Fetch book metadata for document types state.book_metadata = client.get_book_metadata(&full_media.id).await.ok(); state.reading_progress = client.get_reading_progress(&full_media.id).await.ok(); state.selected_media = Some(full_media); if let Some(tags) = media_tags { state.tags = tags; } if let Some(all) = all_tags { state.all_tags = all; } } else { state.book_metadata = None; state.reading_progress = None; state.selected_media = Some(media); } state.current_view = View::Detail; } } }, Action::Back => { if state.input_mode { state.input_mode = false; } else { state.current_view = View::Library; state.status_message = None; } }, Action::Search => { state.current_view = View::Search; state.input_mode = true; }, Action::Import => { state.current_view = View::Import; state.input_mode = true; state.import_input.clear(); }, Action::Open => { if let Some(ref media) = state.selected_media { match client.open_media(&media.id).await { Ok(()) => state.status_message = Some("Opened file".into()), Err(e) => state.status_message = Some(format!("Open error: {e}")), } } else if let Some(idx) = state.selected_index && let Some(media) = state.media_list.get(idx) { match client.open_media(&media.id).await { Ok(()) => state.status_message = Some("Opened file".into()), Err(e) => state.status_message = Some(format!("Open error: {e}")), } } }, Action::Delete => { if let Some(idx) = state.selected_index && let Some(media) = state.media_list.get(idx).cloned() { match client.delete_media(&media.id).await { Ok(()) => { state.media_list.remove(idx); if state.media_list.is_empty() { state.selected_index = None; } else if idx >= state.media_list.len() { state.selected_index = Some(state.media_list.len() - 1); } state.status_message = Some("Deleted".into()); }, Err(e) => state.status_message = Some(format!("Delete error: {e}")), } } }, Action::TagView => { state.current_view = View::Tags; match client.list_tags().await { Ok(tags) => { if !tags.is_empty() { state.tag_selected = Some(0); } state.tags = tags; }, Err(e) => state.status_message = Some(format!("Tags error: {e}")), } }, Action::CollectionView => { state.current_view = View::Collections; match client.list_collections().await { Ok(cols) => { if !cols.is_empty() { state.collection_selected = Some(0); } state.collections = cols; }, Err(e) => { state.status_message = Some(format!("Collections error: {e}")); }, } }, Action::AuditView => { state.current_view = View::Audit; match client.list_audit(0, state.page_size).await { Ok(entries) => { if !entries.is_empty() { state.audit_selected = Some(0); } state.audit_log = entries; }, Err(e) => state.status_message = Some(format!("Audit error: {e}")), } }, Action::SettingsView => { state.current_view = View::Settings; }, Action::DuplicatesView => { state.current_view = View::Duplicates; state.status_message = Some("Loading duplicates...".into()); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.find_duplicates().await { Ok(groups) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Duplicates: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }); }, Action::DatabaseView => { state.current_view = View::Database; state.status_message = Some("Loading stats...".into()); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.database_stats().await { Ok(stats) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Database stats: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } // Also fetch background jobs info match client.list_jobs().await { Ok(jobs) => { tracing::debug!("Found {} background jobs", jobs.len()); for job in &jobs { tracing::debug!( "Job {}: kind={:?}, status={:?}, created={}, updated={}", job.id, job.kind, job.status, job.created_at, job.updated_at ); } }, Err(e) => tracing::warn!("Failed to list jobs: {}", e), } }); }, Action::QueueView => { state.current_view = View::Queue; }, Action::StatisticsView => { state.current_view = View::Statistics; state.status_message = Some("Loading statistics...".into()); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.library_statistics().await { Ok(stats) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Statistics: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }); }, Action::TasksView => { state.current_view = View::Tasks; state.status_message = Some("Loading tasks...".into()); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.list_scheduled_tasks().await { Ok(tasks) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Tasks: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }); }, Action::BooksView => { state.current_view = View::Books; state.books_sub_view = BooksSubView::List; state.status_message = Some("Loading books...".into()); let client = client.clone(); let tx = event_sender.clone(); let page_size = state.page_size; tokio::spawn(async move { match client.list_books(0, page_size).await { Ok(items) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::BooksList(items))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Books: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } // Also load series and authors if let Ok(series) = client.list_series().await { let _ = tx.send(AppEvent::ApiResult(ApiResult::BookSeries(series))); } if let Ok(authors) = client.list_book_authors().await { let _ = tx.send(AppEvent::ApiResult(ApiResult::BookAuthors(authors))); } }); }, Action::ScanTrigger => { state.status_message = Some("Scanning...".into()); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.trigger_scan(None).await { Ok(results) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::ScanDone(results))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx .send(AppEvent::ApiResult(ApiResult::Error(format!("Scan: {e}")))) { tracing::warn!("failed to send event: {e}"); } }, } }); }, Action::Refresh => { // Reload data for the current view asynchronously state.status_message = Some("Refreshing...".into()); let client = client.clone(); let tx = event_sender.clone(); let page_offset = state.page_offset; let page_size = state.page_size; let view = state.current_view; tokio::spawn(async move { match view { View::Library | View::Detail | View::Import | View::Settings => { match client.list_media(page_offset, page_size).await { Ok(items) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Tags => { match client.list_tags().await { Ok(tags) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::AllTags(tags))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Collections => { match client.list_collections().await { Ok(cols) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Collections(cols))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Audit => { match client.list_audit(0, page_size).await { Ok(entries) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::AuditLog(entries))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Duplicates => { match client.find_duplicates().await { Ok(groups) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Database => { match client.database_stats().await { Ok(stats) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Statistics => { match client.library_statistics().await { Ok(stats) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Tasks => { match client.list_scheduled_tasks().await { Ok(tasks) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) { tracing::warn!("failed to send event: {e}"); } }, Err(e) => { if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh: {e}"), ))) { tracing::warn!("failed to send event: {e}"); } }, } }, View::Books => { match client.list_books(0, page_size).await { Ok(items) => { let _ = tx.send(AppEvent::ApiResult(ApiResult::BooksList(items))); }, Err(e) => { let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( format!("Refresh books: {e}"), ))); }, } if let Ok(series) = client.list_series().await { let _ = tx.send(AppEvent::ApiResult(ApiResult::BookSeries(series))); } if let Ok(authors) = client.list_book_authors().await { let _ = tx.send(AppEvent::ApiResult(ApiResult::BookAuthors(authors))); } }, View::Search | View::MetadataEdit | View::Queue => { // No generic refresh for these views }, } }); }, Action::NextTab => { if state.current_view == View::Books { // Cycle through books sub-views state.books_selected = None; state.books_sub_view = match state.books_sub_view { BooksSubView::List => BooksSubView::Series, BooksSubView::Series => BooksSubView::Authors, BooksSubView::Authors => BooksSubView::List, }; // Reset selection for the new sub-view let len = match state.books_sub_view { BooksSubView::List => state.books_list.len(), BooksSubView::Series => state.books_series.len(), BooksSubView::Authors => state.books_authors.len(), }; if len > 0 { state.books_selected = Some(0); } } else { state.current_view = match state.current_view { View::Library => View::Search, View::Search => View::Tags, View::Tags => View::Collections, View::Collections => View::Audit, View::Audit => View::Books, View::Books => View::Queue, View::Queue => View::Statistics, View::Statistics => View::Tasks, View::Tasks | View::Detail | View::Import | View::Settings | View::Duplicates | View::Database | View::MetadataEdit => View::Library, }; } }, Action::PrevTab => { if state.current_view == View::Books { // Cycle through books sub-views in reverse state.books_selected = None; state.books_sub_view = match state.books_sub_view { BooksSubView::List => BooksSubView::Authors, BooksSubView::Series => BooksSubView::List, BooksSubView::Authors => BooksSubView::Series, }; // Reset selection for the new sub-view let len = match state.books_sub_view { BooksSubView::List => state.books_list.len(), BooksSubView::Series => state.books_series.len(), BooksSubView::Authors => state.books_authors.len(), }; if len > 0 { state.books_selected = Some(0); } } else { state.current_view = match state.current_view { View::Library => View::Tasks, View::Search | View::Detail | View::Import | View::Settings | View::Duplicates | View::Database | View::MetadataEdit => View::Library, View::Tags => View::Search, View::Collections => View::Tags, View::Audit => View::Collections, View::Books => View::Audit, View::Queue => View::Books, View::Statistics => View::Queue, View::Tasks => View::Statistics, }; } }, Action::PageDown => { state.page_offset += state.page_size; match client.list_media(state.page_offset, state.page_size).await { Ok(items) => { if items.is_empty() { state.page_offset = state.page_offset.saturating_sub(state.page_size); } else { state.total_media_count = state.page_offset + items.len() as u64; state.media_list = items; state.selected_index = Some(0); } }, Err(e) => state.status_message = Some(format!("Load error: {e}")), } }, Action::PageUp => { if state.page_offset > 0 { state.page_offset = state.page_offset.saturating_sub(state.page_size); match client.list_media(state.page_offset, state.page_size).await { Ok(items) => { state.total_media_count = state.page_offset + items.len() as u64; state.media_list = items; state.selected_index = Some(0); }, Err(e) => state.status_message = Some(format!("Load error: {e}")), } } }, Action::CreateTag => { if state.current_view == View::Tags { state.input_mode = true; state.search_input.clear(); state.status_message = Some("Enter tag name:".into()); } }, Action::DeleteSelected => { match state.current_view { View::Tags => { if let Some(idx) = state.tag_selected && let Some(tag) = state.tags.get(idx).cloned() { match client.delete_tag(&tag.id).await { Ok(()) => { state.tags.remove(idx); if state.tags.is_empty() { state.tag_selected = None; } else if idx >= state.tags.len() { state.tag_selected = Some(state.tags.len() - 1); } state.status_message = Some(format!("Deleted tag: {}", tag.name)); }, Err(e) => { state.status_message = Some(format!("Delete error: {e}")); }, } } }, View::Collections => { if let Some(idx) = state.collection_selected && let Some(col) = state.collections.get(idx).cloned() { match client.delete_collection(&col.id).await { Ok(()) => { state.collections.remove(idx); if state.collections.is_empty() { state.collection_selected = None; } else if idx >= state.collections.len() { state.collection_selected = Some(state.collections.len() - 1); } state.status_message = Some(format!("Deleted collection: {}", col.name)); }, Err(e) => { state.status_message = Some(format!("Delete error: {e}")); }, } } }, _ => {}, } }, Action::Char(c) => { if state.input_mode { match state.current_view { View::Import => state.import_input.push(c), _ => state.search_input.push(c), } } }, Action::Backspace => { if state.input_mode { match state.current_view { View::Import => { state.import_input.pop(); }, _ => { state.search_input.pop(); }, } } }, Action::TagMedia => { // Tag the currently selected media with the currently selected tag if state.current_view == View::Detail { if let (Some(media), Some(tag_idx)) = (&state.selected_media, state.tag_selected) { if let Some(tag) = state.all_tags.get(tag_idx) { let media_id = media.id.clone(); let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); match client.tag_media(&media_id, &tag_id).await { Ok(()) => { state.status_message = Some(format!("Tagged with: {tag_name}")); // Refresh media tags if let Ok(tags) = client.get_media_tags(&media_id).await { state.tags = tags; } }, Err(e) => { state.status_message = Some(format!("Tag error: {e}")); }, } } } else { state.status_message = Some("Select a media item and tag first".into()); } } }, Action::UntagMedia => { // Untag the currently selected media from the currently selected tag if state.current_view == View::Detail { if let (Some(media), Some(tag_idx)) = (&state.selected_media, state.tag_selected) { if let Some(tag) = state.tags.get(tag_idx) { let media_id = media.id.clone(); let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); match client.untag_media(&media_id, &tag_id).await { Ok(()) => { state.status_message = Some(format!("Removed tag: {tag_name}")); // Refresh media tags if let Ok(tags) = client.get_media_tags(&media_id).await { state.tags = tags; } }, Err(e) => { state.status_message = Some(format!("Untag error: {e}")); }, } } } else { state.status_message = Some("Select a media item and tag first".into()); } } }, Action::Help => { state.status_message = Some( "?: Help q: Quit /: Search i: Import o: Open t: Tags c: \ Collections a: Audit b: Books s: Scan S: Settings r: Refresh \ Home/End: Top/Bottom" .into(), ); }, Action::Edit => { if state.current_view == View::Detail && let Some(ref media) = state.selected_media { // Populate edit fields from selected media state.edit_title = media.title.clone().unwrap_or_default(); state.edit_artist = media.artist.clone().unwrap_or_default(); state.edit_album = media.album.clone().unwrap_or_default(); state.edit_genre = media.genre.clone().unwrap_or_default(); state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default(); state.edit_description = media.description.clone().unwrap_or_default(); state.edit_field_index = Some(0); state.input_mode = true; state.current_view = View::MetadataEdit; } }, Action::UpdateReadingProgress => { if state.current_view == View::Detail && state.selected_media.is_some() { state.entering_page = true; state.page_input.clear(); state.status_message = Some("Enter page number (Enter to confirm, Esc to cancel)".into()); } }, Action::Vacuum => { if state.current_view == View::Database { state.status_message = Some("Vacuuming database...".to_string()); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.vacuum_database().await { Ok(()) => { tracing::info!("Database vacuum completed"); // Refresh stats after vacuum if let Ok(stats) = client.database_stats().await { let _ = tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))); } }, Err(e) => { tracing::error!("Vacuum failed: {}", e); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Vacuum failed: {e}" )))); }, } }); } }, Action::Toggle => { if state.current_view == View::Tasks && let Some(idx) = state.scheduled_tasks_selected && let Some(task) = state.scheduled_tasks.get(idx) { let task_id = task.id.clone(); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.toggle_scheduled_task(&task_id).await { Ok(()) => { // Refresh tasks list if let Ok(tasks) = client.list_scheduled_tasks().await { let _ = tx .send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); } }, Err(e) => { tracing::error!("Failed to toggle task: {}", e); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Toggle task failed: {e}" )))); }, } }); } }, Action::RunNow => { if state.current_view == View::Tasks && let Some(idx) = state.scheduled_tasks_selected && let Some(task) = state.scheduled_tasks.get(idx) { let task_id = task.id.clone(); let task_name = task.name.clone(); state.status_message = Some(format!("Running task: {task_name}...")); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { match client.run_task_now(&task_id).await { Ok(()) => { // Refresh tasks list if let Ok(tasks) = client.list_scheduled_tasks().await { let _ = tx .send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); } }, Err(e) => { tracing::error!("Failed to run task: {}", e); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Run task failed: {e}" )))); }, } }); } }, Action::Save => { if state.current_view == View::MetadataEdit && let Some(ref media) = state.selected_media { let updates = serde_json::json!({ "title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) }, "artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) }, "album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) }, "genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) }, "year": state.edit_year.parse::().ok(), "description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) }, }); let media_id = media.id.clone(); let client = client.clone(); let tx = event_sender.clone(); state.status_message = Some("Saving...".to_string()); tokio::spawn(async move { match client.update_media(&media_id, updates).await { Ok(_) => { let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated)); }, Err(e) => { tracing::error!("Failed to update media: {}", e); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Update failed: {e}" )))); }, } }); state.input_mode = false; state.current_view = View::Detail; } }, Action::ToggleSelection => { // Toggle selection of current item let item_id = match state.current_view { View::Search => { state .search_selected .and_then(|i| state.search_results.get(i)) .map(|m| m.id.clone()) }, View::Library => { state .selected_index .and_then(|i| state.media_list.get(i)) .map(|m| m.id.clone()) }, _ => None, }; if let Some(id) = item_id { if state.selected_items.contains(&id) { state.selected_items.remove(&id); } else { state.selected_items.insert(id); } let count = state.selected_items.len(); state.status_message = Some(format!("{count} item(s) selected")); } }, Action::SelectAll => { // Select all items in current view let items: Vec = match state.current_view { View::Search => { state.search_results.iter().map(|m| m.id.clone()).collect() }, View::Library => { state.media_list.iter().map(|m| m.id.clone()).collect() }, _ => Vec::new(), }; for id in items { state.selected_items.insert(id); } let count = state.selected_items.len(); state.status_message = Some(format!("{count} item(s) selected")); }, Action::ClearSelection => { state.selected_items.clear(); state.selection_mode = false; state.status_message = Some("Selection cleared".into()); }, Action::ToggleSelectionMode => { state.selection_mode = !state.selection_mode; if state.selection_mode { state.status_message = Some("Selection mode: ON (Space to toggle, u to clear)".into()); } else { state.status_message = Some("Selection mode: OFF".into()); } }, Action::BatchDelete => { if state.selected_items.is_empty() { state.status_message = Some("No items selected".into()); } else { let count = state.selected_items.len(); state.pending_batch_delete = true; state.status_message = Some(format!("Delete {count} item(s)? (y/n)")); } }, Action::ConfirmBatchDelete => { let count = state.selected_items.len(); let ids: Vec = state.selected_items.iter().cloned().collect(); state.status_message = Some(format!("Deleting {count} item(s)...")); let client = client.clone(); let tx = event_sender.clone(); let page_offset = state.page_offset; let page_size = state.page_size; tokio::spawn(async move { let mut deleted = 0; let mut errors = Vec::new(); for id in &ids { match client.delete_media(id).await { Ok(()) => deleted += 1, Err(e) => errors.push(format!("{id}: {e}")), } } // Refresh the media list if let Ok(items) = client.list_media(page_offset, page_size).await { let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))); } if errors.is_empty() { let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Deleted {deleted} item(s)" )))); } else { let error_count = errors.len(); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Deleted {deleted} item(s), {error_count} error(s)" )))); } }); state.selected_items.clear(); }, Action::BatchTag => { if state.selected_items.is_empty() { state.status_message = Some("No items selected".into()); } else if state.all_tags.is_empty() { // Load tags first match client.list_tags().await { Ok(tags) => { state.all_tags = tags; if state.all_tags.is_empty() { state.status_message = Some("No tags available. Create a tag first.".into()); } else { state.tag_selected = Some(0); state.status_message = Some(format!( "{} item(s) selected. Press +/- to tag/untag with selected \ tag.", state.selected_items.len() )); } }, Err(e) => { state.status_message = Some(format!("Failed to load tags: {e}")); }, } } else if let Some(tag_idx) = state.tag_selected && let Some(tag) = state.all_tags.get(tag_idx) { let count = state.selected_items.len(); let ids: Vec = state.selected_items.iter().cloned().collect(); let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); state.status_message = Some(format!("Tagging {count} item(s) with '{tag_name}'...")); let client = client.clone(); let tx = event_sender.clone(); tokio::spawn(async move { let mut tagged = 0; let mut errors = Vec::new(); for id in &ids { match client.tag_media(id, &tag_id).await { Ok(()) => tagged += 1, Err(e) => errors.push(format!("{id}: {e}")), } } if errors.is_empty() { let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Tagged {tagged} item(s) with '{tag_name}'" )))); } else { let error_count = errors.len(); let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( "Tagged {tagged} item(s), {error_count} error(s)" )))); } }); } else { state.status_message = Some("Select a tag first (use t to view tags)".into()); } }, Action::NavigateLeft | Action::NavigateRight | Action::None => {}, } }