pinakes: import in parallel; various UI improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
raf 2026-02-03 10:31:20 +03:00
commit 116fe7b059
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
42 changed files with 4316 additions and 316 deletions

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::Result;
@ -53,6 +54,9 @@ pub struct AppState {
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>,
@ -131,6 +135,9 @@ impl AppState {
page_size: 50,
total_media_count: 0,
server_url: server_url.to_string(),
// Multi-select
selected_items: HashSet::new(),
selection_mode: false,
}
}
}
@ -1156,6 +1163,154 @@ async fn handle_action(
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 => {}
}
}

View file

@ -43,6 +43,13 @@ pub enum Action {
Save,
Char(char),
Backspace,
// Multi-select actions
ToggleSelection,
SelectAll,
ClearSelection,
ToggleSelectionMode,
BatchDelete,
BatchTag,
None,
}
@ -87,13 +94,25 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
_ => Action::TagView,
},
(KeyCode::Char('c'), _) => Action::CollectionView,
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
(KeyCode::Char('a'), KeyModifiers::CONTROL) => match current_view {
View::Library | View::Search => Action::SelectAll,
_ => Action::None,
},
(KeyCode::Char('a'), _) => Action::AuditView,
(KeyCode::Char('S'), _) => Action::SettingsView,
(KeyCode::Char('D'), _) => Action::DuplicatesView,
(KeyCode::Char('B'), _) => Action::DatabaseView,
(KeyCode::Char('Q'), _) => Action::QueueView,
(KeyCode::Char('X'), _) => Action::StatisticsView,
(KeyCode::Char('T'), _) => Action::TasksView,
// Use plain D/T for views in non-library contexts, keep for batch ops in library/search
(KeyCode::Char('D'), _) => match current_view {
View::Library | View::Search => Action::BatchDelete,
_ => Action::DuplicatesView,
},
(KeyCode::Char('T'), _) => match current_view {
View::Library | View::Search => Action::BatchTag,
_ => Action::TasksView,
},
// Ctrl+S must come before plain 's' to ensure proper precedence
(KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view {
View::MetadataEdit => Action::Save,
@ -106,7 +125,7 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
(KeyCode::Char('-'), _) => Action::UntagMedia,
(KeyCode::Char('v'), _) => match current_view {
View::Database => Action::Vacuum,
_ => Action::None,
_ => Action::ToggleSelectionMode,
},
(KeyCode::Char('x'), _) => match current_view {
View::Tasks => Action::RunNow,
@ -116,6 +135,15 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
(KeyCode::BackTab, _) => Action::PrevTab,
(KeyCode::PageUp, _) => Action::PageUp,
(KeyCode::PageDown, _) => Action::PageDown,
// Multi-select keys
(KeyCode::Char(' '), _) => match current_view {
View::Library | View::Search => Action::ToggleSelection,
_ => Action::None,
},
(KeyCode::Char('u'), _) => match current_view {
View::Library | View::Search => Action::ClearSelection,
_ => Action::None,
},
_ => Action::None,
}
}

View file

@ -8,7 +8,7 @@ use super::{format_duration, format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style(
let header = Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
@ -19,12 +19,27 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
.iter()
.enumerate()
.map(|(i, item)| {
let style = if Some(i) == state.selected_index {
let is_cursor = Some(i) == state.selected_index;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
let type_color = media_type_color(&item.media_type);
@ -44,6 +59,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
.unwrap_or_else(|| "-".to_string());
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(display_name),
type_cell,
Cell::from(duration),
@ -56,16 +72,22 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let page = (state.page_offset / state.page_size) + 1;
let item_count = state.media_list.len();
let title = format!(" Library (page {page}, {item_count} items) ");
let selected_count = state.selected_items.len();
let title = if selected_count > 0 {
format!(" Library (page {page}, {item_count} items, {selected_count} selected) ")
} else {
format!(" Library (page {page}, {item_count} items) ")
};
let table = Table::new(
rows,
[
Constraint::Percentage(35),
Constraint::Percentage(20),
Constraint::Percentage(15),
Constraint::Percentage(10),
Constraint::Percentage(20),
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Title
Constraint::Percentage(18), // Type
Constraint::Percentage(13), // Duration
Constraint::Percentage(8), // Year
Constraint::Percentage(18), // Size
],
)
.header(header)

View file

@ -28,7 +28,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
f.render_widget(input, chunks[0]);
// Results
let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style(
let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
@ -39,12 +39,27 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
.iter()
.enumerate()
.map(|(i, item)| {
let style = if Some(i) == state.search_selected {
let is_cursor = Some(i) == state.search_selected;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
@ -52,6 +67,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
));
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(item.file_name.clone()),
type_cell,
Cell::from(item.artist.clone().unwrap_or_default()),
@ -63,15 +79,21 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let shown = state.search_results.len();
let total = state.search_total_count;
let results_title = format!(" Results: {shown} shown, {total} total ");
let selected_count = state.selected_items.len();
let results_title = if selected_count > 0 {
format!(" Results: {shown} shown, {total} total, {selected_count} selected ")
} else {
format!(" Results: {shown} shown, {total} total ")
};
let table = Table::new(
rows,
[
Constraint::Percentage(35),
Constraint::Percentage(20),
Constraint::Percentage(25),
Constraint::Percentage(20),
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Name
Constraint::Percentage(18), // Type
Constraint::Percentage(23), // Artist
Constraint::Percentage(18), // Size
],
)
.header(header)