Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
1384 lines
47 KiB
Rust
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 => {},
|
|
}
|
|
}
|