feat(yarr): restructure into workspace with separate API and CLI crates
Some checks failed
build / checks-matrix (push) Has been cancelled
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled

This commit is contained in:
uttarayan21
2025-10-08 17:02:34 +05:30
parent e9ecd2a295
commit 03fd2de38f
17 changed files with 803 additions and 148 deletions

28
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"

169
KEYBINDS.md Normal file
View File

@@ -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`

View File

@@ -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

View File

@@ -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<T, E = Error> = core::result::Result<T, Report<E>>;

33
yarr-api/Cargo.toml Normal file
View File

@@ -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 }

135
yarr-api/README.md Normal file
View File

@@ -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

View File

@@ -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(())
}

74
yarr-api/src/error.rs Normal file
View File

@@ -0,0 +1,74 @@
use thiserror::Error;
/// Result type alias for API operations
pub type Result<T, E = ApiError> = std::result::Result<T, E>;
/// 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<reqwest::Error> 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<serde_json::Error> for ApiError {
fn from(_: serde_json::Error) -> Self {
ApiError::Json
}
}

View File

@@ -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<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, Error> {
async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, ApiError> {
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<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, Error> {
async fn get_debug<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, ApiError> {
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<R, Error> {
) -> Result<R, ApiError> {
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<SystemStatus, Error> {
pub async fn get_system_status(&self) -> Result<SystemStatus, ApiError> {
self.get("/system/status").await
}
pub async fn get_series(&self) -> Result<Vec<Series>, Error> {
pub async fn get_series(&self) -> Result<Vec<Series>, ApiError> {
self.get("/series").await
}
#[allow(dead_code)]
pub async fn get_series_by_id(&self, id: u32) -> Result<Series, Error> {
pub async fn get_series_by_id(&self, id: u32) -> Result<Series, ApiError> {
self.get(&format!("/series/{}", id)).await
}
@@ -147,7 +128,7 @@ impl SonarrClient {
&self,
series_id: Option<u32>,
season_number: Option<u32>,
) -> Result<Vec<Episode>, Error> {
) -> Result<Vec<Episode>, 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<Vec<Episode>, Error> {
) -> Result<Vec<Episode>, 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<QueuePagingResource, Error> {
pub async fn get_queue(&self) -> Result<QueuePagingResource, ApiError> {
self.get("/queue").await
}
pub async fn get_history(&self) -> Result<HistoryPagingResource, Error> {
pub async fn get_history(&self) -> Result<HistoryPagingResource, ApiError> {
self.get("/history").await
}
#[allow(dead_code)]
pub async fn get_missing_episodes(&self) -> Result<EpisodePagingResource, Error> {
pub async fn get_missing_episodes(&self) -> Result<EpisodePagingResource, ApiError> {
self.get("/wanted/missing").await
}
pub async fn get_health(&self) -> Result<Vec<HealthResource>, Error> {
pub async fn get_health(&self) -> Result<Vec<HealthResource>, ApiError> {
self.get("/health").await
}
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>, Error> {
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>, 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<Series, Error> {
pub async fn add_series(&self, series: &Series) -> Result<Series, ApiError> {
self.post("/series", series).await
}
}

50
yarr-cli/Cargo.toml Normal file
View File

@@ -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"

View File

@@ -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<dyn std::error::Error>> {

View File

@@ -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
}
_ => {}
}
}

52
yarr-vim.toml.example Normal file
View File

@@ -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"