diff --git a/Cargo.lock b/Cargo.lock index ee8d66b..40ac32d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1777,17 +1777,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -2806,15 +2795,28 @@ dependencies = [ "error-stack", "futures", "ratatui", - "reqwest", "serde", "serde_json", - "serde_path_to_error", "thiserror 2.0.12", "tokio", "toml", "tracing", "tracing-subscriber", + "yarr-api", +] + +[[package]] +name = "yarr-api" +version = "0.1.0" +dependencies = [ + "chrono", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tracing", + "tracing-subscriber", "urlencoding", ] diff --git a/Cargo.toml b/Cargo.toml index 0eb5334..9633e0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,40 +1,18 @@ -[package] -name = "yarr" -version = "0.1.0" -edition = "2021" -license = "MIT" +[workspace] +members = [ + "yarr-cli", + "yarr-api" +] +resolver = "2" -[[bin]] -name = "yarr" -path = "src/main.rs" - -[dependencies] -clap = { version = "4.5", features = ["derive", "env"] } -clap_complete = "4.5" -error-stack = "0.5" -thiserror = "2.0" +[workspace.dependencies] +# Common dependencies that can be shared across workspace members tokio = { version = "1.43.1", features = ["full"] } -tracing = "0.1" -tracing-subscriber = "0.3" - -# TUI dependencies -ratatui = { version = "0.28", features = ["crossterm"] } -crossterm = "0.28" - -# HTTP client and serialization -reqwest = { version = "0.12", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" - -# Date/time handling +reqwest = { version = "0.12", features = ["json"] } chrono = { version = "0.4", features = ["serde"] } - -# Async utilities -futures = "0.3" -urlencoding = "2.1.3" -serde_path_to_error = "0.1.20" - -# Configuration -config = "0.14" -toml = "0.8" -dirs = "5.0" +thiserror = "2.0" +error-stack = "0.5" +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/KEYBINDS.md b/KEYBINDS.md new file mode 100644 index 0000000..080147d --- /dev/null +++ b/KEYBINDS.md @@ -0,0 +1,169 @@ +# Keybind Reference + +Yarr supports two keybind modes: **Normal** (default) and **Vim**. You can switch between modes in the Settings tab or by configuring it in your config file. + +## Configuration + +### Via Config File + +```toml +[ui] +keybind_mode = "Vim" # or "Normal" +show_help = true +``` + +### Via Environment Variable + +```bash +export YARR_UI_KEYBIND_MODE="Vim" +``` + +### Via Settings Menu + +1. Navigate to the Settings tab (last tab) +2. Use arrow keys or navigation keys to select "Keybind Mode" +3. Press Enter to toggle between Normal and Vim modes +4. Press 's' to save changes + +## Normal Mode (Default) + +### Navigation +| Key | Action | +|-----|--------| +| `↑` / `k` | Move up | +| `↓` / `j` | Move down | +| `Tab` | Next tab | +| `Shift+Tab` | Previous tab | + +### Actions +| Key | Action | +|-----|--------| +| `q` | Quit application | +| `r` | Refresh/reload data | +| `d` | Toggle details view | +| `/` | Enter search mode (Search tab only) | +| `Enter` | Select/activate item | +| `Esc` | Cancel/clear error/exit search | +| `s` | Save configuration changes | + +### Search Mode (Search Tab) +| Key | Action | +|-----|--------| +| `Enter` | Execute search | +| `Esc` | Exit search mode | +| `Backspace` | Delete character | +| Any character | Add to search term | + +## Vim Mode + +### Navigation +| Key | Action | +|-----|--------| +| `h` | Move left / Previous tab | +| `j` | Move down | +| `k` | Move up | +| `l` | Move right / Next tab | +| `w` | Next tab (word forward) | +| `b` | Previous tab (word backward) | +| `gg` | Go to first item | +| `G` | Go to last item | + +### Actions +| Key | Action | +|-----|--------| +| `q` | Quit application | +| `u` | Refresh/reload data (undo) | +| `v` | Toggle details view (visual mode) | +| `/` | Enter search mode | +| `i` | Enter insert/input mode (search) | +| `Enter` | Select/activate item | +| `Esc` | Cancel/clear error/exit modes | +| `s` | Save configuration changes | + +### Insert/Search Mode (Vim) +| Key | Action | +|-----|--------| +| `Enter` | Execute search | +| `Esc` | Exit insert/search mode | +| `Backspace` | Delete character | +| Any character | Add to search term | + +## Tab Navigation + +Both modes support the following tabs: + +1. **Series** - View all series +2. **Search** - Search for new series +3. **Calendar** - Upcoming episodes +4. **Queue** - Download queue +5. **History** - Download history +6. **Health** - System health status +7. **Settings** - Configure application settings + +## Settings Tab + +The Settings tab allows you to: + +- Toggle between Normal and Vim keybind modes +- Enable/disable help text display +- Edit Sonarr server URL +- Edit Sonarr API key +- Save configuration changes to file + +### Settings Navigation +| Key | Action | +|-----|--------| +| `↑/↓` or `j/k` | Navigate settings options | +| `Enter` | Toggle setting or edit Sonarr config | +| `s` | Save all changes to config file | + +### Editing Sonarr Configuration +When editing URL or API key: +| Key | Action | +|-----|--------| +| `Enter` | Save changes and return to settings | +| `Esc` | Cancel editing and return to settings | +| Any character | Type new value | +| `Backspace` | Delete character | + +## Tips + +1. **Vim Mode Features**: Vim mode includes additional navigation shortcuts like `gg` for first item and `G` for last item. + +2. **Help Display**: Enable "Show Help" in settings to see keybind hints in the footer. + +3. **Configuration Persistence**: Use the 's' key to save any settings changes. Unsaved changes are indicated in the Settings tab title. + +4. **Mode Switching**: You can switch between keybind modes anytime via the Settings tab without restarting the application. + +5. **Fallback Keys**: Most vim keys have fallback arrow key equivalents, and most normal mode keys work in vim mode too. + +## Examples + +### Switching to Vim Mode +1. Navigate to Settings tab: `Tab` (repeatedly until you reach Settings) +2. Select keybind mode: `↓` or `j` (if not already selected) +3. Toggle to Vim: `Enter` +4. Save changes: `s` + +### Configuring Sonarr Connection +1. Navigate to Settings tab: `Tab` (repeatedly until you reach Settings) +2. Select "Sonarr URL": `↓` or `j` to navigate +3. Edit URL: `Enter`, type new URL, `Enter` to save +4. Select "API Key": `↓` or `j` to navigate +5. Edit API Key: `Enter`, type new key, `Enter` to save +6. Save changes: `s` + +### Quick Navigation in Vim Mode +- Jump to first series: `gg` (in Series tab) +- Jump to last item: `G` +- Switch to next tab: `w` or `l` +- Refresh data: `u` +- Search: `/` (in Search tab) or `i` (in Search tab) + +### Search Workflow +1. Go to Search tab: `Tab` (navigate to Search) +2. Enter search mode: `/` (Normal) or `i` (Vim) +3. Type search term: any characters +4. Execute search: `Enter` +5. Navigate results: `↑/↓` or `j/k` diff --git a/README.md b/README.md index 979173f..ed48348 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,16 @@ # Yarr -A Terminal User Interface (TUI) for managing Sonarr. +A Terminal User Interface (TUI) for managing Sonarr, built as a Rust workspace with separate API client library. > ✨ **Note**: This project was fully vibe coded with AI assistance, showcasing modern development workflows and comprehensive feature implementation. +## Project Structure + +This workspace contains two crates: + +- **`yarr-api`** - A standalone Rust library for interacting with the Sonarr API +- **`yarr-cli`** - The main TUI application that uses the API library + ## Features - View system status and health @@ -17,8 +24,19 @@ A Terminal User Interface (TUI) for managing Sonarr. ## Installation +### Install the TUI Application + ```bash -cargo install --path . +cargo install --path yarr-cli +``` + +### Use the API Library + +Add to your `Cargo.toml`: + +```toml +[dependencies] +yarr-api = { path = "yarr-api" } ``` ## Configuration @@ -210,6 +228,50 @@ This eliminates the need to manually edit config files for basic setup. 3. Find the "Security" section 4. Copy the "API Key" value +## API Library Usage + +The `yarr-api` crate can be used independently in your own projects: + +```rust +use yarr_api::{SonarrClient, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let client = SonarrClient::new( + "http://localhost:8989".to_string(), + "your-api-key".to_string() + ); + + let series = client.get_series().await?; + println!("Found {} series", series.len()); + + Ok(()) +} +``` + +See the [yarr-api README](yarr-api/README.md) for detailed API documentation and examples. + +## Development + +### Building the Workspace + +```bash +# Build all crates +cargo build + +# Build just the CLI +cargo build -p yarr + +# Build just the API library +cargo build -p yarr-api + +# Run tests +cargo test + +# Run the API library example +cargo run --example basic_usage +``` + ## License MIT \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs deleted file mode 100644 index 75205af..0000000 --- a/src/errors.rs +++ /dev/null @@ -1,13 +0,0 @@ -use error_stack::Report; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("API error")] - Api, - #[error("HTTP request failed")] - Request, - #[error("Serialization/Deserialization error")] - Serialization, -} - -pub type Result = core::result::Result>; diff --git a/yarr-api/Cargo.toml b/yarr-api/Cargo.toml new file mode 100644 index 0000000..d3c633c --- /dev/null +++ b/yarr-api/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "yarr-api" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Sonarr API client library" +authors = ["yarr contributors"] +repository = "https://github.com/user/yarr" + +[dependencies] +# HTTP client and serialization +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +# Async runtime +tokio = { workspace = true } + +# Date/time handling +chrono = { workspace = true, features = ["serde"] } + +# Error handling +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } + +# URL encoding +urlencoding = "2.1.3" + +[dev-dependencies] +# For examples +tracing-subscriber = { workspace = true } diff --git a/yarr-api/README.md b/yarr-api/README.md new file mode 100644 index 0000000..9e37f2b --- /dev/null +++ b/yarr-api/README.md @@ -0,0 +1,135 @@ +# yarr-api + +A Rust client library for the Sonarr API. + +## Overview + +`yarr-api` provides a strongly-typed, async Rust client for interacting with Sonarr instances. It handles authentication, request/response serialization, and provides convenient methods for all major Sonarr API endpoints. + +## Features + +- **Async/await support** - Built on `tokio` and `reqwest` +- **Type-safe API** - All API responses are strongly typed with `serde` +- **Error handling** - Comprehensive error types with detailed error information +- **Easy to use** - Simple client interface with intuitive method names +- **Well documented** - Extensive documentation and examples + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +yarr-api = "0.1.0" +``` + +## Quick Start + +```rust +use yarr_api::{SonarrClient, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + // Create a client + let client = SonarrClient::new( + "http://localhost:8989".to_string(), + "your-api-key".to_string() + ); + + // Get system status + let status = client.get_system_status().await?; + println!("Sonarr version: {}", status.version.unwrap_or_default()); + + // Get all series + let series = client.get_series().await?; + println!("Total series: {}", series.len()); + + // Get download queue + let queue = client.get_queue().await?; + println!("Items in queue: {}", queue.records.len()); + + Ok(()) +} +``` + +## API Coverage + +### System +- ✅ System status +- ✅ Health check + +### Series Management +- ✅ List all series +- ✅ Get series by ID +- ✅ Search for series +- ✅ Add new series + +### Episodes +- ✅ Get episodes for series/season +- ✅ Get calendar (upcoming episodes) +- ✅ Get missing episodes + +### Downloads +- ✅ Get download queue +- ✅ Get download history + +## Examples + +See the `examples/` directory for more comprehensive usage examples: + +```bash +# Run the basic usage example +cargo run --example basic_usage + +# Make sure to set your Sonarr URL and API key first: +export SONARR_URL="http://localhost:8989" +export SONARR_API_KEY="your-api-key-here" +``` + +## Error Handling + +The library uses a custom `ApiError` type that provides detailed error information: + +```rust +use yarr_api::{SonarrClient, ApiError}; + +let client = SonarrClient::new(url, api_key); + +match client.get_series().await { + Ok(series) => println!("Found {} series", series.len()), + Err(ApiError::Authentication) => println!("Invalid API key"), + Err(ApiError::NotFound) => println!("Endpoint not found"), + Err(ApiError::ServerError) => println!("Sonarr server error"), + Err(e) => println!("Other error: {}", e), +} +``` + +## Configuration + +The client requires two pieces of information: + +1. **Base URL** - The URL of your Sonarr instance (e.g., `http://localhost:8989`) +2. **API Key** - Your Sonarr API key (found in Settings > General > Security) + +## Data Types + +All Sonarr API responses are represented as strongly-typed Rust structs: + +- `SystemStatus` - System information and status +- `Series` - TV series information +- `Episode` - Episode details +- `QueueItem` - Download queue items +- `HistoryItem` - Download history +- `HealthResource` - Health check results + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Related Projects + +- [yarr](../yarr-cli) - TUI client for Sonarr built using this library \ No newline at end of file diff --git a/yarr-api/examples/basic_usage.rs b/yarr-api/examples/basic_usage.rs new file mode 100644 index 0000000..91a93a3 --- /dev/null +++ b/yarr-api/examples/basic_usage.rs @@ -0,0 +1,125 @@ +//! Basic usage example for the yarr-api crate +//! +//! This example demonstrates how to use the Sonarr API client to fetch basic information. +//! +//! To run this example: +//! ```bash +//! cargo run --example basic_usage +//! ``` +//! +//! Make sure to set the following environment variables: +//! - SONARR_URL: Your Sonarr instance URL (e.g., "http://localhost:8989") +//! - SONARR_API_KEY: Your Sonarr API key + +use yarr_api::{Result, SonarrClient}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing for better error visibility + tracing_subscriber::fmt::init(); + + // Get configuration from environment variables + let sonarr_url = + std::env::var("SONARR_URL").unwrap_or_else(|_| "http://localhost:8989".to_string()); + let sonarr_api_key = + std::env::var("SONARR_API_KEY").expect("SONARR_API_KEY environment variable must be set"); + + // Create the API client + let client = SonarrClient::new(sonarr_url, sonarr_api_key); + + println!("Connecting to Sonarr..."); + + // Fetch and display system status + match client.get_system_status().await { + Ok(status) => { + println!("✓ Connected to Sonarr successfully!"); + println!(" App Name: {}", status.app_name.unwrap_or_default()); + println!(" Version: {}", status.version.unwrap_or_default()); + println!(" Instance: {}", status.instance_name.unwrap_or_default()); + } + Err(e) => { + eprintln!("✗ Failed to connect to Sonarr: {}", e); + return Err(e); + } + } + + // Fetch and display series count + match client.get_series().await { + Ok(series) => { + println!("\n📺 Series Information:"); + println!(" Total series: {}", series.len()); + + let monitored_count = series.iter().filter(|s| s.monitored).count(); + println!(" Monitored series: {}", monitored_count); + + if !series.is_empty() { + println!("\n🎬 Sample series:"); + for (i, show) in series.iter().take(5).enumerate() { + let title = show.title.as_deref().unwrap_or("Unknown"); + let status = if show.monitored { + "Monitored" + } else { + "Not Monitored" + }; + println!(" {}. {} ({})", i + 1, title, status); + } + + if series.len() > 5 { + println!(" ... and {} more", series.len() - 5); + } + } + } + Err(e) => { + eprintln!("✗ Failed to fetch series: {}", e); + } + } + + // Fetch and display queue information + match client.get_queue().await { + Ok(queue) => { + println!("\n📥 Download Queue:"); + println!(" Items in queue: {}", queue.records.len()); + + if !queue.records.is_empty() { + for (i, item) in queue.records.iter().take(3).enumerate() { + let title = item.title.as_deref().unwrap_or("Unknown"); + let status = &item.status; + println!(" {}. {} ({})", i + 1, title, status); + } + + if queue.records.len() > 3 { + println!(" ... and {} more", queue.records.len() - 3); + } + } + } + Err(e) => { + eprintln!("✗ Failed to fetch queue: {}", e); + } + } + + // Check health status + match client.get_health().await { + Ok(health) => { + println!("\n🏥 Health Status:"); + if health.is_empty() { + println!(" ✓ All systems healthy!"); + } else { + println!(" ⚠️ {} health issue(s) detected:", health.len()); + for (i, issue) in health.iter().take(3).enumerate() { + let message = issue.message.as_deref().unwrap_or("Unknown issue"); + println!(" {}. {}", i + 1, message); + } + + if health.len() > 3 { + println!(" ... and {} more issues", health.len() - 3); + } + } + } + Err(e) => { + eprintln!("✗ Failed to fetch health status: {}", e); + } + } + + println!("\n🎉 Example completed successfully!"); + Ok(()) +} diff --git a/yarr-api/src/error.rs b/yarr-api/src/error.rs new file mode 100644 index 0000000..9192d83 --- /dev/null +++ b/yarr-api/src/error.rs @@ -0,0 +1,74 @@ +use thiserror::Error; + +/// Result type alias for API operations +pub type Result = std::result::Result; + +/// Error types for the Sonarr API client +#[derive(Error, Debug)] +pub enum ApiError { + /// HTTP request failed + #[error("HTTP request failed")] + Request, + + /// API returned an error response + #[error("API error")] + Api, + + /// Failed to serialize/deserialize JSON + #[error("JSON serialization/deserialization failed")] + Json, + + /// Invalid URL or endpoint + #[error("Invalid URL or endpoint")] + InvalidUrl, + + /// Authentication failed (invalid API key) + #[error("Authentication failed")] + Authentication, + + /// Resource not found + #[error("Resource not found")] + NotFound, + + /// Rate limit exceeded + #[error("Rate limit exceeded")] + RateLimit, + + /// Server error (5xx responses) + #[error("Server error")] + ServerError, + + /// Connection timeout + #[error("Connection timeout")] + Timeout, + + /// Generic error with custom message + #[error("API client error: {message}")] + Generic { message: String }, +} + +impl From for ApiError { + fn from(err: reqwest::Error) -> Self { + if err.is_timeout() { + ApiError::Timeout + } else if err.is_connect() { + ApiError::Request + } else if let Some(status) = err.status() { + match status.as_u16() { + 401 | 403 => ApiError::Authentication, + 404 => ApiError::NotFound, + 429 => ApiError::RateLimit, + 500..=599 => ApiError::ServerError, + _ => ApiError::Api, + } + } else { + ApiError::Request + } + } +} + +impl From for ApiError { + fn from(_: serde_json::Error) -> Self { + ApiError::Json + } +} diff --git a/src/api.rs b/yarr-api/src/lib.rs similarity index 84% rename from src/api.rs rename to yarr-api/src/lib.rs index 13a98db..3e94955 100644 --- a/src/api.rs +++ b/yarr-api/src/lib.rs @@ -1,5 +1,12 @@ -use crate::errors::{Error, Result}; -use error_stack::{Report, ResultExt}; +//! Sonarr API client library +//! +//! This crate provides a Rust client for the Sonarr API, allowing you to interact +//! with Sonarr instances programmatically. + +pub mod error; + +pub use error::{ApiError, Result}; + use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -20,74 +27,56 @@ impl SonarrClient { } } - async fn get Deserialize<'de>>(&self, endpoint: &str) -> Result { + async fn get Deserialize<'de>>(&self, endpoint: &str) -> Result { let url = format!("{}/api/v3{}", self.base_url, endpoint); let response = self .client .get(&url) .header("X-Api-Key", &self.api_key) .send() - .await - .change_context(Error::Request) - .attach_printable("Failed to send HTTP request")?; + .await?; if !response.status().is_success() { let status = response.status(); let error_text = response .text() .await - .change_context(Error::Request) - .attach_printable("Failed to read error response body")?; - return Err(Report::new(Error::Api) - .attach_printable(format!("HTTP {}: {}", status, error_text))); + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(ApiError::Generic { + message: format!("HTTP {}: {}", status, error_text), + }); } - let text = response - .text() - .await - .change_context(Error::Request) - .attach_printable("Failed to read response body")?; - let deser = &mut serde_json::Deserializer::from_str(&text); - - serde_path_to_error::deserialize(deser) - .change_context(Error::Serialization) - .attach_printable("Failed to deserialize API response") + let text = response.text().await?; + let result: T = serde_json::from_str(&text)?; + Ok(result) } #[allow(dead_code)] - async fn get_debug Deserialize<'de>>(&self, endpoint: &str) -> Result { + async fn get_debug Deserialize<'de>>(&self, endpoint: &str) -> Result { let url = format!("{}/api/v3{}", self.base_url, endpoint); let response = self .client .get(&url) .header("X-Api-Key", &self.api_key) .send() - .await - .change_context(Error::Request) - .attach_printable("Failed to send debug HTTP request")?; + .await?; if !response.status().is_success() { let status = response.status(); let error_text = response .text() .await - .change_context(Error::Request) - .attach_printable("Failed to read debug error response body")?; - return Err(Report::new(Error::Api) - .attach_printable(format!("Debug HTTP {}: {}", status, error_text))); + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(ApiError::Generic { + message: format!("Debug HTTP {}: {}", status, error_text), + }); } - let text = response - .text() - .await - .change_context(Error::Request) - .attach_printable("Failed to read debug response body")?; + let text = response.text().await?; let _ = std::fs::write(endpoint.replace("/", "_"), &text); - let deser = &mut serde_json::Deserializer::from_str(&text); - - serde_path_to_error::deserialize(deser) - .change_context(Error::Serialization) - .attach_printable("Failed to deserialize debug API response") + let result: T = serde_json::from_str(&text)?; + Ok(result) } #[allow(dead_code)] @@ -95,7 +84,7 @@ impl SonarrClient { &self, endpoint: &str, body: &T, - ) -> Result { + ) -> Result { let url = format!("{}/api/v3{}", self.base_url, endpoint); let response = self .client @@ -103,42 +92,34 @@ impl SonarrClient { .header("X-Api-Key", &self.api_key) .json(body) .send() - .await - .change_context(Error::Request) - .attach_printable("Failed to send POST request")?; + .await?; if !response.status().is_success() { let status = response.status(); let error_text = response .text() .await - .change_context(Error::Request) - .attach_printable("Failed to read POST error response body")?; - return Err(Report::new(Error::Api) - .attach_printable(format!("POST HTTP {}: {}", status, error_text))); + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(ApiError::Generic { + message: format!("POST HTTP {}: {}", status, error_text), + }); } - let text = response - .text() - .await - .change_context(Error::Request) - .attach_printable("Failed to read POST response body")?; - let deser = &mut serde_json::Deserializer::from_str(&text); - serde_path_to_error::deserialize(deser) - .change_context(Error::Serialization) - .attach_printable("Failed to deserialize POST API response") + let text = response.text().await?; + let result: R = serde_json::from_str(&text)?; + Ok(result) } - pub async fn get_system_status(&self) -> Result { + pub async fn get_system_status(&self) -> Result { self.get("/system/status").await } - pub async fn get_series(&self) -> Result, Error> { + pub async fn get_series(&self) -> Result, ApiError> { self.get("/series").await } #[allow(dead_code)] - pub async fn get_series_by_id(&self, id: u32) -> Result { + pub async fn get_series_by_id(&self, id: u32) -> Result { self.get(&format!("/series/{}", id)).await } @@ -147,7 +128,7 @@ impl SonarrClient { &self, series_id: Option, season_number: Option, - ) -> Result, Error> { + ) -> Result, ApiError> { let mut query = Vec::new(); if let Some(id) = series_id { query.push(format!("seriesId={}", id)); @@ -169,7 +150,7 @@ impl SonarrClient { &self, start: Option<&str>, end: Option<&str>, - ) -> Result, Error> { + ) -> Result, ApiError> { let mut query = Vec::new(); if let Some(start_date) = start { query.push(format!("start={}", start_date)); @@ -187,24 +168,24 @@ impl SonarrClient { self.get(&format!("/calendar{}", query_string)).await } - pub async fn get_queue(&self) -> Result { + pub async fn get_queue(&self) -> Result { self.get("/queue").await } - pub async fn get_history(&self) -> Result { + pub async fn get_history(&self) -> Result { self.get("/history").await } #[allow(dead_code)] - pub async fn get_missing_episodes(&self) -> Result { + pub async fn get_missing_episodes(&self) -> Result { self.get("/wanted/missing").await } - pub async fn get_health(&self) -> Result, Error> { + pub async fn get_health(&self) -> Result, ApiError> { self.get("/health").await } - pub async fn search_series(&self, term: &str) -> Result, Error> { + pub async fn search_series(&self, term: &str) -> Result, ApiError> { self.get(&format!( "/series/lookup?term={}", urlencoding::encode(term) @@ -213,7 +194,7 @@ impl SonarrClient { } #[allow(dead_code)] - pub async fn add_series(&self, series: &Series) -> Result { + pub async fn add_series(&self, series: &Series) -> Result { self.post("/series", series).await } } diff --git a/yarr-cli/Cargo.toml b/yarr-cli/Cargo.toml new file mode 100644 index 0000000..257bb8b --- /dev/null +++ b/yarr-cli/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "yarr" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "A TUI client for Sonarr" +authors = ["yarr contributors"] +repository = "https://github.com/user/yarr" + +[[bin]] +name = "yarr" +path = "src/main.rs" + +[dependencies] +# API client library +yarr-api = { path = "../yarr-api" } + +# CLI dependencies +clap = { version = "4.5", features = ["derive", "env"] } +clap_complete = "4.5" + +# Error handling +error-stack = { workspace = true } +thiserror = { workspace = true } + +# Async runtime +tokio = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# TUI dependencies +ratatui = { version = "0.28", features = ["crossterm"] } +crossterm = "0.28" + +# Serialization +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +# Date/time handling +chrono = { workspace = true, features = ["serde"] } + +# Async utilities +futures = "0.3" + +# Configuration +config = "0.14" +toml = "0.8" +dirs = "5.0" diff --git a/sonarr.json b/yarr-cli/sonarr.json similarity index 100% rename from sonarr.json rename to yarr-cli/sonarr.json diff --git a/src/cli.rs b/yarr-cli/src/cli.rs similarity index 100% rename from src/cli.rs rename to yarr-cli/src/cli.rs diff --git a/src/config.rs b/yarr-cli/src/config.rs similarity index 100% rename from src/config.rs rename to yarr-cli/src/config.rs diff --git a/src/main.rs b/yarr-cli/src/main.rs similarity index 99% rename from src/main.rs rename to yarr-cli/src/main.rs index 82b9227..ed05448 100644 --- a/src/main.rs +++ b/yarr-cli/src/main.rs @@ -1,14 +1,12 @@ #[deny(warnings)] -mod api; mod cli; mod config; -mod errors; mod tui; -use crate::api::SonarrClient; use crate::config::AppConfig; use clap::Parser; use std::process; +use yarr_api::SonarrClient; #[tokio::main] pub async fn main() -> Result<(), Box> { diff --git a/src/tui.rs b/yarr-cli/src/tui.rs similarity index 98% rename from src/tui.rs rename to yarr-cli/src/tui.rs index 05ad128..9ce972f 100644 --- a/src/tui.rs +++ b/yarr-cli/src/tui.rs @@ -20,10 +20,10 @@ use std::{ }; use tokio::sync::mpsc; -use crate::api::{ +use crate::config::{AppConfig, KeybindMode}; +use yarr_api::{ Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus, }; -use crate::config::{AppConfig, KeybindMode}; #[derive(Debug)] pub enum AppEvent { @@ -44,29 +44,29 @@ pub enum AppEvent { #[derive(Debug, Clone, Copy, PartialEq)] pub enum TabIndex { Series, + Search, Calendar, Queue, History, Health, - Search, Settings, } impl TabIndex { fn titles() -> Vec<&'static str> { vec![ - "Series", "Calendar", "Queue", "History", "Health", "Search", "Settings", + "Series", "Search", "Calendar", "Queue", "History", "Health", "Settings", ] } fn from_index(index: usize) -> Self { match index { 0 => TabIndex::Series, - 1 => TabIndex::Calendar, - 2 => TabIndex::Queue, - 3 => TabIndex::History, - 4 => TabIndex::Health, - 5 => TabIndex::Search, + 1 => TabIndex::Search, + 2 => TabIndex::Calendar, + 3 => TabIndex::Queue, + 4 => TabIndex::History, + 5 => TabIndex::Health, 6 => TabIndex::Settings, _ => TabIndex::Series, } @@ -75,11 +75,11 @@ impl TabIndex { fn to_index(&self) -> usize { match self { TabIndex::Series => 0, - TabIndex::Calendar => 1, - TabIndex::Queue => 2, - TabIndex::History => 3, - TabIndex::Health => 4, - TabIndex::Search => 5, + TabIndex::Search => 1, + TabIndex::Calendar => 2, + TabIndex::Queue => 3, + TabIndex::History => 4, + TabIndex::Health => 5, TabIndex::Settings => 6, } } @@ -400,6 +400,11 @@ impl App { self.series_list_state.select(Some(0)); } } + TabIndex::Calendar => { + if !self.calendar.is_empty() { + self.episodes_list_state.select(Some(0)); + } + } TabIndex::Queue => { if !self.queue.is_empty() { self.queue_table_state.select(Some(0)); @@ -423,7 +428,6 @@ impl App { TabIndex::Settings => { self.settings_list_state.select(Some(0)); } - _ => {} } } @@ -434,6 +438,12 @@ impl App { self.series_list_state.select(Some(self.series.len() - 1)); } } + TabIndex::Calendar => { + if !self.calendar.is_empty() { + self.episodes_list_state + .select(Some(self.calendar.len() - 1)); + } + } TabIndex::Queue => { if !self.queue.is_empty() { self.queue_table_state.select(Some(self.queue.len() - 1)); @@ -459,7 +469,6 @@ impl App { TabIndex::Settings => { self.settings_list_state.select(Some(4)); // Total settings options - 1 } - _ => {} } } diff --git a/yarr-vim.toml.example b/yarr-vim.toml.example new file mode 100644 index 0000000..46f6be7 --- /dev/null +++ b/yarr-vim.toml.example @@ -0,0 +1,52 @@ +# Yarr Configuration File - Vim Mode Example +# Copy this file to one of the following locations: +# - ./yarr.toml (current directory) +# - ~/.config/yarr/config.toml (user config directory) + +[sonarr] +# Sonarr server URL (required) +# Example: "http://localhost:8989" or "https://sonarr.example.com" +# Can also be edited from the Settings tab in the application +url = "http://localhost:8989" + +# Sonarr API key (required) +# You can find this in Sonarr under Settings > General > Security > API Key +# Can also be edited from the Settings tab in the application +api_key = "your-api-key-here" + +[ui] +# Keybind mode: "Normal" or "Vim" +# This example shows vim mode configuration +# Can be toggled from the Settings tab in the application +keybind_mode = "Vim" + +# Show help text in the footer +# Can be toggled from the Settings tab in the application +show_help = true + +# Vim Mode Keybinds: +# - h/j/k/l: Navigate left/down/up/right +# - w/b: Next/previous tab +# - gg: Go to first item +# - G: Go to last item +# - v: Toggle details (visual mode) +# - u: Refresh data (undo) +# - /: Search mode +# - i: Insert/input mode (search) +# - s: Save configuration changes +# - q: Quit + +# All settings including Sonarr URL and API key can be changed in the Settings tab: +# 1. Launch yarr and navigate to the Settings tab (last tab) +# 2. Use vim navigation (j/k) to select a setting +# 3. Press Enter to edit Sonarr URL/API key or toggle other options +# 4. Press 's' to save changes + +# Environment variables can also be used: +# YARR_SONARR_URL="http://localhost:8989" +# YARR_SONARR_API_KEY="your-api-key-here" +# YARR_UI_KEYBIND_MODE="Vim" +# YARR_UI_SHOW_HELP="true" +# +# Command line arguments take highest priority: +# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"