Compare commits

..

10 Commits

Author SHA1 Message Date
uttarayan21
92d69f13f0 feat(config): add command to edit config files with preferred editor
Some checks failed
build / checks-matrix (push) Successful in 19m18s
build / codecov (push) Failing after 19m13s
docs / docs (push) Failing after 28m39s
build / checks-build (push) Has been cancelled
2025-10-13 15:44:52 +05:30
uttarayan21
37e682a162 test: remove unnecessary env import from tests module in config.rs
Some checks failed
build / checks-matrix (push) Successful in 19m19s
build / codecov (push) Failing after 19m21s
docs / docs (push) Failing after 28m48s
build / checks-build (push) Has been cancelled
2025-10-08 17:17:22 +05:30
uttarayan21
f039977e94 feat: Move yarr-cli into the workspace package
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
2025-10-08 17:14:51 +05:30
uttarayan21
b876626d3e docs: add heading for note on AI assistance in README.md
Some checks failed
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-matrix (push) Has been cancelled
2025-10-08 17:10:23 +05:30
uttarayan21
e5521f2c3e chore(dependencies): update multiple dependencies and library versions
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
2025-10-08 17:09:28 +05:30
uttarayan21
9fcacdcb32 chore(deps): update dependencies in Cargo.lock
Some checks failed
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-matrix (push) Has been cancelled
2025-10-08 17:04:49 +05:30
uttarayan21
03fd2de38f 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
2025-10-08 17:02:34 +05:30
uttarayan21
e9ecd2a295 refactor(api): enhance error handling using error_stack crate
Some checks failed
build / checks-matrix (push) Successful in 19m20s
build / checks-build (push) Has been cancelled
docs / docs (push) Has been cancelled
build / codecov (push) Has been cancelled
2025-10-08 16:25:56 +05:30
uttarayan21
8139fe4cb3 chore: annotate unused code with #[allow(dead_code)]
Some checks failed
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-matrix (push) Has been cancelled
2025-10-08 16:20:27 +05:30
uttarayan21
a8f0ab160e feat(tui): add vim-like keybinds and settings tab for config edit
Some checks failed
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-matrix (push) Has been cancelled
2025-10-08 16:11:41 +05:30
18 changed files with 1848 additions and 454 deletions

634
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,67 @@
[workspace]
members = [
"yarr-api"
]
resolver = "2"
[workspace.dependencies]
# Common dependencies that can be shared across workspace members
tokio = { version = "1.43.1", features = ["full"] }
yarr-api = { path = "yarr-api" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.12", features = ["json"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = "0.3"
[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 = { workspace = true }
# CLI dependencies
clap = { version = "4.5", features = ["derive", "env"] }
clap_complete = "4.5"
error-stack = "0.5"
thiserror = "2.0"
tokio = { version = "1.43.1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
# Error handling
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"
ratatui = { version = "0.29", features = ["crossterm"] }
crossterm = "0.29"
# HTTP client and serialization
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Serialization
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# Date/time handling
chrono = { version = "0.4", features = ["serde"] }
chrono = { workspace = true, 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"
config = "0.15"
toml = "0.9"
dirs = "6.0"

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`

132
README.md
View File

@@ -1,6 +1,15 @@
# 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
@@ -8,13 +17,26 @@ A Terminal User Interface (TUI) for managing Sonarr.
- Browse series and episodes
- Monitor download queue
- View download history
- Interactive TUI interface
- Interactive TUI interface with vim-like keybind support
- Configurable UI preferences and keybind modes
- In-app configuration editing for Sonarr connection and UI settings
- Configurable via config files, environment variables, or CLI arguments
## 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
@@ -39,6 +61,12 @@ Example configuration:
[sonarr]
url = "http://localhost:8989"
api_key = "your-api-key-here"
[ui]
# Keybind mode: "Normal" or "Vim"
keybind_mode = "Normal"
# Show help text in footer
show_help = true
```
### Environment Variables
@@ -48,6 +76,8 @@ Set these environment variables:
```bash
export YARR_SONARR_URL="http://localhost:8989"
export YARR_SONARR_API_KEY="your-api-key-here"
export YARR_UI_KEYBIND_MODE="Vim"
export YARR_UI_SHOW_HELP="true"
```
### Command Line Arguments
@@ -114,6 +144,18 @@ Show configuration file search paths:
yarr config paths
```
Edit configuration file with your preferred editor:
```bash
yarr config edit
```
Edit specific config file:
```bash
yarr config edit --path /path/to/config.toml
```
### Shell Completions
Generate shell completions:
@@ -134,11 +176,49 @@ yarr completions powershell > yarr.ps1
## TUI Controls
### Normal Mode (Default)
- `q` - Quit
- `↑/↓` or `j/k` - Navigate up/down
- `Enter` - Select/expand
- `Tab` - Switch between panels
- `Tab` - Switch between tabs
- `d` - Toggle details
- `r` - Refresh data
- `/` - Search (in Search tab)
- `s` - Save configuration changes
### Vim Mode
- `q` - Quit
- `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
- `s` - Save configuration changes
### Settings Tab
Use the Settings tab to:
- Toggle between Normal and Vim keybind modes
- Enable/disable help text display
- Edit Sonarr server URL and API key
- Save configuration changes to file
Access the Settings tab by navigating to the last tab or pressing `Tab` repeatedly.
### In-App Configuration
You can configure Sonarr connection settings directly within the application:
1. Navigate to the Settings tab
2. Select "Sonarr URL" or "API Key" and press Enter
3. Type the new value and press Enter to save
4. Press 's' to save all changes to the config file
This eliminates the need to manually edit config files for basic setup.
## Getting Started
@@ -160,6 +240,50 @@ yarr completions powershell > yarr.ps1
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

@@ -144,6 +144,7 @@
stableToolchainWithRustAnalyzer
cargo-nextest
cargo-deny
cargo-udeps
]
++ (lib.optionals pkgs.stdenv.isDarwin [
apple-sdk_13

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,13 @@ pub enum ConfigAction {
/// Show possible config file locations
Paths,
/// Edit configuration file with $EDITOR or vi
Edit {
/// Path to config file to edit
#[arg(short, long)]
path: Option<PathBuf>,
},
}
impl Cli {

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub sonarr: SonarrConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -13,6 +14,33 @@ pub struct SonarrConfig {
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
pub keybind_mode: KeybindMode,
pub show_help: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeybindMode {
Normal,
Vim,
}
impl Default for KeybindMode {
fn default() -> Self {
Self::Normal
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
keybind_mode: KeybindMode::default(),
show_help: true,
}
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
@@ -20,6 +48,7 @@ impl Default for AppConfig {
url: "http://localhost:8989".to_string(),
api_key: String::new(),
},
ui: UiConfig::default(),
}
}
}
@@ -92,6 +121,10 @@ impl AppConfig {
url: "http://localhost:8989".to_string(),
api_key: "your-api-key-here".to_string(),
},
ui: UiConfig {
keybind_mode: KeybindMode::Normal,
show_help: true,
},
};
let toml_content = toml::to_string_pretty(&sample_config)?;
@@ -144,7 +177,6 @@ impl AppConfig {
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_default_config() {

View File

@@ -1,6 +0,0 @@
// Removed unused imports Report and ResultExt
#[derive(Debug, thiserror::Error)]
#[error("An error occurred")]
pub struct Error;
pub type Result<T, E = error_stack::Report<Error>> = core::result::Result<T, E>;

View File

@@ -1,13 +1,13 @@
mod api;
#[deny(warnings)]
mod cli;
mod config;
mod errors;
mod tui;
use crate::api::SonarrClient;
use crate::config::AppConfig;
use clap::Parser;
use std::process;
use std::process::Command;
use yarr_api::SonarrClient;
#[tokio::main]
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -61,7 +61,7 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
Some(cli::Commands::Tui) | None => {
// Default to TUI mode
tui::run_app(client).await?;
tui::run_app(client, config).await?;
}
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
// Already handled above
@@ -123,6 +123,64 @@ fn handle_config_command(
println!(" YARR_SONARR_URL");
println!(" YARR_SONARR_API_KEY");
}
cli::ConfigAction::Edit { path } => {
let config_path = if let Some(path) = path {
path.clone()
} else {
// Find existing config file or use default location
let paths = AppConfig::get_default_config_paths();
let existing_config = paths.iter().find(|p| p.exists());
if let Some(existing_path) = existing_config {
existing_path.clone()
} else {
// No existing config found, use preferred default location
if let Some(default_path) = paths.get(1) {
// Prefer user config directory over current directory
default_path.clone()
} else {
std::env::current_dir()?.join("yarr.toml")
}
}
};
// Create config file if it doesn't exist
if !config_path.exists() {
println!(
"Config file doesn't exist. Creating sample config at: {}",
config_path.display()
);
AppConfig::create_sample_config(&config_path)?;
}
// Get editor from environment or use vi as fallback
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
println!(
"Opening config file with {}: {}",
editor,
config_path.display()
);
// Execute editor
let status = Command::new(&editor).arg(&config_path).status();
match status {
Ok(exit_status) => {
if exit_status.success() {
println!("Configuration file edited successfully.");
} else {
eprintln!("Editor exited with non-zero status: {}", exit_status);
process::exit(1);
}
}
Err(e) => {
eprintln!("Failed to launch editor '{}': {}", editor, e);
eprintln!("Make sure the editor is installed and accessible in your PATH.");
process::exit(1);
}
}
}
}
Ok(())
}

View File

@@ -20,7 +20,8 @@ use std::{
};
use tokio::sync::mpsc;
use crate::api::{
use crate::config::{AppConfig, KeybindMode};
use yarr_api::{
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
};
@@ -30,6 +31,7 @@ pub enum AppEvent {
Key(crossterm::event::KeyEvent),
Error(String),
SeriesLoaded(Vec<Series>),
#[allow(dead_code)]
EpisodesLoaded(Vec<Episode>),
QueueLoaded(Vec<QueueItem>),
HistoryLoaded(Vec<HistoryItem>),
@@ -42,38 +44,43 @@ 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"]
vec![
"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,
}
}
fn to_index(self) -> usize {
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,
}
}
}
@@ -87,6 +94,7 @@ pub struct App {
pub history_table_state: TableState,
pub health_list_state: ListState,
pub search_list_state: ListState,
pub settings_list_state: ListState,
pub series: Vec<Series>,
pub episodes: Vec<Episode>,
pub queue: Vec<QueueItem>,
@@ -94,6 +102,7 @@ pub struct App {
pub health: Vec<HealthResource>,
pub calendar: Vec<Episode>,
pub system_status: Option<SystemStatus>,
#[allow(dead_code)]
pub selected_series: Option<Series>,
pub loading: bool,
pub error_message: Option<String>,
@@ -102,6 +111,12 @@ pub struct App {
pub search_mode: bool,
pub show_details: bool,
pub input_mode: bool,
pub config: AppConfig,
pub config_changed: bool,
pub editing_url: bool,
pub editing_api_key: bool,
pub url_input: String,
pub api_key_input: String,
}
impl Default for App {
@@ -115,6 +130,7 @@ impl Default for App {
history_table_state: TableState::default(),
health_list_state: ListState::default(),
search_list_state: ListState::default(),
settings_list_state: ListState::default(),
series: Vec::new(),
episodes: Vec::new(),
queue: Vec::new(),
@@ -130,8 +146,15 @@ impl Default for App {
search_mode: false,
show_details: false,
input_mode: false,
config: AppConfig::default(),
config_changed: false,
editing_url: false,
editing_api_key: false,
url_input: String::new(),
api_key_input: String::new(),
};
app.series_list_state.select(Some(0));
app.settings_list_state.select(Some(0));
app
}
}
@@ -215,6 +238,9 @@ impl App {
self.search_list_state.select(Some(i));
}
}
TabIndex::Settings => {
self.next_settings_item();
}
}
}
@@ -316,9 +342,13 @@ impl App {
self.search_list_state.select(Some(i));
}
}
TabIndex::Settings => {
self.previous_settings_item();
}
}
}
#[allow(dead_code)]
pub fn get_selected_series(&self) -> Option<&Series> {
if let Some(index) = self.series_list_state.selected() {
self.series.get(index)
@@ -327,6 +357,7 @@ impl App {
}
}
#[allow(dead_code)]
pub fn get_selected_search_result(&self) -> Option<&Series> {
if let Some(index) = self.search_list_state.selected() {
self.search_results.get(index)
@@ -361,9 +392,217 @@ impl App {
pub fn set_error(&mut self, error: String) {
self.error_message = Some(error);
}
pub fn go_to_first(&mut self) {
match self.current_tab {
TabIndex::Series => {
if !self.series.is_empty() {
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));
}
}
TabIndex::History => {
if !self.history.is_empty() {
self.history_table_state.select(Some(0));
}
}
TabIndex::Health => {
if !self.health.is_empty() {
self.health_list_state.select(Some(0));
}
}
TabIndex::Search => {
if !self.search_results.is_empty() {
self.search_list_state.select(Some(0));
}
}
TabIndex::Settings => {
self.settings_list_state.select(Some(0));
}
}
}
pub async fn run_app(client: SonarrClient) -> Result<(), Box<dyn std::error::Error>> {
pub fn go_to_last(&mut self) {
match self.current_tab {
TabIndex::Series => {
if !self.series.is_empty() {
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));
}
}
TabIndex::History => {
if !self.history.is_empty() {
self.history_table_state
.select(Some(self.history.len() - 1));
}
}
TabIndex::Health => {
if !self.health.is_empty() {
self.health_list_state.select(Some(self.health.len() - 1));
}
}
TabIndex::Search => {
if !self.search_results.is_empty() {
self.search_list_state
.select(Some(self.search_results.len() - 1));
}
}
TabIndex::Settings => {
self.settings_list_state.select(Some(4)); // Total settings options - 1
}
}
}
pub fn toggle_keybind_mode(&mut self) {
self.config.ui.keybind_mode = match self.config.ui.keybind_mode {
KeybindMode::Normal => KeybindMode::Vim,
KeybindMode::Vim => KeybindMode::Normal,
};
self.config_changed = true;
}
pub fn toggle_help(&mut self) {
self.config.ui.show_help = !self.config.ui.show_help;
self.config_changed = true;
}
pub fn handle_settings_input(&mut self) {
if let Some(selected) = self.settings_list_state.selected() {
match selected {
0 => self.toggle_keybind_mode(),
1 => self.toggle_help(),
2 => self.start_editing_url(),
3 => self.start_editing_api_key(),
_ => {}
}
}
}
pub fn start_editing_url(&mut self) {
self.editing_url = true;
self.input_mode = true;
self.url_input = self.config.sonarr.url.clone();
}
pub fn start_editing_api_key(&mut self) {
self.editing_api_key = true;
self.input_mode = true;
self.api_key_input = self.config.sonarr.api_key.clone();
}
pub fn finish_editing_url(&mut self) {
let trimmed_url = self.url_input.trim();
if !trimmed_url.is_empty() {
// Basic URL validation
if trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://") {
self.config.sonarr.url = trimmed_url.to_string();
self.config_changed = true;
} else {
self.set_error("URL must start with http:// or https://".to_string());
}
}
self.editing_url = false;
self.input_mode = false;
self.url_input.clear();
}
pub fn finish_editing_api_key(&mut self) {
if !self.api_key_input.trim().is_empty() {
self.config.sonarr.api_key = self.api_key_input.trim().to_string();
self.config_changed = true;
}
self.editing_api_key = false;
self.input_mode = false;
self.api_key_input.clear();
}
pub fn cancel_editing(&mut self) {
self.editing_url = false;
self.editing_api_key = false;
self.input_mode = false;
self.url_input.clear();
self.api_key_input.clear();
}
pub fn next_settings_item(&mut self) {
let i = match self.settings_list_state.selected() {
Some(i) => {
if i >= 4 {
0
} else {
i + 1
}
}
None => 0,
};
self.settings_list_state.select(Some(i));
}
pub fn previous_settings_item(&mut self) {
let i = match self.settings_list_state.selected() {
Some(i) => {
if i == 0 {
4
} else {
i - 1
}
}
None => 0,
};
self.settings_list_state.select(Some(i));
}
pub fn save_config(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if !self.config_changed {
return Ok(());
}
// Find a writable config path
let config_paths = crate::config::AppConfig::get_default_config_paths();
let config_path = if let Some(path) = config_paths.get(1) {
// Prefer user config directory
path.clone()
} else {
std::env::current_dir()?.join("yarr.toml")
};
// Create directory if it doesn't exist
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Save the config
let toml_content = toml::to_string_pretty(&self.config)?;
std::fs::write(&config_path, toml_content)?;
self.config_changed = false;
Ok(())
}
}
pub async fn run_app(
client: SonarrClient,
config: AppConfig,
) -> Result<(), Box<dyn std::error::Error>> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
@@ -373,6 +612,7 @@ pub async fn run_app(client: SonarrClient) -> Result<(), Box<dyn std::error::Err
// Create app and run it
let mut app = App::default();
app.config = config;
let res = run_tui(&mut terminal, &mut app, client).await;
// Restore terminal
@@ -588,9 +828,28 @@ async fn handle_input(
tx: mpsc::UnboundedSender<AppEvent>,
) {
if app.input_mode {
handle_input_mode(app, key, client, tx).await;
} else {
match app.config.ui.keybind_mode {
KeybindMode::Normal => handle_normal_mode(app, key, client, tx).await,
KeybindMode::Vim => handle_vim_mode(app, key, client, tx).await,
}
}
}
async fn handle_input_mode(
app: &mut App,
key: crossterm::event::KeyEvent,
client: SonarrClient,
tx: mpsc::UnboundedSender<AppEvent>,
) {
match key.code {
KeyCode::Enter => {
if !app.search_input.is_empty() {
if app.editing_url {
app.finish_editing_url();
} else if app.editing_api_key {
app.finish_editing_api_key();
} else if !app.search_input.is_empty() {
app.loading = true;
let search_term = app.search_input.clone();
let client_clone = client.clone();
@@ -601,43 +860,89 @@ async fn handle_input(
let _ = tx_clone.send(AppEvent::SearchResults(results));
}
Err(e) => {
let _ =
tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
let _ = tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
}
}
});
}
app.input_mode = false;
}
}
KeyCode::Esc => {
if app.editing_url || app.editing_api_key {
app.cancel_editing();
} else {
app.exit_search_mode();
}
}
KeyCode::Char(c) => {
if app.editing_url {
app.url_input.push(c);
} else if app.editing_api_key {
app.api_key_input.push(c);
} else {
app.search_input.push(c);
}
}
KeyCode::Backspace => {
if app.editing_url {
app.url_input.pop();
} else if app.editing_api_key {
app.api_key_input.pop();
} else {
app.search_input.pop();
}
}
_ => {}
}
} else {
}
async fn handle_normal_mode(
app: &mut App,
key: crossterm::event::KeyEvent,
client: SonarrClient,
tx: mpsc::UnboundedSender<AppEvent>,
) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
KeyCode::Down | KeyCode::Char('j') => app.next_item(),
KeyCode::Up | KeyCode::Char('k') => app.previous_item(),
KeyCode::Down | KeyCode::Char('j') => {
if app.current_tab == TabIndex::Settings {
app.next_settings_item();
} else {
app.next_item();
}
}
KeyCode::Up | KeyCode::Char('k') => {
if app.current_tab == TabIndex::Settings {
app.previous_settings_item();
} else {
app.previous_item();
}
}
KeyCode::Char('d') => app.toggle_details(),
KeyCode::Char('r') => {
app.loading = true;
app.clear_error();
load_all_data(client, tx);
tokio::spawn(async move {
load_all_data(client, tx).await;
});
}
KeyCode::Char('/') => {
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Char('s') => {
if let Err(e) = app.save_config() {
app.set_error(format!("Failed to save config: {}", e));
}
}
KeyCode::Enter => {
if app.current_tab == TabIndex::Settings {
app.handle_settings_input();
}
}
KeyCode::Esc => {
app.clear_error();
if app.search_mode {
@@ -647,6 +952,85 @@ async fn handle_input(
_ => {}
}
}
async fn handle_vim_mode(
app: &mut App,
key: crossterm::event::KeyEvent,
client: SonarrClient,
tx: mpsc::UnboundedSender<AppEvent>,
) {
match key.code {
// Vim-like quit
KeyCode::Char('q') => app.should_quit = true,
// Tab navigation
KeyCode::Char('w') | KeyCode::Tab => app.next_tab(),
KeyCode::Char('b') | KeyCode::BackTab => app.previous_tab(),
// Vim navigation
KeyCode::Char('j') | KeyCode::Down => {
if app.current_tab == TabIndex::Settings {
app.next_settings_item();
} else {
app.next_item();
}
}
KeyCode::Char('k') | KeyCode::Up => {
if app.current_tab == TabIndex::Settings {
app.previous_settings_item();
} else {
app.previous_item();
}
}
KeyCode::Char('h') | KeyCode::Left => app.previous_tab(),
KeyCode::Char('l') | KeyCode::Right => app.next_tab(),
// Go to first/last
KeyCode::Char('g') => {
// Handle gg for go to first (simplified - in real vim this would need state)
app.go_to_first();
}
KeyCode::Char('G') => app.go_to_last(),
// Other vim-like commands
KeyCode::Char('v') => app.toggle_details(), // visual/details mode
KeyCode::Char('u') => {
// undo/refresh
app.loading = true;
app.clear_error();
tokio::spawn(async move {
load_all_data(client, tx).await;
});
}
KeyCode::Char('/') => {
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Char('i') => {
// insert mode - enter search if on search tab
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Enter => {
if app.current_tab == TabIndex::Settings {
app.handle_settings_input();
}
}
KeyCode::Char('s') => {
if let Err(e) = app.save_config() {
app.set_error(format!("Failed to save config: {}", e));
}
}
KeyCode::Esc => {
app.clear_error();
if app.search_mode {
app.exit_search_mode();
}
}
_ => {}
}
}
fn ui(f: &mut Frame, app: &App) {
@@ -673,6 +1057,7 @@ fn ui(f: &mut Frame, app: &App) {
TabIndex::History => render_history_tab(f, chunks[1], app),
TabIndex::Health => render_health_tab(f, chunks[1], app),
TabIndex::Search => render_search_tab(f, chunks[1], app),
TabIndex::Settings => render_settings_tab(f, chunks[1], app),
}
// Render footer
@@ -862,7 +1247,7 @@ fn render_queue_tab(f: &mut Frame, area: Rect, app: &App) {
.borders(Borders::ALL)
.title("Download Queue"),
)
.highlight_style(
.row_highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
@@ -930,7 +1315,7 @@ fn render_history_tab(f: &mut Frame, area: Rect, app: &App) {
)
.header(header.style(Style::default().fg(Color::Yellow)))
.block(Block::default().borders(Borders::ALL).title("History"))
.highlight_style(
.row_highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
@@ -1072,11 +1457,140 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
}
}
fn render_settings_tab(f: &mut Frame, area: Rect, app: &App) {
if app.editing_url || app.editing_api_key {
render_settings_input(f, area, app);
} else {
render_settings_list(f, area, app);
}
}
fn render_settings_list(f: &mut Frame, area: Rect, app: &App) {
let mut settings_items = vec![
ListItem::new(format!(
"Keybind Mode: {} (Press Enter to toggle)",
match app.config.ui.keybind_mode {
KeybindMode::Normal => "Normal",
KeybindMode::Vim => "Vim",
}
)),
ListItem::new(format!(
"Show Help: {} (Press Enter to toggle)",
if app.config.ui.show_help { "Yes" } else { "No" }
)),
ListItem::new(format!(
"Sonarr URL: {} (Press Enter to edit)",
if app.config.sonarr.url.is_empty() {
"Not set"
} else {
&app.config.sonarr.url
}
)),
ListItem::new(format!(
"API Key: {} (Press Enter to edit)",
if app.config.sonarr.api_key.is_empty() {
"Not set"
} else {
"***hidden***"
}
)),
ListItem::new(if app.config_changed {
"Settings changed - Press 's' to save"
} else {
"Press 's' to save settings"
}),
];
// Add keybind help based on current mode
settings_items.push(ListItem::new(""));
settings_items.push(ListItem::new("Keybind Modes:"));
settings_items.push(ListItem::new(
" Normal: Standard navigation (arrows, j/k, tab)",
));
settings_items.push(ListItem::new(
" Vim: Vim-like navigation (hjkl, gg/G, w/b)",
));
let title = if app.config_changed {
"Settings (Unsaved Changes)"
} else {
"Settings"
};
let list = List::new(settings_items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut app.settings_list_state.clone());
}
fn render_settings_input(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Input field
Constraint::Min(0), // Instructions
])
.split(area);
let (title, current_input, field_name) = if app.editing_url {
("Edit Sonarr URL", &app.url_input, "URL")
} else if app.editing_api_key {
("Edit API Key", &app.api_key_input, "API Key")
} else {
("Settings", &String::new(), "")
};
// Input field
let input = Paragraph::new(current_input.as_str())
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("{} - {}", title, field_name)),
);
f.render_widget(input, chunks[0]);
// Instructions
let instructions = vec![
Line::from("Enter: Save and return"),
Line::from("Esc: Cancel and return"),
Line::from(""),
if app.editing_url {
Line::from("Enter the Sonarr server URL (must start with http:// or https://)")
} else {
Line::from("Enter your Sonarr API key (found in Sonarr Settings > General > Security)")
},
];
let help = Paragraph::new(instructions)
.block(Block::default().borders(Borders::ALL).title("Instructions"))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[1]);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let help_text = if app.input_mode {
"ESC: Cancel | Enter: Search | Type to enter search term"
if app.editing_url || app.editing_api_key {
"ESC: Cancel | Enter: Save | Type to enter value"
} else {
"q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search (in Search tab)"
"ESC: Cancel | Enter: Search | Type to enter search term"
}
} else if !app.config.ui.show_help {
"" // Don't show help if disabled
} else {
match app.config.ui.keybind_mode {
KeybindMode::Normal => {
"q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search"
}
KeybindMode::Vim => {
"q: Quit | w/b: Tabs | hjkl: Navigate | v: Details | u: Refresh | /: Search | gg/G: First/Last"
}
}
};
let mut spans = vec![Span::raw(help_text)];

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,19 +1,15 @@
//! 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;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("Deserialization error: {0}")]
Deserialization(#[from] serde_path_to_error::Error<serde_json::Error>),
#[error("API error: {message}")]
Api { message: String },
}
pub type Result<T> = std::result::Result<T, ApiError>;
#[derive(Clone)]
pub struct SonarrClient {
@@ -31,7 +27,7 @@ impl SonarrClient {
}
}
async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T> {
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
@@ -41,18 +37,23 @@ impl SonarrClient {
.await?;
if !response.status().is_success() {
return Err(ApiError::Api {
message: format!("HTTP {}: {}", response.status(), response.text().await?),
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ApiError::Generic {
message: format!("HTTP {}: {}", status, error_text),
});
}
let text = response.text().await?;
let deser = &mut serde_json::Deserializer::from_str(&text);
serde_path_to_error::deserialize(deser).map_err(ApiError::from)
let result: T = serde_json::from_str(&text)?;
Ok(result)
}
async fn get_debug<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T> {
#[allow(dead_code)]
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
@@ -62,60 +63,72 @@ impl SonarrClient {
.await?;
if !response.status().is_success() {
return Err(ApiError::Api {
message: format!("HTTP {}: {}", response.status(), response.text().await?),
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ApiError::Generic {
message: format!("Debug HTTP {}: {}", status, error_text),
});
}
let text = response.text().await?;
std::fs::write(endpoint.replace("/", "_"), &text);
let deser = &mut serde_json::Deserializer::from_str(&text);
serde_path_to_error::deserialize(deser).map_err(ApiError::from)
let _ = std::fs::write(endpoint.replace("/", "_"), &text);
let result: T = serde_json::from_str(&text)?;
Ok(result)
}
#[allow(dead_code)]
async fn post<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
data: &T,
) -> Result<R> {
body: &T,
) -> Result<R, ApiError> {
let url = format!("{}/api/v3{}", self.base_url, endpoint);
let response = self
.client
.post(&url)
.header("X-Api-Key", &self.api_key)
.json(data)
.json(body)
.send()
.await?;
if !response.status().is_success() {
return Err(ApiError::Api {
message: format!("HTTP {}: {}", response.status(), response.text().await?),
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ApiError::Generic {
message: format!("POST HTTP {}: {}", status, error_text),
});
}
let text = response.text().await?;
let deser = &mut serde_json::Deserializer::from_str(&text);
serde_path_to_error::deserialize(deser).map_err(ApiError::from)
let result: R = serde_json::from_str(&text)?;
Ok(result)
}
pub async fn get_system_status(&self) -> Result<SystemStatus> {
pub async fn get_system_status(&self) -> Result<SystemStatus, ApiError> {
self.get("/system/status").await
}
pub async fn get_series(&self) -> Result<Vec<Series>> {
pub async fn get_series(&self) -> Result<Vec<Series>, ApiError> {
self.get("/series").await
}
pub async fn get_series_by_id(&self, id: u32) -> Result<Series> {
#[allow(dead_code)]
pub async fn get_series_by_id(&self, id: u32) -> Result<Series, ApiError> {
self.get(&format!("/series/{}", id)).await
}
#[allow(dead_code)]
pub async fn get_episodes(
&self,
series_id: Option<u32>,
season_number: Option<u32>,
) -> Result<Vec<Episode>> {
) -> Result<Vec<Episode>, ApiError> {
let mut query = Vec::new();
if let Some(id) = series_id {
query.push(format!("seriesId={}", id));
@@ -137,7 +150,7 @@ impl SonarrClient {
&self,
start: Option<&str>,
end: Option<&str>,
) -> Result<Vec<Episode>> {
) -> Result<Vec<Episode>, ApiError> {
let mut query = Vec::new();
if let Some(start_date) = start {
query.push(format!("start={}", start_date));
@@ -155,31 +168,33 @@ impl SonarrClient {
self.get(&format!("/calendar{}", query_string)).await
}
pub async fn get_queue(&self) -> Result<QueuePagingResource> {
pub async fn get_queue(&self) -> Result<QueuePagingResource, ApiError> {
self.get("/queue").await
}
pub async fn get_history(&self) -> Result<HistoryPagingResource> {
pub async fn get_history(&self) -> Result<HistoryPagingResource, ApiError> {
self.get("/history").await
}
pub async fn get_missing_episodes(&self) -> Result<EpisodePagingResource> {
#[allow(dead_code)]
pub async fn get_missing_episodes(&self) -> Result<EpisodePagingResource, ApiError> {
self.get("/wanted/missing").await
}
pub async fn get_health(&self) -> Result<Vec<HealthResource>> {
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>> {
self.get_debug(&format!(
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>, ApiError> {
self.get(&format!(
"/series/lookup?term={}",
urlencoding::encode(term)
))
.await
}
pub async fn add_series(&self, series: &Series) -> Result<Series> {
#[allow(dead_code)]
pub async fn add_series(&self, series: &Series) -> Result<Series, ApiError> {
self.post("/series", series).await
}
}

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"

View File

@@ -6,15 +6,36 @@
[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"
# Normal mode uses standard navigation keys (arrows, j/k)
# Vim mode uses vim-like keybinds (hjkl, gg, G, etc.)
# Can be toggled from the Settings tab in the application
keybind_mode = "Normal"
# Show help text in the footer
# Can be toggled from the Settings tab in the application
show_help = true
# All settings can be changed in the Settings tab:
# 1. Launch yarr and navigate to the Settings tab (last tab)
# 2. Use arrow keys 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="false"
#
# Command line arguments take highest priority:
# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"