pinakes/crates/pinakes-tui/src/app.rs
NotAShelf 3ccddce7fd
treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
2026-03-06 18:29:33 +03:00

1384 lines
47 KiB
Rust

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},
event::{ApiResult, AppEvent, EventHandler},
input::{self, Action},
ui,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum View {
Library,
Search,
Detail,
Tags,
Collections,
Audit,
Import,
Settings,
Duplicates,
Database,
MetadataEdit,
Queue,
Statistics,
Tasks,
}
pub struct AppState {
pub current_view: View,
pub media_list: Vec<crate::client::MediaResponse>,
pub selected_index: Option<usize>,
pub selected_media: Option<crate::client::MediaResponse>,
pub search_input: String,
pub search_results: Vec<crate::client::MediaResponse>,
pub search_selected: Option<usize>,
pub search_total_count: u64,
pub tags: Vec<crate::client::TagResponse>,
pub all_tags: Vec<crate::client::TagResponse>,
pub tag_selected: Option<usize>,
pub collections: Vec<crate::client::CollectionResponse>,
pub collection_selected: Option<usize>,
pub audit_log: Vec<AuditEntryResponse>,
pub audit_selected: Option<usize>,
pub input_mode: bool,
pub import_input: String,
pub status_message: Option<String>,
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<String>,
pub selection_mode: bool,
// Duplicates view
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
pub duplicates_selected: Option<usize>,
// Database view
pub database_stats: Option<Vec<(String, String)>>,
// 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<usize>,
// Queue view
pub play_queue: Vec<QueueItem>,
pub queue_current_index: Option<usize>,
pub queue_selected: Option<usize>,
pub queue_repeat: u8,
pub queue_shuffle: bool,
// Statistics view
pub library_stats: Option<crate::client::LibraryStatisticsResponse>,
// Scheduled tasks view
pub scheduled_tasks: Vec<crate::client::ScheduledTaskResponse>,
pub scheduled_tasks_selected: Option<usize>,
}
#[derive(Clone)]
pub struct QueueItem {
pub media_id: String,
pub title: String,
pub artist: Option<String>,
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,
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,
}
}
}
pub async fn run(server_url: &str) -> Result<()> {
let client = ApiClient::new(server_url);
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) => {
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<String> =
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::MediaUpdated => {
state.status_message = Some("Media 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<AppEvent>,
) {
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(),
_ => 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,
_ => &mut state.selected_index,
};
*idx = Some(idx.map(|i| (i + 1).min(len - 1)).unwrap_or(0));
}
},
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,
_ => &mut state.selected_index,
};
*idx = Some(idx.map(|i| i.saturating_sub(1)).unwrap_or(0));
},
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,
_ => &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(),
_ => 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,
_ => &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 {
match client.get_media(&media.id).await {
Ok(full_media) => {
// 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();
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;
}
state.current_view = View::Detail;
},
Err(_) => {
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::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::Search => {
// Nothing to refresh for search without a query
},
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::MetadataEdit | View::Queue => {
// No generic refresh for these views
},
}
});
},
Action::NextTab => {
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::Queue,
View::Queue => View::Statistics,
View::Statistics => View::Tasks,
View::Tasks => View::Library,
View::Detail
| View::Import
| View::Settings
| View::Duplicates
| View::Database
| View::MetadataEdit => View::Library,
};
},
Action::PrevTab => {
state.current_view = match state.current_view {
View::Library => View::Tasks,
View::Search => View::Library,
View::Tags => View::Search,
View::Collections => View::Tags,
View::Audit => View::Collections,
View::Queue => View::Audit,
View::Statistics => View::Queue,
View::Tasks => View::Statistics,
View::Detail
| View::Import
| View::Settings
| View::Duplicates
| View::Database
| View::MetadataEdit => View::Library,
};
},
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 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::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::<i32>().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!("{} item(s) selected", count));
}
},
Action::SelectAll => {
// Select all items in current view
let items: Vec<String> = 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!("{} item(s) selected", count));
},
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();
let ids: Vec<String> = state.selected_items.iter().cloned().collect();
state.status_message = Some(format!("Deleting {} item(s)...", count));
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 {} item(s)",
deleted
))));
} else {
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Deleted {} item(s), {} error(s)",
deleted,
errors.len()
))));
}
});
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<String> = state.selected_items.iter().cloned().collect();
let tag_id = tag.id.clone();
let tag_name = tag.name.clone();
state.status_message =
Some(format!("Tagging {} item(s) with '{}'...", count, 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 {} item(s) with '{}'",
tagged, tag_name
))));
} else {
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Tagged {} item(s), {} error(s)",
tagged,
errors.len()
))));
}
});
} else {
state.status_message =
Some("Select a tag first (use t to view tags)".into());
}
},
Action::NavigateLeft | Action::NavigateRight | Action::None => {},
}
}