meta: move public crates to packages/

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I928162008cb1ba02e1aa0e7aa971e8326a6a6964
This commit is contained in:
raf 2026-03-23 02:32:37 +03:00
commit 00bab69598
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
308 changed files with 53890 additions and 53889 deletions

View file

@ -0,0 +1,24 @@
[package]
name = "pinakes-tui"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
rustc-hash = { workspace = true }
[lints]
workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,991 @@
use anyhow::Result;
use reqwest::Client;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
}
// Response types (mirror server DTOs)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MediaResponse {
pub id: String,
pub path: String,
pub file_name: String,
pub media_type: String,
pub content_hash: String,
pub file_size: u64,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub duration_secs: Option<f64>,
pub description: Option<String>,
#[serde(default)]
pub has_thumbnail: bool,
pub custom_fields: FxHashMap<String, CustomFieldResponse>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CustomFieldResponse {
pub field_type: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ImportResponse {
pub media_id: String,
pub was_duplicate: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TagResponse {
pub id: String,
pub name: String,
pub parent_id: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CollectionResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub kind: String,
pub filter_query: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SearchResponse {
pub items: Vec<MediaResponse>,
pub total_count: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuditEntryResponse {
pub id: String,
pub media_id: Option<String>,
pub action: String,
pub details: Option<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScanResponse {
pub files_found: usize,
pub files_processed: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseStatsResponse {
pub media_count: u64,
pub tag_count: u64,
pub collection_count: u64,
pub audit_count: u64,
pub database_size_bytes: u64,
pub backend_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DuplicateGroupResponse {
pub content_hash: String,
pub items: Vec<MediaResponse>,
}
/// Background job response from the API.
#[derive(Debug, Clone, Deserialize)]
pub struct JobResponse {
pub id: String,
pub kind: serde_json::Value,
pub status: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduledTaskResponse {
pub id: String,
pub name: String,
pub schedule: String,
pub enabled: bool,
pub last_run: Option<String>,
pub next_run: Option<String>,
pub last_status: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LibraryStatisticsResponse {
pub total_media: u64,
pub total_size_bytes: u64,
pub avg_file_size_bytes: u64,
pub media_by_type: Vec<TypeCount>,
pub storage_by_type: Vec<TypeCount>,
pub newest_item: Option<String>,
pub oldest_item: Option<String>,
pub top_tags: Vec<TypeCount>,
pub top_collections: Vec<TypeCount>,
pub total_tags: u64,
pub total_collections: u64,
pub total_duplicates: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TypeCount {
pub name: String,
pub count: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BookMetadataResponse {
pub media_id: String,
pub title: Option<String>,
pub subtitle: Option<String>,
pub publisher: Option<String>,
pub language: Option<String>,
pub isbn: Option<String>,
pub isbn13: Option<String>,
pub page_count: Option<i32>,
pub series: Option<String>,
pub series_index: Option<f64>,
pub authors: Vec<BookAuthorResponse>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BookAuthorResponse {
pub name: String,
pub role: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ReadingProgressResponse {
pub media_id: String,
pub current_page: i32,
pub total_pages: Option<i32>,
pub status: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SeriesSummary {
pub name: String,
pub count: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorSummary {
pub name: String,
pub count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlaylistResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommentResponse {
pub text: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TranscodeSessionResponse {
pub id: String,
pub profile: String,
pub status: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleEntry {
pub language: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleListResponse {
pub subtitles: Vec<SubtitleEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceResponse {
pub id: String,
pub name: String,
pub device_type: Option<String>,
pub last_seen: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebhookInfo {
#[serde(default)]
pub id: String,
pub url: String,
pub events: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserResponse {
pub id: String,
pub username: String,
pub role: String,
pub created_at: String,
}
impl ApiClient {
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
let client = api_key.map_or_else(Client::new, |key| {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(val) =
reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))
{
headers.insert(reqwest::header::AUTHORIZATION, val);
}
Client::builder()
.default_headers(headers)
.build()
.unwrap_or_else(|e| {
tracing::warn!(
"failed to build authenticated HTTP client: {e}; falling back to \
unauthenticated client"
);
Client::new()
})
});
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
}
}
fn url(&self, path: &str) -> String {
format!("{}/api/v1{}", self.base_url, path)
}
pub async fn list_media(
&self,
offset: u64,
limit: u64,
) -> Result<Vec<MediaResponse>> {
let resp = self
.client
.get(self.url("/media"))
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn get_media(&self, id: &str) -> Result<MediaResponse> {
let resp = self
.client
.get(self.url(&format!("/media/{id}")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn import_file(&self, path: &str) -> Result<ImportResponse> {
let resp = self
.client
.post(self.url("/media/import"))
.json(&serde_json::json!({"path": path}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_media(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/media/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn open_media(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{id}/open")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn search(
&self,
query: &str,
offset: u64,
limit: u64,
) -> Result<SearchResponse> {
let resp = self
.client
.get(self.url("/search"))
.query(&[
("q", query.to_string()),
("offset", offset.to_string()),
("limit", limit.to_string()),
])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_tags(&self) -> Result<Vec<TagResponse>> {
let resp = self
.client
.get(self.url("/tags"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn create_tag(
&self,
name: &str,
parent_id: Option<&str>,
) -> Result<TagResponse> {
let mut body = serde_json::json!({"name": name});
if let Some(pid) = parent_id {
body["parent_id"] = serde_json::Value::String(pid.to_string());
}
let resp = self
.client
.post(self.url("/tags"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_tag(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/tags/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/tags")))
.json(&serde_json::json!({"tag_id": tag_id}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/media/{media_id}/tags/{tag_id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_media_tags(
&self,
media_id: &str,
) -> Result<Vec<TagResponse>> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/tags")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_collections(&self) -> Result<Vec<CollectionResponse>> {
let resp = self
.client
.get(self.url("/collections"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_collection(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/collections/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn trigger_scan(
&self,
path: Option<&str>,
) -> Result<Vec<ScanResponse>> {
let body = path.map_or_else(
|| serde_json::json!({"path": null}),
|p| serde_json::json!({"path": p}),
);
let resp = self
.client
.post(self.url("/scan"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_audit(
&self,
offset: u64,
limit: u64,
) -> Result<Vec<AuditEntryResponse>> {
let resp = self
.client
.get(self.url("/audit"))
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn find_duplicates(&self) -> Result<Vec<DuplicateGroupResponse>> {
let resp = self
.client
.get(self.url("/duplicates"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
let resp = self
.client
.get(self.url("/database/stats"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_jobs(&self) -> Result<Vec<JobResponse>> {
let resp = self
.client
.get(self.url("/jobs"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn vacuum_database(&self) -> Result<()> {
self
.client
.post(self.url("/database/vacuum"))
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn update_media(
&self,
id: &str,
updates: serde_json::Value,
) -> Result<MediaResponse> {
let resp = self
.client
.patch(self.url(&format!("/media/{id}")))
.json(&updates)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn library_statistics(&self) -> Result<LibraryStatisticsResponse> {
let resp = self
.client
.get(self.url("/statistics"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_scheduled_tasks(
&self,
) -> Result<Vec<ScheduledTaskResponse>> {
let resp = self
.client
.get(self.url("/tasks/scheduled"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/tasks/scheduled/{id}/toggle")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn run_task_now(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/tasks/scheduled/{id}/run-now")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_book_metadata(
&self,
media_id: &str,
) -> Result<BookMetadataResponse> {
let resp = self
.client
.get(self.url(&format!("/books/{media_id}/metadata")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_books(
&self,
offset: u64,
limit: u64,
) -> Result<Vec<MediaResponse>> {
let resp = self
.client
.get(self.url("/books"))
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_series(&self) -> Result<Vec<SeriesSummary>> {
let resp = self
.client
.get(self.url("/books/series"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_book_authors(&self) -> Result<Vec<AuthorSummary>> {
let resp = self
.client
.get(self.url("/books/authors"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn get_reading_progress(
&self,
media_id: &str,
) -> Result<ReadingProgressResponse> {
let resp = self
.client
.get(self.url(&format!("/books/{media_id}/progress")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn update_reading_progress(
&self,
media_id: &str,
current_page: i32,
) -> Result<()> {
self
.client
.put(self.url(&format!("/books/{media_id}/progress")))
.json(&serde_json::json!({"current_page": current_page}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_playlists(&self) -> Result<Vec<PlaylistResponse>> {
let resp = self
.client
.get(self.url("/playlists"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn create_playlist(
&self,
name: &str,
description: Option<&str>,
) -> Result<PlaylistResponse> {
let mut body = serde_json::json!({"name": name});
if let Some(desc) = description {
body["description"] = serde_json::Value::String(desc.to_string());
}
let resp = self
.client
.post(self.url("/playlists"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_playlist(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/playlists/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_playlist_items(
&self,
id: &str,
) -> Result<Vec<MediaResponse>> {
let resp = self
.client
.get(self.url(&format!("/playlists/{id}/items")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn remove_from_playlist(
&self,
playlist_id: &str,
media_id: &str,
) -> Result<()> {
self
.client
.delete(self.url(&format!("/playlists/{playlist_id}/items/{media_id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn shuffle_playlist(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/playlists/{id}/shuffle")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn rate_media(&self, media_id: &str, stars: u8) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/ratings")))
.json(&serde_json::json!({"stars": stars}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_comment(
&self,
media_id: &str,
text: &str,
) -> Result<CommentResponse> {
let resp = self
.client
.post(self.url(&format!("/media/{media_id}/comments")))
.json(&serde_json::json!({"text": text}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_comments(
&self,
media_id: &str,
) -> Result<Vec<CommentResponse>> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/comments")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn toggle_favorite(&self, media_id: &str) -> Result<()> {
// Try POST to add; if it fails with conflict, DELETE to remove
let post_resp = self
.client
.post(self.url("/favorites"))
.json(&serde_json::json!({"media_id": media_id}))
.send()
.await?;
if post_resp.status() == reqwest::StatusCode::CONFLICT
|| post_resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY
{
// Already a favorite: remove it
self
.client
.delete(self.url(&format!("/favorites/{media_id}")))
.send()
.await?
.error_for_status()?;
} else {
post_resp.error_for_status()?;
}
Ok(())
}
pub async fn enrich_media(&self, media_id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/enrich")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn start_transcode(
&self,
media_id: &str,
profile: &str,
) -> Result<TranscodeSessionResponse> {
let resp = self
.client
.post(self.url(&format!("/media/{media_id}/transcode")))
.json(&serde_json::json!({"profile": profile}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_transcodes(&self) -> Result<Vec<TranscodeSessionResponse>> {
let resp = self
.client
.get(self.url("/transcode"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn cancel_transcode(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/transcode/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_subtitles(
&self,
media_id: &str,
) -> Result<SubtitleListResponse> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/subtitles")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_sync_devices(&self) -> Result<Vec<DeviceResponse>> {
let resp = self
.client
.get(self.url("/sync/devices"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_sync_device(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/sync/devices/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_webhooks(&self) -> Result<Vec<WebhookInfo>> {
let resp = self
.client
.get(self.url("/webhooks"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn test_webhook(&self, id: &str) -> Result<()> {
self
.client
.post(self.url("/webhooks/test"))
.json(&serde_json::json!({"id": id}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_users(&self) -> Result<Vec<UserResponse>> {
let resp = self
.client
.get(self.url("/users"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_user(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/users/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_to_playlist(
&self,
playlist_id: &str,
media_id: &str,
) -> Result<()> {
let body = serde_json::json!({"media_id": media_id});
let resp = self
.client
.post(self.url(&format!("/playlists/{playlist_id}/items")))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
anyhow::bail!("add to playlist failed: {}", resp.status())
}
}
}

View file

@ -0,0 +1,93 @@
use std::time::Duration;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
use tokio::sync::mpsc;
#[derive(Debug)]
pub enum AppEvent {
Key(KeyEvent),
Tick,
ApiResult(ApiResult),
}
#[derive(Debug)]
pub enum ApiResult {
MediaList(Vec<crate::client::MediaResponse>),
SearchResults(crate::client::SearchResponse),
AllTags(Vec<crate::client::TagResponse>),
Collections(Vec<crate::client::CollectionResponse>),
ImportDone(crate::client::ImportResponse),
ScanDone(Vec<crate::client::ScanResponse>),
AuditLog(Vec<crate::client::AuditEntryResponse>),
Duplicates(Vec<crate::client::DuplicateGroupResponse>),
DatabaseStats(crate::client::DatabaseStatsResponse),
Statistics(crate::client::LibraryStatisticsResponse),
ScheduledTasks(Vec<crate::client::ScheduledTaskResponse>),
BooksList(Vec<crate::client::MediaResponse>),
BookSeries(Vec<crate::client::SeriesSummary>),
BookAuthors(Vec<crate::client::AuthorSummary>),
MediaUpdated,
ReadingProgressUpdated,
// Playlists
PlaylistsLoaded(Vec<crate::client::PlaylistResponse>),
PlaylistItemsLoaded(Vec<crate::client::MediaResponse>),
// Social
CommentsLoaded(Vec<crate::client::CommentResponse>),
RatingSet(u8),
FavoriteToggled,
// Subtitles
SubtitlesLoaded(crate::client::SubtitleListResponse),
// Enrichment / transcode
EnrichmentTriggered,
TranscodeStarted,
// Admin
UsersLoaded(Vec<crate::client::UserResponse>),
SyncDevicesLoaded(Vec<crate::client::DeviceResponse>),
WebhooksLoaded(Vec<crate::client::WebhookInfo>),
TranscodesLoaded(Vec<crate::client::TranscodeSessionResponse>),
Error(String),
}
pub struct EventHandler {
tx: mpsc::UnboundedSender<AppEvent>,
rx: mpsc::UnboundedReceiver<AppEvent>,
}
impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let event_tx = tx.clone();
std::thread::spawn(move || {
loop {
match event::poll(tick_rate) {
Ok(true) => {
if let Ok(CrosstermEvent::Key(key)) = event::read()
&& event_tx.send(AppEvent::Key(key)).is_err()
{
break;
}
},
Ok(false) => {
if event_tx.send(AppEvent::Tick).is_err() {
break;
}
},
Err(e) => {
tracing::warn!(error = %e, "event poll failed");
},
}
}
});
Self { tx, rx }
}
pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
self.tx.clone()
}
pub async fn next(&mut self) -> Option<AppEvent> {
self.rx.recv().await
}
}

View file

@ -0,0 +1,289 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::View;
pub enum Action {
Quit,
NavigateUp,
NavigateDown,
NavigateLeft,
NavigateRight,
Select,
Back,
Search,
Import,
Delete,
DeleteSelected,
Open,
TagView,
CollectionView,
AuditView,
SettingsView,
DuplicatesView,
DatabaseView,
QueueView,
StatisticsView,
TasksView,
BooksView,
ScanTrigger,
Refresh,
NextTab,
PrevTab,
PageUp,
PageDown,
GoTop,
GoBottom,
CreateTag,
TagMedia,
UntagMedia,
Help,
Edit,
Vacuum,
Toggle,
RunNow,
Save,
Char(char),
Backspace,
// Multi-select actions
ToggleSelection,
SelectAll,
ClearSelection,
ToggleSelectionMode,
BatchDelete,
ConfirmBatchDelete,
BatchTag,
UpdateReadingProgress,
// Playlists
PlaylistsView,
CreatePlaylist,
DeletePlaylist,
RemoveFromPlaylist,
ShufflePlaylist,
AddToPlaylist,
// Social / detail
ToggleFavorite,
RateMedia,
AddComment,
EnrichMedia,
ToggleSubtitles,
// Transcode
ToggleTranscodes,
CancelTranscode,
// Admin
AdminView,
AdminTabNext,
AdminTabPrev,
DeleteUser,
DeleteDevice,
TestWebhook,
None,
}
#[expect(
clippy::missing_const_for_fn,
reason = "match arms return non-trivially constructed enum variants"
)]
pub fn handle_key(
key: KeyEvent,
in_input_mode: bool,
current_view: View,
) -> Action {
if in_input_mode {
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => Action::Back,
(KeyCode::Enter, _) => Action::Select,
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
match current_view {
View::MetadataEdit => Action::Save,
_ => Action::Select,
}
},
(KeyCode::Char(c), _) => Action::Char(c),
(KeyCode::Backspace, _) => Action::Backspace,
_ => Action::None,
}
} else {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
Action::Quit
},
(KeyCode::Up | KeyCode::Char('k'), _) => Action::NavigateUp,
(KeyCode::Down | KeyCode::Char('j'), _) => Action::NavigateDown,
(KeyCode::Left | KeyCode::Char('h'), _) => Action::NavigateLeft,
(KeyCode::Right | KeyCode::Char('l'), _) => Action::NavigateRight,
(KeyCode::Home, _) => Action::GoTop,
(KeyCode::End, _) => Action::GoBottom,
(KeyCode::Enter, _) => Action::Select,
(KeyCode::Esc, _) => Action::Back,
(KeyCode::Char('/'), _) => Action::Search,
(KeyCode::Char('?'), _) => Action::Help,
(KeyCode::Char('i'), _) => Action::Import,
(KeyCode::Char('d'), _) => {
match current_view {
View::Tags | View::Collections => Action::DeleteSelected,
View::Playlists => Action::DeletePlaylist,
View::Admin => Action::DeleteUser,
_ => Action::Delete,
}
},
(KeyCode::Char('o'), _) => Action::Open,
(KeyCode::Char('e'), _) => {
match current_view {
View::Detail => Action::Edit,
_ => Action::None,
}
},
(KeyCode::Char('p'), _) => {
match current_view {
View::Detail => Action::UpdateReadingProgress,
_ => Action::PlaylistsView,
}
},
(KeyCode::Char('t'), _) => {
match current_view {
View::Tasks => Action::Toggle,
View::Detail => Action::ToggleTranscodes,
_ => Action::TagView,
}
},
(KeyCode::Char('c'), _) => {
match current_view {
View::Detail => Action::AddComment,
_ => 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('b'), _) => Action::BooksView,
(KeyCode::Char('S'), _) => {
match current_view {
View::Playlists => Action::ShufflePlaylist,
_ => Action::SettingsView,
}
},
(KeyCode::Char('B'), _) => Action::DatabaseView,
(KeyCode::Char('Q'), _) => Action::QueueView,
(KeyCode::Char('X'), _) => Action::StatisticsView,
(KeyCode::Char('A'), _) => Action::AdminView,
// 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,
View::Admin => Action::DeleteDevice,
_ => 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,
_ => Action::None,
}
},
(KeyCode::Char('s'), _) => Action::ScanTrigger,
(KeyCode::Char('r'), _) => Action::Refresh,
(KeyCode::Char('n'), _) => {
match current_view {
View::Playlists => Action::CreatePlaylist,
_ => Action::CreateTag,
}
},
(KeyCode::Char('+'), _) => {
match current_view {
View::Library | View::Search => Action::AddToPlaylist,
_ => Action::TagMedia,
}
},
(KeyCode::Char('-'), _) => {
match current_view {
View::Playlists => Action::RemoveFromPlaylist,
_ => Action::UntagMedia,
}
},
(KeyCode::Char('v'), _) => {
match current_view {
View::Database => Action::Vacuum,
_ => Action::ToggleSelectionMode,
}
},
(KeyCode::Char('x'), _) => {
match current_view {
View::Tasks => Action::RunNow,
View::Detail => Action::CancelTranscode,
_ => Action::None,
}
},
(KeyCode::Char('f'), _) => {
match current_view {
View::Detail => Action::ToggleFavorite,
_ => Action::None,
}
},
(KeyCode::Char('R'), _) => {
match current_view {
View::Detail => Action::RateMedia,
_ => Action::None,
}
},
(KeyCode::Char('E'), _) => {
match current_view {
View::Detail => Action::EnrichMedia,
_ => Action::None,
}
},
(KeyCode::Char('U'), _) => {
match current_view {
View::Detail => Action::ToggleSubtitles,
_ => Action::None,
}
},
(KeyCode::Char('w'), _) => {
match current_view {
View::Admin => Action::TestWebhook,
_ => Action::None,
}
},
(KeyCode::Tab, _) => {
match current_view {
View::Admin => Action::AdminTabNext,
_ => Action::NextTab,
}
},
(KeyCode::BackTab, _) => {
match current_view {
View::Admin => Action::AdminTabPrev,
_ => 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

@ -0,0 +1,61 @@
use anyhow::Result;
use clap::Parser;
use tracing_subscriber::EnvFilter;
mod app;
mod client;
mod event;
mod input;
mod ui;
/// Pinakes terminal UI client
#[derive(Parser)]
#[command(name = "pinakes-tui", version, about)]
struct Cli {
/// Server URL to connect to
#[arg(
short,
long,
env = "PINAKES_SERVER_URL",
default_value = "http://localhost:3000"
)]
server: String,
/// API key for bearer token authentication
#[arg(long, env = "PINAKES_API_KEY")]
api_key: Option<String>,
/// Set log level (trace, debug, info, warn, error)
#[arg(long, default_value = "warn")]
log_level: String,
/// Log to file instead of stderr (avoids corrupting TUI display)
#[arg(long)]
log_file: Option<std::path::PathBuf>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize logging - for TUI, must log to file to avoid corrupting the
// display
let env_filter = EnvFilter::try_new(&cli.log_level)
.unwrap_or_else(|_| EnvFilter::new("warn"));
if let Some(log_path) = &cli.log_file {
let file = std::fs::File::create(log_path)?;
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(file)
.with_ansi(false)
.init();
} else {
// When no log file specified, suppress all output to avoid TUI corruption
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new("off"))
.init();
}
app::run(&cli.server, cli.api_key.as_deref()).await
}

View file

@ -0,0 +1,174 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Row, Table, Tabs},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
render_tab_bar(f, state, chunks[0]);
match state.admin_tab {
0 => render_users(f, state, chunks[1]),
1 => render_devices(f, state, chunks[1]),
_ => render_webhooks(f, state, chunks[1]),
}
}
fn render_tab_bar(f: &mut Frame, state: &AppState, area: Rect) {
let titles: Vec<Line> = vec!["Users", "Sync Devices", "Webhooks"]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" Admin "))
.select(state.admin_tab)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn render_users(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Username", "Role", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.users_list
.iter()
.enumerate()
.map(|(i, user)| {
let style = if i == state.users_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
let role_color = match user.role.as_str() {
"admin" => Color::Red,
"editor" => Color::Yellow,
_ => Color::White,
};
Style::default().fg(role_color)
};
Row::new(vec![
user.username.clone(),
user.role.clone(),
format_date(&user.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Users ({}) ", state.users_list.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(40),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_devices(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Type", "Last Seen"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.sync_devices
.iter()
.enumerate()
.map(|(i, dev)| {
let style = if i == state.sync_devices_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![
dev.name.clone(),
dev.device_type.clone().unwrap_or_else(|| "-".into()),
dev
.last_seen
.as_deref()
.map_or("-", format_date)
.to_string(),
])
.style(style)
})
.collect();
let title = format!(" Sync Devices ({}) ", state.sync_devices.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(40),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_webhooks(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
let header = Row::new(vec!["URL", "Events"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.webhooks
.iter()
.enumerate()
.map(|(i, wh)| {
let style = if i == state.webhooks_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let events = if wh.events.is_empty() {
"-".to_string()
} else {
wh.events.join(", ")
};
Row::new(vec![wh.url.clone(), events]).style(style)
})
.collect();
let title = format!(" Webhooks ({}) ", state.webhooks.len());
let table = Table::new(rows, [
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, chunks[0]);
}

View file

@ -0,0 +1,83 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Cell, Row, Table},
};
use super::format_date;
use crate::app::AppState;
/// Return a color for an audit action string.
fn action_color(action: &str) -> Color {
match action {
"imported" | "import" | "created" => Color::Green,
"deleted" | "delete" | "removed" => Color::Red,
"tagged" | "tag_added" => Color::Cyan,
"untagged" | "tag_removed" => Color::Yellow,
"updated" | "modified" | "edited" => Color::Blue,
"scanned" | "scan" => Color::Magenta,
_ => Color::White,
}
}
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.audit_log
.iter()
.enumerate()
.map(|(i, entry)| {
let style = if Some(i) == state.audit_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let color = action_color(&entry.action);
let action_cell = Cell::from(Span::styled(
entry.action.clone(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
// Truncate media ID for display
let media_display = entry.media_id.as_deref().map_or_else(
|| "-".into(),
|id| {
if id.len() > 12 {
format!("{}...", &id[..12])
} else {
id.to_string()
}
},
);
Row::new(vec![
action_cell,
Cell::from(media_display),
Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())),
Cell::from(format_date(&entry.timestamp).to_string()),
])
.style(style)
})
.collect();
let title = format!(" Audit Log ({}) ", state.audit_log.len());
let table = Table::new(rows, [
ratatui::layout::Constraint::Percentage(18),
ratatui::layout::Constraint::Percentage(22),
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(20),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,177 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Row, Table, Tabs},
};
use crate::app::{AppState, BooksSubView};
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Sub-tab headers
Constraint::Min(0), // Content area
])
.split(area);
render_sub_tabs(f, state, chunks[0]);
match state.books_sub_view {
BooksSubView::List => render_book_list(f, state, chunks[1]),
BooksSubView::Series => render_series(f, state, chunks[1]),
BooksSubView::Authors => render_authors(f, state, chunks[1]),
}
}
fn render_sub_tabs(f: &mut Frame, state: &AppState, area: Rect) {
let titles: Vec<Line> = vec!["List", "Series", "Authors"]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let selected = match state.books_sub_view {
BooksSubView::List => 0,
BooksSubView::Series => 1,
BooksSubView::Authors => 2,
};
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" Books "))
.select(selected)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn render_book_list(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Title", "Author", "Format", "Pages"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.books_list
.iter()
.enumerate()
.map(|(i, media)| {
let style = if Some(i) == state.books_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let title = media
.title
.as_deref()
.unwrap_or(&media.file_name)
.to_string();
let author = media.artist.as_deref().unwrap_or("-").to_string();
// Extract format from media_type or file extension
let format = media
.file_name
.rsplit('.')
.next()
.map_or_else(|| media.media_type.clone(), str::to_uppercase);
// Page count from custom fields if available
let pages = media
.custom_fields
.get("page_count")
.map_or_else(|| "-".to_string(), |f| f.value.clone());
Row::new(vec![title, author, format, pages]).style(style)
})
.collect();
let title = format!(" Book List ({}) ", state.books_list.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(30),
Constraint::Percentage(15),
Constraint::Percentage(15),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_series(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Series Name", "Books"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.books_series
.iter()
.enumerate()
.map(|(i, series)| {
let style = if Some(i) == state.books_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![series.name.clone(), series.count.to_string()]).style(style)
})
.collect();
let title = format!(" Series ({}) ", state.books_series.len());
let table = Table::new(rows, [
Constraint::Percentage(70),
Constraint::Percentage(30),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_authors(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Author Name", "Books"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.books_authors
.iter()
.enumerate()
.map(|(i, author)| {
let style = if Some(i) == state.books_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![author.name.clone(), author.count.to_string()]).style(style)
})
.collect();
let title = format!(" Authors ({}) ", state.books_authors.len());
let table = Table::new(rows, [
Constraint::Percentage(70),
Constraint::Percentage(30),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,65 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Row, Table},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header =
Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.collections
.iter()
.enumerate()
.map(|(i, col)| {
let style = if Some(i) == state.collection_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
// We show the filter_query as a proxy for member info when kind is
// "smart"
let members_display = if col.kind == "smart" {
col
.filter_query
.as_deref()
.map_or_else(|| "-".to_string(), |q| format!("filter: {q}"))
} else {
"-".to_string()
};
Row::new(vec![
col.name.clone(),
col.kind.clone(),
col.description.clone().unwrap_or_else(|| "-".into()),
members_display,
format_date(&col.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Collections ({}) ", state.collections.len());
let table = Table::new(rows, [
ratatui::layout::Constraint::Percentage(25),
ratatui::layout::Constraint::Percentage(12),
ratatui::layout::Constraint::Percentage(28),
ratatui::layout::Constraint::Percentage(15),
ratatui::layout::Constraint::Percentage(20),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,57 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let pad = " ";
let mut lines = vec![
Line::default(),
Line::from(Span::styled("--- Database Statistics ---", section_style)),
];
if let Some(ref stats) = state.database_stats {
for (key, value) in stats {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<20}"), label_style),
Span::styled(value.clone(), value_style),
]));
}
} else {
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Press 'r' to load database statistics"),
]));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("v: Vacuum database"),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Esc: Return to library"),
]));
let paragraph = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Database "));
f.render_widget(paragraph, area);
}

View file

@ -0,0 +1,418 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::{format_date, format_duration, format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let Some(item) = &state.selected_media else {
let msg = Paragraph::new("No item selected")
.block(Block::default().borders(Borders::ALL).title(" Detail "));
f.render_widget(msg, area);
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let dim_style = Style::default().fg(Color::DarkGray);
let pad = " ";
let label_width = 14;
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
let mut lines: Vec<Line> = Vec::new();
// Section: File Info
lines.push(Line::from(Span::styled(
"--- File Info ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Name"), label_style),
Span::styled(&item.file_name, value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Path"), label_style),
Span::styled(&item.path, dim_style),
]));
let type_color = media_type_color(&item.media_type);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Type"), label_style),
Span::styled(&item.media_type, Style::default().fg(type_color)),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Size"), label_style),
Span::styled(format_size(item.file_size), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Hash"), label_style),
Span::styled(&item.content_hash, dim_style),
]));
if item.has_thumbnail {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Thumbnail"), label_style),
Span::styled("Yes", Style::default().fg(Color::Green)),
]));
}
lines.push(Line::default()); // blank line
// Section: Metadata
lines.push(Line::from(Span::styled(
"--- Metadata ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Title"), label_style),
Span::styled(item.title.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Artist"), label_style),
Span::styled(item.artist.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Album"), label_style),
Span::styled(item.album.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Genre"), label_style),
Span::styled(item.genre.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Year"), label_style),
Span::styled(
item.year.map_or_else(|| "-".to_string(), |y| y.to_string()),
value_style,
),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Duration"), label_style),
Span::styled(
item
.duration_secs
.map_or_else(|| "-".to_string(), format_duration),
value_style,
),
]));
// Description
if let Some(ref desc) = item.description
&& !desc.is_empty()
{
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Description"), label_style),
Span::styled(desc.as_str(), value_style),
]));
}
// Custom fields
if !item.custom_fields.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Custom Fields ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let mut fields: Vec<_> = item.custom_fields.iter().collect();
fields.sort_by_key(|(k, _)| k.as_str());
for (key, field) in fields {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<label_width$}"), label_style),
Span::styled(
format!("{} ({})", field.value, field.field_type),
value_style,
),
]));
}
}
// Tags section
if !state.tags.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Tags ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let tag_names: Vec<&str> =
state.tags.iter().map(|t| t.name.as_str()).collect();
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
]));
}
// Book metadata section
if let Some(ref book) = state.book_metadata {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Book Metadata ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
if let Some(ref subtitle) = book.subtitle {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Subtitle"), label_style),
Span::styled(subtitle.as_str(), value_style),
]));
}
if !book.authors.is_empty() {
let authors: Vec<&str> =
book.authors.iter().map(|a| a.name.as_str()).collect();
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Authors"), label_style),
Span::styled(authors.join(", "), value_style),
]));
}
if let Some(ref publisher) = book.publisher {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Publisher"), label_style),
Span::styled(publisher.as_str(), value_style),
]));
}
if let Some(isbn) = book.isbn13.as_ref().or(book.isbn.as_ref()) {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("ISBN"), label_style),
Span::styled(isbn.as_str(), value_style),
]));
}
if let Some(ref language) = book.language {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Language"), label_style),
Span::styled(language.as_str(), value_style),
]));
}
if let Some(pages) = book.page_count {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Pages"), label_style),
Span::styled(pages.to_string(), value_style),
]));
}
if let Some(ref series) = book.series {
let series_display = book
.series_index
.map_or_else(|| series.clone(), |idx| format!("{series} #{idx}"));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Series"), label_style),
Span::styled(series_display, value_style),
]));
}
}
// Social section: rating, favorite, comments
{
let has_social = state.media_rating.is_some()
|| state.is_favorite
|| !state.media_comments.is_empty();
if has_social {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Social ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
}
if state.is_favorite {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Favorite"), label_style),
Span::styled("Yes", Style::default().fg(Color::Yellow)),
]));
}
if let Some(stars) = state.media_rating {
let stars_str = "*".repeat(stars as usize);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Rating"), label_style),
Span::styled(format!("{stars_str} ({stars}/5)"), value_style),
]));
}
if !state.media_comments.is_empty() {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
format!("Comments ({})", state.media_comments.len()),
label_style,
),
]));
for comment in state.media_comments.iter().take(5) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{}] {}", format_date(&comment.created_at), comment.text),
dim_style,
),
]));
}
if state.media_comments.len() > 5 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("... and {} more", state.media_comments.len() - 5),
dim_style,
),
]));
}
}
}
// Subtitles section
if state.showing_subtitles {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Subtitles ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
if state.subtitles.is_empty() {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled("No subtitles found", dim_style),
]));
} else {
for sub in &state.subtitles {
let lang = sub.language.as_deref().unwrap_or("?");
let fmt = sub.format.as_deref().unwrap_or("?");
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("[{lang}] {fmt}"), value_style),
]));
}
}
}
// Transcodes section
if state.showing_transcodes && !state.transcodes.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Transcodes ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
for tc in &state.transcodes {
let status_color = match tc.status.as_str() {
"done" | "completed" => Color::Green,
"failed" | "error" => Color::Red,
_ => Color::Yellow,
};
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("[{}] ", tc.profile), label_style),
Span::styled(&tc.status, Style::default().fg(status_color)),
]));
}
}
// Reading progress section
if let Some(ref progress) = state.reading_progress {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Reading Progress ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let page_display = progress.total_pages.map_or_else(
|| format!("Page {}", progress.current_page),
|total| format!("Page {} / {total}", progress.current_page),
);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Progress"), label_style),
Span::styled(page_display, value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Status"), label_style),
Span::styled(&progress.status, value_style),
]));
}
lines.push(Line::default());
// Section: Timestamps
lines.push(Line::from(Span::styled(
"--- Timestamps ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Created"), label_style),
Span::styled(format_date(&item.created_at), dim_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Updated"), label_style),
Span::styled(format_date(&item.updated_at), dim_style),
]));
let title = item.title.as_ref().map_or_else(
|| format!(" Detail: {} ", item.file_name),
|title_str| format!(" Detail: {title_str} "),
);
let detail = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(detail, chunks[0]);
}

View file

@ -0,0 +1,61 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.duplicate_groups.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No duplicates found. Press 'r' to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
let mut list_items = Vec::new();
for (i, group) in state.duplicate_groups.iter().enumerate() {
// Show truncated hash (first 16 chars) for identification
let hash_display = if group.content_hash.len() > 16 {
&group.content_hash[..16]
} else {
&group.content_hash
};
let header = format!(
"Group {} ({} items, hash: {}...)",
i + 1,
group.items.len(),
hash_display
);
list_items.push(ListItem::new(Line::from(Span::styled(
header,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
for item in &group.items {
let line = format!(" {} - {}", item.file_name, item.path);
let is_selected = state
.duplicates_selected
.is_some_and(|sel| sel == list_items.len());
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
list_items.push(ListItem::new(Line::from(Span::styled(line, style))));
}
list_items.push(ListItem::new(Line::default()));
}
list_items
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Duplicates "));
f.render_widget(list, area);
}

View file

@ -0,0 +1,77 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let input = Paragraph::new(state.import_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Import File (enter path and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let help_lines = vec![
Line::default(),
Line::from(Span::styled(
" Import a file or trigger a library scan",
label_style,
)),
Line::default(),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::raw(" Import the file at the entered path"),
]),
Line::from(vec![
Span::styled(" Esc", key_style),
Span::raw(" Cancel and return to library"),
]),
Line::from(vec![
Span::styled(" s", key_style),
Span::raw(
" Trigger a full library scan (scans all configured \
directories)",
),
]),
Line::default(),
Line::from(Span::styled(" Tips:", label_style)),
Line::from(
" - Enter an absolute path to a media file (e.g. \
/home/user/music/song.mp3)",
),
Line::from(" - The file will be copied into the managed library"),
Line::from(
" - Duplicates are detected by content hash and will be skipped",
),
Line::from(
" - Press 's' (without typing a path) to scan all library directories",
),
];
let help = Paragraph::new(help_lines)
.block(Block::default().borders(Borders::ALL).title(" Help "));
f.render_widget(help, chunks[1]);
}

View file

@ -0,0 +1,97 @@
use ratatui::{
Frame,
layout::{Constraint, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Cell, Row, Table},
};
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(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.media_list
.iter()
.enumerate()
.map(|(i, item)| {
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);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
let duration = item
.duration_secs
.map_or_else(|| "-".to_string(), format_duration);
let year = item.year.map_or_else(|| "-".to_string(), |y| y.to_string());
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(display_name),
type_cell,
Cell::from(duration),
Cell::from(year),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let page = (state.page_offset / state.page_size) + 1;
let item_count = state.media_list.len();
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::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)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,84 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Header
let title = state.selected_media.as_ref().map_or_else(
|| " Edit Metadata ".to_string(),
|media| format!(" Edit: {} ", media.file_name),
);
let header = Paragraph::new(Line::from(Span::styled(
&title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)))
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
// Edit fields
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let active_style = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let pad = " ";
let fields = [
("Title", &state.edit_title),
("Artist", &state.edit_artist),
("Album", &state.edit_album),
("Genre", &state.edit_genre),
("Year", &state.edit_year),
("Description", &state.edit_description),
];
let mut lines = Vec::new();
lines.push(Line::default());
for (i, (label, value)) in fields.iter().enumerate() {
let is_active = state.edit_field_index == Some(i);
let style = if is_active { active_style } else { label_style };
let cursor = if is_active { "> " } else { pad };
lines.push(Line::from(vec![
Span::raw(cursor),
Span::styled(format!("{label:<14}"), style),
Span::styled(value.as_str(), value_style),
if is_active {
Span::styled("_", Style::default().fg(Color::Green))
} else {
Span::raw("")
},
]));
}
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
"Tab: Next field Enter: Save Esc: Cancel",
Style::default().fg(Color::DarkGray),
),
]));
let editor = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Fields "));
f.render_widget(editor, chunks[1]);
}

View file

@ -0,0 +1,238 @@
pub mod admin;
pub mod audit;
pub mod books;
pub mod collections;
pub mod database;
pub mod detail;
pub mod duplicates;
pub mod import;
pub mod library;
pub mod metadata_edit;
pub mod playlists;
pub mod queue;
pub mod search;
pub mod settings;
pub mod statistics;
pub mod tags;
pub mod tasks;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Tabs},
};
use crate::app::{AppState, View};
/// Format a file size in bytes into a human-readable string.
#[expect(
clippy::cast_precision_loss,
reason = "file sizes beyond 2^52 bytes are unlikely in practice"
)]
pub fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Format duration in seconds into hh:mm:ss format.
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "duration seconds are always non-negative and within u64 range"
)]
pub fn format_duration(secs: f64) -> String {
let total = secs as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
format!("{m:02}:{s:02}")
}
}
/// Trim a timestamp string to just the date portion (YYYY-MM-DD).
pub fn format_date(timestamp: &str) -> &str {
// Timestamps are typically "2024-01-15T10:30:00Z" or similar
if timestamp.len() >= 10 {
&timestamp[..10]
} else {
timestamp
}
}
/// Return a color based on media type string.
pub fn media_type_color(media_type: &str) -> Color {
match media_type {
t if t.starts_with("audio") => Color::Green,
t if t.starts_with("video") => Color::Magenta,
t if t.starts_with("image") => Color::Yellow,
t if t.starts_with("application/pdf") => Color::Red,
t if t.starts_with("text") => Color::Cyan,
_ => Color::White,
}
}
pub fn render(f: &mut Frame, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
render_tabs(f, state, chunks[0]);
match state.current_view {
View::Library => library::render(f, state, chunks[1]),
View::Search => search::render(f, state, chunks[1]),
View::Detail => detail::render(f, state, chunks[1]),
View::Tags => tags::render(f, state, chunks[1]),
View::Collections => collections::render(f, state, chunks[1]),
View::Audit => audit::render(f, state, chunks[1]),
View::Import => import::render(f, state, chunks[1]),
View::Settings => settings::render(f, state, chunks[1]),
View::Duplicates => duplicates::render(f, state, chunks[1]),
View::Database => database::render(f, state, chunks[1]),
View::MetadataEdit => metadata_edit::render(f, state, chunks[1]),
View::Queue => queue::render(f, state, chunks[1]),
View::Statistics => statistics::render(f, state, chunks[1]),
View::Tasks => tasks::render(f, state, chunks[1]),
View::Books => books::render(f, state, chunks[1]),
View::Playlists => playlists::render(f, state, chunks[1]),
View::Admin => admin::render(f, state, chunks[1]),
}
render_status_bar(f, state, chunks[2]);
}
fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
let titles: Vec<Line> = vec![
"Library",
"Search",
"Tags",
"Collections",
"Audit",
"Books",
"Queue",
"Stats",
"Tasks",
"Playlists",
"Admin",
]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let selected = match state.current_view {
View::Library
| View::Detail
| View::Import
| View::Settings
| View::MetadataEdit => 0,
View::Search => 1,
View::Tags => 2,
View::Collections => 3,
View::Audit | View::Duplicates | View::Database => 4,
View::Books => 5,
View::Queue => 6,
View::Statistics => 7,
View::Tasks => 8,
View::Playlists => 9,
View::Admin => 10,
};
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" Pinakes "))
.select(selected)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
let status = state.status_message.as_ref().map_or_else(
|| {
match state.current_view {
View::Tags => {
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \
Tab:Switch"
.to_string()
},
View::Collections => {
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch"
.to_string()
},
View::Audit => {
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch"
.to_string()
},
View::Detail => {
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \
R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh"
.to_string()
},
View::Playlists => {
" q:Quit j/k:Nav n:New d:Delete Enter:Items S:Shuffle Esc:Back"
.to_string()
},
View::Admin => " q:Quit j/k:Nav Tab:Switch tab d:Del user/device \
w:Test webhook r:Refresh Esc:Back"
.to_string(),
View::Import => {
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
},
View::Settings => " q:Quit Esc:Back ?:Help".to_string(),
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
View::MetadataEdit => {
" Tab:Next field Enter:Save Esc:Cancel".to_string()
},
View::Queue => {
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \
S:Shuffle C:Clear"
.to_string()
},
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
View::Tasks => {
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back"
.to_string()
},
View::Books => {
" q:Quit j/k:Nav Home/End:Top/Bot Tab:Sub-view r:Refresh \
Esc:Back"
.to_string()
},
_ => {
" q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \
b:Books D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
.to_string()
},
}
},
String::clone,
);
let paragraph = Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(paragraph, area);
}

View file

@ -0,0 +1,117 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
if state.viewing_playlist_items {
render_items(f, state, area);
} else {
render_list(f, state, area);
}
}
fn render_list(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Description", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.playlists
.iter()
.enumerate()
.map(|(i, pl)| {
let style = if i == state.playlists_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![
pl.name.clone(),
pl.description.clone().unwrap_or_else(|| "-".into()),
format_date(&pl.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Playlists ({}) ", state.playlists.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(40),
Constraint::Percentage(20),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_items(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(area);
let pl_name = state
.playlists
.get(state.playlists_selected)
.map_or("Playlist", |p| p.name.as_str());
let hint = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {pl_name} "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled("Esc:Back d:Remove", Style::default().fg(Color::DarkGray)),
]));
f.render_widget(hint, chunks[0]);
let header = Row::new(vec!["File", "Type", "Title"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.playlist_items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == state.playlist_items_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![
item.file_name.clone(),
item.media_type.clone(),
item.title.clone().unwrap_or_else(|| "-".into()),
])
.style(style)
})
.collect();
let title = format!(" Items ({}) ", state.playlist_items.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(40),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, chunks[1]);
}

View file

@ -0,0 +1,71 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.play_queue.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" Queue is empty. Select items in the library and press 'q' to add.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.play_queue
.iter()
.enumerate()
.map(|(i, item)| {
let is_current = state.queue_current_index == Some(i);
let is_selected = state.queue_selected == Some(i);
let prefix = if is_current { ">> " } else { " " };
let type_color = super::media_type_color(&item.media_type);
let id_suffix = if item.media_id.len() > 8 {
&item.media_id[item.media_id.len() - 8..]
} else {
&item.media_id
};
let text = item.artist.as_ref().map_or_else(
|| format!("{prefix}{} [{id_suffix}]", item.title),
|artist| format!("{prefix}{} - {artist} [{id_suffix}]", item.title),
);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(type_color)
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
let repeat_str = match state.queue_repeat {
0 => "Off",
1 => "One",
_ => "All",
};
let shuffle_str = if state.queue_shuffle { "On" } else { "Off" };
let title = format!(
" Queue ({}) | Repeat: {} | Shuffle: {} ",
state.play_queue.len(),
repeat_str,
shuffle_str,
);
let list =
List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
}

View file

@ -0,0 +1,104 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
};
use super::{format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Search input
let input = Paragraph::new(state.search_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Search (type and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
// Results
let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.search_results
.iter()
.enumerate()
.map(|(i, item)| {
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(),
Style::default().fg(type_color),
));
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()),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let shown = state.search_results.len();
let total = state.search_total_count;
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::Length(3), // Selection marker
Constraint::Percentage(33), // Name
Constraint::Percentage(18), // Type
Constraint::Percentage(23), // Artist
Constraint::Percentage(18), // Size
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(results_title));
f.render_widget(table, chunks[1]);
}

View file

@ -0,0 +1,84 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let pad = " ";
let lines = vec![
Line::default(),
Line::from(Span::styled("--- Connection ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Server URL: ", label_style),
Span::styled(&state.server_url, value_style),
]),
Line::default(),
Line::from(Span::styled("--- Library ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Total items: ", label_style),
Span::styled(state.total_media_count.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Page size: ", label_style),
Span::styled(state.page_size.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Current page: ", label_style),
Span::styled(
((state.page_offset / state.page_size) + 1).to_string(),
value_style,
),
]),
Line::default(),
Line::from(Span::styled("--- State ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Tags loaded: ", label_style),
Span::styled(state.tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("All tags: ", label_style),
Span::styled(state.all_tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Collections: ", label_style),
Span::styled(state.collections.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Audit entries: ", label_style),
Span::styled(state.audit_log.len().to_string(), value_style),
]),
Line::default(),
Line::from(Span::styled("--- Shortcuts ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::raw("Press Esc to return to the library view"),
]),
];
let settings = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Settings "));
f.render_widget(settings, area);
}

View file

@ -0,0 +1,181 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let Some(ref stats) = state.library_stats else {
let msg = Paragraph::new("Loading statistics... (press X to refresh)")
.block(Block::default().borders(Borders::ALL).title(" Statistics "));
f.render_widget(msg, area);
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), // Overview
Constraint::Length(10), // Media by type
Constraint::Min(6), // Top tags & collections
])
.split(area);
// Overview section
let overview_lines = vec![
Line::from(vec![
Span::styled(" Total Media: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_media.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Total Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.total_size_bytes),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.avg_file_size_bytes),
Style::default().fg(Color::White),
),
]),
Line::from(vec![
Span::styled(" Tags: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_tags.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Collections: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_collections.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Duplicates: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_duplicates.to_string(),
Style::default().fg(Color::Yellow),
),
]),
Line::from(vec![
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.newest_item.as_deref().map_or("-", super::format_date),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.oldest_item.as_deref().map_or("-", super::format_date),
Style::default().fg(Color::White),
),
]),
];
let overview = Paragraph::new(overview_lines)
.block(Block::default().borders(Borders::ALL).title(" Overview "));
f.render_widget(overview, chunks[0]);
// Media by Type table
let type_rows: Vec<Row> = stats
.media_by_type
.iter()
.map(|tc| {
let color = super::media_type_color(&tc.name);
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(color)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let storage_rows: Vec<Row> = stats
.storage_by_type
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(tc.count),
Style::default().fg(Color::White),
),
])
})
.collect();
let type_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let type_table =
Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Media by Type "),
);
f.render_widget(type_table, type_cols[0]);
let storage_table =
Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Storage by Type "),
);
f.render_widget(storage_table, type_cols[1]);
// Top tags and collections
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
let tag_rows: Vec<Row> = stats
.top_tags
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Green)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let tags_table =
Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)])
.block(Block::default().borders(Borders::ALL).title(" Top Tags "));
f.render_widget(tags_table, bottom_cols[0]);
let col_rows: Vec<Row> = stats
.top_collections
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let cols_table =
Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Top Collections "),
);
f.render_widget(cols_table, bottom_cols[1]);
}

View file

@ -0,0 +1,60 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Row, Table},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Parent", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.tags
.iter()
.enumerate()
.map(|(i, tag)| {
let style = if Some(i) == state.tag_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
// Resolve parent tag name from the tags list itself
let parent_display = tag.parent_id.as_ref().map_or_else(
|| "-".to_string(),
|pid| {
state.tags.iter().find(|t| t.id == *pid).map_or_else(
|| pid.chars().take(8).collect::<String>() + "...",
|t| t.name.clone(),
)
},
);
Row::new(vec![
tag.name.clone(),
parent_display,
format_date(&tag.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Tags ({}) ", state.tags.len());
let table = Table::new(rows, [
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(30),
ratatui::layout::Constraint::Percentage(30),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,65 @@
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.scheduled_tasks.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No scheduled tasks. Press T to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.scheduled_tasks
.iter()
.enumerate()
.map(|(i, task)| {
let is_selected = state.scheduled_tasks_selected == Some(i);
let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" };
let enabled_color = if task.enabled {
Color::Green
} else {
Color::DarkGray
};
let last_run = task.last_run.as_deref().map_or("-", super::format_date);
let next_run = task.next_run.as_deref().map_or("-", super::format_date);
let status = task.last_status.as_deref().unwrap_or("-");
// Show abbreviated task ID (first 8 chars)
let task_id_short = if task.id.len() > 8 {
&task.id[..8]
} else {
&task.id
};
let text = format!(
" {enabled_marker} [{task_id_short}] {:<20} {:<16} Last: {:<12} \
Next: {:<12} Status: {}",
task.name, task.schedule, last_run, next_run, status
);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(enabled_color)
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len());
let list =
List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
}