pinakes-tui: add book management view and api key authentication

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I20f205d9e06a93a89e8f4433ed6f80576a6a6964
This commit is contained in:
raf 2026-03-08 00:42:34 +03:00
commit 66861b8a20
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
18 changed files with 917 additions and 251 deletions

View file

@ -8,13 +8,18 @@ use crossterm::{
use ratatui::{Terminal, backend::CrosstermBackend};
use crate::{
client::{ApiClient, AuditEntryResponse},
client::{
ApiClient,
AuditEntryResponse,
BookMetadataResponse,
ReadingProgressResponse,
},
event::{ApiResult, AppEvent, EventHandler},
input::{self, Action},
ui,
};
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Library,
Search,
@ -30,8 +35,20 @@ pub enum View {
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<crate::client::MediaResponse>,
@ -59,6 +76,7 @@ pub struct AppState {
// Multi-select support
pub selected_items: HashSet<String>,
pub selection_mode: bool,
pub pending_batch_delete: bool,
// Duplicates view
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
pub duplicates_selected: Option<usize>,
@ -83,6 +101,18 @@ pub struct AppState {
// Scheduled tasks view
pub scheduled_tasks: Vec<crate::client::ScheduledTaskResponse>,
pub scheduled_tasks_selected: Option<usize>,
// Books view
pub books_list: Vec<crate::client::MediaResponse>,
pub books_series: Vec<crate::client::SeriesSummary>,
pub books_authors: Vec<crate::client::AuthorSummary>,
pub books_selected: Option<usize>,
pub books_sub_view: BooksSubView,
// Book detail metadata
pub book_metadata: Option<BookMetadataResponse>,
pub reading_progress: Option<ReadingProgressResponse>,
// Reading progress input (page number)
pub page_input: String,
pub entering_page: bool,
}
#[derive(Clone)]
@ -133,6 +163,16 @@ impl AppState {
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,
@ -140,12 +180,13 @@ impl AppState {
// Multi-select
selected_items: HashSet::new(),
selection_mode: false,
pending_batch_delete: false,
}
}
}
pub async fn run(server_url: &str) -> Result<()> {
let client = ApiClient::new(server_url);
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
@ -179,9 +220,78 @@ pub async fn run(server_url: &str) -> Result<()> {
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;
// 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::<i32>() {
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) => {
@ -293,9 +403,27 @@ fn handle_api_result(state: &mut AppState, result: ApiResult) {
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}"));
},
@ -316,6 +444,13 @@ async fn handle_action(
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 {
@ -324,9 +459,10 @@ async fn handle_action(
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(|i| (i + 1).min(len - 1)).unwrap_or(0));
*idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1)));
}
},
Action::NavigateUp => {
@ -335,9 +471,10 @@ async fn handle_action(
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(|i| i.saturating_sub(1)).unwrap_or(0));
*idx = Some(idx.map_or(0, |i| i.saturating_sub(1)));
},
Action::GoTop => {
let idx = match state.current_view {
@ -345,6 +482,7 @@ async fn handle_action(
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);
@ -355,6 +493,13 @@ async fn handle_action(
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 {
@ -363,6 +508,7 @@ async fn handle_action(
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);
@ -468,26 +614,29 @@ async fn handle_action(
},
};
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;
},
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;
}
}
},
@ -511,14 +660,14 @@ async fn handle_action(
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()),
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()),
Ok(()) => state.status_message = Some("Opened file".into()),
Err(e) => state.status_message = Some(format!("Open error: {e}")),
}
}
@ -528,7 +677,7 @@ async fn handle_action(
&& let Some(media) = state.media_list.get(idx).cloned()
{
match client.delete_media(&media.id).await {
Ok(_) => {
Ok(()) => {
state.media_list.remove(idx);
if state.media_list.is_empty() {
state.selected_index = None;
@ -563,7 +712,7 @@ async fn handle_action(
state.collections = cols;
},
Err(e) => {
state.status_message = Some(format!("Collections error: {e}"))
state.status_message = Some(format!("Collections error: {e}"));
},
}
},
@ -698,6 +847,39 @@ async fn handle_action(
}
});
},
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();
@ -803,9 +985,6 @@ async fn handle_action(
},
}
},
View::Search => {
// Nothing to refresh for search without a query
},
View::Duplicates => {
match client.find_duplicates().await {
Ok(groups) => {
@ -878,47 +1057,108 @@ async fn handle_action(
},
}
},
View::MetadataEdit | View::Queue => {
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 => {
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,
};
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 => {
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,
};
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;
@ -963,7 +1203,7 @@ async fn handle_action(
&& let Some(tag) = state.tags.get(idx).cloned()
{
match client.delete_tag(&tag.id).await {
Ok(_) => {
Ok(()) => {
state.tags.remove(idx);
if state.tags.is_empty() {
state.tag_selected = None;
@ -974,7 +1214,7 @@ async fn handle_action(
Some(format!("Deleted tag: {}", tag.name));
},
Err(e) => {
state.status_message = Some(format!("Delete error: {e}"))
state.status_message = Some(format!("Delete error: {e}"));
},
}
}
@ -984,7 +1224,7 @@ async fn handle_action(
&& let Some(col) = state.collections.get(idx).cloned()
{
match client.delete_collection(&col.id).await {
Ok(_) => {
Ok(()) => {
state.collections.remove(idx);
if state.collections.is_empty() {
state.collection_selected = None;
@ -995,7 +1235,7 @@ async fn handle_action(
Some(format!("Deleted collection: {}", col.name));
},
Err(e) => {
state.status_message = Some(format!("Delete error: {e}"))
state.status_message = Some(format!("Delete error: {e}"));
},
}
}
@ -1034,7 +1274,7 @@ async fn handle_action(
let tag_id = tag.id.clone();
let tag_name = tag.name.clone();
match client.tag_media(&media_id, &tag_id).await {
Ok(_) => {
Ok(()) => {
state.status_message = Some(format!("Tagged with: {tag_name}"));
// Refresh media tags
if let Ok(tags) = client.get_media_tags(&media_id).await {
@ -1063,7 +1303,7 @@ async fn handle_action(
let tag_id = tag.id.clone();
let tag_name = tag.name.clone();
match client.untag_media(&media_id, &tag_id).await {
Ok(_) => {
Ok(()) => {
state.status_message = Some(format!("Removed tag: {tag_name}"));
// Refresh media tags
if let Ok(tags) = client.get_media_tags(&media_id).await {
@ -1084,8 +1324,8 @@ async fn handle_action(
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"
Collections a: Audit b: Books s: Scan S: Settings r: Refresh \
Home/End: Top/Bottom"
.into(),
);
},
@ -1105,6 +1345,14 @@ async fn handle_action(
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());
@ -1243,7 +1491,7 @@ async fn handle_action(
state.selected_items.insert(id);
}
let count = state.selected_items.len();
state.status_message = Some(format!("{} item(s) selected", count));
state.status_message = Some(format!("{count} item(s) selected"));
}
},
Action::SelectAll => {
@ -1261,7 +1509,7 @@ async fn handle_action(
state.selected_items.insert(id);
}
let count = state.selected_items.len();
state.status_message = Some(format!("{} item(s) selected", count));
state.status_message = Some(format!("{count} item(s) selected"));
},
Action::ClearSelection => {
state.selected_items.clear();
@ -1282,41 +1530,44 @@ async fn handle_action(
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();
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<String> = 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());
@ -1338,7 +1589,7 @@ async fn handle_action(
}
},
Err(e) => {
state.status_message = Some(format!("Failed to load tags: {e}"))
state.status_message = Some(format!("Failed to load tags: {e}"));
},
}
} else if let Some(tag_idx) = state.tag_selected
@ -1349,7 +1600,7 @@ async fn handle_action(
let tag_id = tag.id.clone();
let tag_name = tag.name.clone();
state.status_message =
Some(format!("Tagging {} item(s) with '{}'...", count, tag_name));
Some(format!("Tagging {count} item(s) with '{tag_name}'..."));
let client = client.clone();
let tx = event_sender.clone();
tokio::spawn(async move {
@ -1357,20 +1608,18 @@ async fn handle_action(
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)),
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
"Tagged {tagged} item(s) with '{tag_name}'"
))));
} else {
let error_count = errors.len();
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Tagged {} item(s), {} error(s)",
tagged,
errors.len()
"Tagged {tagged} item(s), {error_count} error(s)"
))));
}
});