Compare commits

...

12 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
uttarayan21
48e26332a3 feat(config): add configuration management for Yarr TUI app
Some checks failed
build / checks-matrix (push) Successful in 19m23s
build / checks-build (push) Has been cancelled
docs / docs (push) Has been cancelled
build / codecov (push) Has been cancelled
2025-10-08 15:45:08 +05:30
uttarayan21
1be1e19c43 feat(cli): add config management and enhanced command options
- Introduce `config` command for config file management
- Add global options for Sonarr URL and API key
- Implement `--monitored` filter for listing series
- Add default TUI startup behavior
2025-10-08 15:44:36 +05:30
18 changed files with 2815 additions and 304 deletions

825
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +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] [package]
name = "yarr" name = "yarr"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "A TUI client for Sonarr"
authors = ["yarr contributors"]
repository = "https://github.com/user/yarr"
[[bin]] [[bin]]
name = "yarr" name = "yarr"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
clap = { version = "4.5", features = ["derive"] } # API client library
yarr-api = { workspace = true }
# CLI dependencies
clap = { version = "4.5", features = ["derive", "env"] }
clap_complete = "4.5" clap_complete = "4.5"
error-stack = "0.5"
thiserror = "2.0" # Error handling
tokio = { version = "1.43.1", features = ["full"] } thiserror = { workspace = true }
tracing = "0.1"
tracing-subscriber = "0.3" # Async runtime
tokio = { workspace = true }
# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# TUI dependencies # TUI dependencies
ratatui = { version = "0.28", features = ["crossterm"] } ratatui = { version = "0.29", features = ["crossterm"] }
crossterm = "0.28" crossterm = "0.29"
# HTTP client and serialization # Serialization
reqwest = { version = "0.12", features = ["json"] } serde = { workspace = true, features = ["derive"] }
serde = { version = "1.0", features = ["derive"] } serde_json = { workspace = true }
serde_json = "1.0"
# Date/time handling # Date/time handling
chrono = { version = "0.4", features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
# Async utilities # Async utilities
futures = "0.3" futures = "0.3"
urlencoding = "2.1.3"
serde_path_to_error = "0.1.20" # Configuration
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`

289
README.md Normal file
View File

@@ -0,0 +1,289 @@
# Yarr
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
- Browse series and episodes
- Monitor download queue
- View download history
- 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 yarr-cli
```
### Use the API Library
Add to your `Cargo.toml`:
```toml
[dependencies]
yarr-api = { path = "yarr-api" }
```
## Configuration
Yarr supports multiple configuration methods with the following priority order (highest to lowest):
1. Command line arguments
2. Environment variables
3. Configuration file
4. Default values
### Configuration File
Create a configuration file in one of these locations:
- `./yarr.toml` (current directory)
- `~/.config/yarr/config.toml` (user config directory)
Example configuration:
```toml
[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
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
```bash
yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-api-key"
```
## Usage
### TUI Mode (Default)
Launch the interactive TUI:
```bash
yarr
# or explicitly
yarr tui
```
### Command Line Mode
List all series:
```bash
yarr list
```
List only monitored series:
```bash
yarr list --monitored
```
Add a new series:
```bash
yarr add --name "Series Name"
```
### Configuration Management
Create a sample config file:
```bash
yarr config init
```
Create config file at specific location:
```bash
yarr config init --path /path/to/config.toml
```
Show current configuration:
```bash
yarr config show
```
Show configuration file search paths:
```bash
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:
```bash
# Bash
yarr completions bash > /etc/bash_completion.d/yarr
# Zsh
yarr completions zsh > ~/.zfunc/_yarr
# Fish
yarr completions fish > ~/.config/fish/completions/yarr.fish
# PowerShell
yarr completions powershell > yarr.ps1
```
## TUI Controls
### Normal Mode (Default)
- `q` - Quit
- `↑/↓` or `j/k` - Navigate up/down
- `Enter` - Select/expand
- `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
1. Install yarr
2. Create a configuration file:
```bash
yarr config init
```
3. Edit the configuration file to set your Sonarr URL and API key
4. Launch the TUI:
```bash
yarr
```
## Finding Your Sonarr API Key
1. Open your Sonarr web interface
2. Go to Settings > General
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 stableToolchainWithRustAnalyzer
cargo-nextest cargo-nextest
cargo-deny cargo-deny
cargo-udeps
] ]
++ (lib.optionals pkgs.stdenv.isDarwin [ ++ (lib.optionals pkgs.stdenv.isDarwin [
apple-sdk_13 apple-sdk_13

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,94 @@
#[derive(Debug, clap::Parser)] use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "yarr")]
#[command(about = "A TUI for managing Sonarr")]
#[command(version)]
pub struct Cli { pub struct Cli {
#[clap(subcommand)] /// Path to config file
pub cmd: SubCommand, #[arg(short, long, global = true)]
pub config: Option<PathBuf>,
/// Sonarr URL (overrides config file)
#[arg(long, global = true, env = "YARR_SONARR_URL")]
pub sonarr_url: Option<String>,
/// Sonarr API key (overrides config file)
#[arg(long, global = true, env = "YARR_SONARR_API_KEY")]
pub sonarr_api_key: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
} }
#[derive(Debug, clap::Subcommand)] #[derive(Debug, Subcommand)]
pub enum SubCommand { pub enum Commands {
#[clap(name = "add")] /// Add a new series
Add(Add), Add(AddArgs),
#[clap(name = "list")]
List(List), /// List series
#[clap(name = "completions")] List(ListArgs),
Completions { shell: clap_complete::Shell },
/// Start the TUI interface (default)
Tui,
/// Generate shell completions
Completions {
/// The shell to generate completions for
#[arg(value_enum)]
shell: clap_complete::Shell,
},
/// Configuration management
Config(ConfigArgs),
} }
#[derive(Debug, clap::Args)] #[derive(Debug, Args)]
pub struct Add { pub struct AddArgs {
#[clap(short, long)] /// Name of the series to add
#[arg(short, long)]
pub name: String, pub name: String,
} }
#[derive(Debug, clap::Args)] #[derive(Debug, Args)]
pub struct List {} pub struct ListArgs {
/// Show only monitored series
#[arg(short, long)]
pub monitored: bool,
}
#[derive(Debug, Args)]
pub struct ConfigArgs {
#[command(subcommand)]
pub action: ConfigAction,
}
#[derive(Debug, Subcommand)]
pub enum ConfigAction {
/// Show current configuration
Show,
/// Create a sample config file
Init {
/// Path where to create the config file
#[arg(short, long)]
path: Option<PathBuf>,
},
/// 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 { impl Cli {
pub fn completions(shell: clap_complete::Shell) { pub fn generate_completions(shell: clap_complete::Shell) {
let mut command = <Cli as clap::CommandFactory>::command(); let mut command = <Cli as clap::CommandFactory>::command();
clap_complete::generate( clap_complete::generate(
shell, shell,

218
src/config.rs Normal file
View File

@@ -0,0 +1,218 @@
use config::{Config, ConfigError, Environment, File};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub sonarr: SonarrConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SonarrConfig {
pub url: String,
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 {
sonarr: SonarrConfig {
url: "http://localhost:8989".to_string(),
api_key: String::new(),
},
ui: UiConfig::default(),
}
}
}
impl AppConfig {
/// Load configuration from various sources with the following priority:
/// 1. Command line arguments (highest priority)
/// 2. Environment variables
/// 3. Config file
/// 4. Default values (lowest priority)
pub fn load(
config_path: Option<PathBuf>,
sonarr_url: Option<String>,
sonarr_api_key: Option<String>,
) -> Result<Self, ConfigError> {
let mut builder = Config::builder();
// Start with default config
builder = builder.add_source(Config::try_from(&AppConfig::default())?);
// Add config file if it exists
if let Some(path) = config_path {
if path.exists() {
builder = builder.add_source(File::from(path));
}
} else {
// Try to load from default locations
if let Some(config_dir) = dirs::config_dir() {
let yarr_config = config_dir.join("yarr").join("config.toml");
if yarr_config.exists() {
builder = builder.add_source(File::from(yarr_config));
}
}
// Also try current directory
let local_config = std::env::current_dir()
.map(|dir| dir.join("yarr.toml"))
.unwrap_or_else(|_| PathBuf::from("yarr.toml"));
if local_config.exists() {
builder = builder.add_source(File::from(local_config));
}
}
// Add environment variables with YARR_ prefix
builder = builder.add_source(
Environment::with_prefix("YARR")
.try_parsing(true)
.separator("_"),
);
// Override with command line arguments if provided
let mut config = builder.build()?.try_deserialize::<AppConfig>()?;
if let Some(url) = sonarr_url {
config.sonarr.url = url;
}
if let Some(api_key) = sonarr_api_key {
config.sonarr.api_key = api_key;
}
Ok(config)
}
/// Create a sample config file
pub fn create_sample_config(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let sample_config = AppConfig {
sonarr: SonarrConfig {
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)?;
// Create directory if it doesn't exist
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, toml_content)?;
Ok(())
}
/// Validate the configuration
pub fn validate(&self) -> Result<(), String> {
if self.sonarr.url.is_empty() {
return Err("Sonarr URL is required".to_string());
}
if self.sonarr.api_key.is_empty() {
return Err("Sonarr API key is required".to_string());
}
// Validate URL format
if !self.sonarr.url.starts_with("http://") && !self.sonarr.url.starts_with("https://") {
return Err("Sonarr URL must start with http:// or https://".to_string());
}
Ok(())
}
/// Get the default config file paths
pub fn get_default_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Current directory
if let Ok(current_dir) = std::env::current_dir() {
paths.push(current_dir.join("yarr.toml"));
}
// User config directory
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("yarr").join("config.toml"));
}
paths
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert_eq!(config.sonarr.url, "http://localhost:8989");
assert_eq!(config.sonarr.api_key, "");
}
#[test]
fn test_config_validation() {
let mut config = AppConfig::default();
// Should fail validation with empty API key
assert!(config.validate().is_err());
// Should fail with invalid URL
config.sonarr.api_key = "test-key".to_string();
config.sonarr.url = "invalid-url".to_string();
assert!(config.validate().is_err());
// Should pass with valid config
config.sonarr.url = "https://example.com".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_override() {
// Test that CLI args override config
let config = AppConfig::load(
None,
Some("https://cli-url.com".to_string()),
Some("cli-api-key".to_string()),
)
.unwrap();
assert_eq!(config.sonarr.url, "https://cli-url.com");
assert_eq!(config.sonarr.api_key, "cli-api-key");
}
}

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,28 +1,229 @@
mod api; #[deny(warnings)]
mod cli; mod cli;
mod errors; mod config;
mod tui; 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] #[tokio::main]
pub async fn main() -> Result<(), Box<dyn std::error::Error>> { pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
// let args = <cli::Cli as clap::Parser>::parse(); // Parse command line arguments
// match args.cmd { let args = cli::Cli::parse();
// cli::SubCommand::Add(add) => {
// println!("Add: {:?}", add); // Handle config subcommands first
// } if let Some(cli::Commands::Config(config_cmd)) = &args.command {
// cli::SubCommand::List(list) => { handle_config_command(config_cmd, &args)?;
// println!("List: {:?}", list); return Ok(());
// } }
// cli::SubCommand::Completions { shell } => {
// cli::Cli::completions(shell); // Handle completions
// } if let Some(cli::Commands::Completions { shell }) = &args.command {
// } cli::Cli::generate_completions(*shell);
return Ok(());
}
// Load configuration
let config = match AppConfig::load(
args.config.clone(),
args.sonarr_url.clone(),
args.sonarr_api_key.clone(),
) {
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {}", e);
eprintln!("Run 'yarr config init' to create a sample config file");
process::exit(1);
}
};
// Validate configuration
if let Err(e) = config.validate() {
eprintln!("Configuration error: {}", e);
eprintln!("Run 'yarr config show' to see current configuration");
eprintln!("Run 'yarr config init' to create a sample config file");
process::exit(1);
}
// Create Sonarr client
let client = SonarrClient::new(config.sonarr.url.clone(), config.sonarr.api_key.clone());
// Handle different commands
match args.command {
Some(cli::Commands::Add(add_args)) => {
handle_add_command(&client, add_args).await?;
}
Some(cli::Commands::List(list_args)) => {
handle_list_command(&client, list_args).await?;
}
Some(cli::Commands::Tui) | None => {
// Default to TUI mode
tui::run_app(client, config).await?;
}
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
// Already handled above
}
}
Ok(())
}
fn handle_config_command(
config_cmd: &cli::ConfigArgs,
args: &cli::Cli,
) -> Result<(), Box<dyn std::error::Error>> {
match &config_cmd.action {
cli::ConfigAction::Show => {
// Load and display current configuration
let config = AppConfig::load(
args.config.clone(),
args.sonarr_url.clone(),
args.sonarr_api_key.clone(),
)?;
println!("Current configuration:");
println!("Sonarr URL: {}", config.sonarr.url);
println!(
"Sonarr API Key: {}",
if config.sonarr.api_key.is_empty() {
"(not set)"
} else {
"***masked***"
}
);
}
cli::ConfigAction::Init { path } => {
let config_path = if let Some(path) = path {
path.clone()
} else {
// Use default config location
let paths = AppConfig::get_default_config_paths();
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")
}
};
AppConfig::create_sample_config(&config_path)?;
println!("Sample configuration created at: {}", config_path.display());
println!("Please edit the file to set your Sonarr URL and API key.");
}
cli::ConfigAction::Paths => {
println!("Configuration file search order:");
let paths = AppConfig::get_default_config_paths();
for (i, path) in paths.iter().enumerate() {
let exists = if path.exists() { " (exists)" } else { "" };
println!(" {}. {}{}", i + 1, path.display(), exists);
}
println!("\nEnvironment variables:");
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(())
}
async fn handle_add_command(
_client: &SonarrClient,
_add_args: cli::AddArgs,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Add command not yet implemented");
// TODO: Implement series addition
Ok(())
}
async fn handle_list_command(
client: &SonarrClient,
list_args: cli::ListArgs,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Fetching series list...");
match client.get_series().await {
Ok(series_list) => {
let filtered_series: Vec<_> = if list_args.monitored {
series_list.into_iter().filter(|s| s.monitored).collect()
} else {
series_list
};
if filtered_series.is_empty() {
println!("No series found");
} else {
println!("\nSeries ({}):", filtered_series.len());
for series in filtered_series {
let status = if series.monitored { "" } else { "" };
let title = series.title.as_deref().unwrap_or("Unknown");
println!(" {} {} ({})", status, title, series.status);
}
}
}
Err(e) => {
eprintln!("Failed to fetch series: {}", e);
process::exit(1);
}
}
let client = SonarrClient::new(
"https://sonarr.tsuba.darksailor.dev".into(),
"1a47401731bf44ae9787dfcd4bab402f".into(),
);
tui::run_app(client).await?;
Ok(()) Ok(())
} }

View File

@@ -20,7 +20,8 @@ use std::{
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::api::{ use crate::config::{AppConfig, KeybindMode};
use yarr_api::{
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus, Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
}; };
@@ -30,6 +31,7 @@ pub enum AppEvent {
Key(crossterm::event::KeyEvent), Key(crossterm::event::KeyEvent),
Error(String), Error(String),
SeriesLoaded(Vec<Series>), SeriesLoaded(Vec<Series>),
#[allow(dead_code)]
EpisodesLoaded(Vec<Episode>), EpisodesLoaded(Vec<Episode>),
QueueLoaded(Vec<QueueItem>), QueueLoaded(Vec<QueueItem>),
HistoryLoaded(Vec<HistoryItem>), HistoryLoaded(Vec<HistoryItem>),
@@ -42,38 +44,43 @@ pub enum AppEvent {
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum TabIndex { pub enum TabIndex {
Series, Series,
Search,
Calendar, Calendar,
Queue, Queue,
History, History,
Health, Health,
Search, Settings,
} }
impl TabIndex { impl TabIndex {
fn titles() -> Vec<&'static str> { 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 { fn from_index(index: usize) -> Self {
match index { match index {
0 => TabIndex::Series, 0 => TabIndex::Series,
1 => TabIndex::Calendar, 1 => TabIndex::Search,
2 => TabIndex::Queue, 2 => TabIndex::Calendar,
3 => TabIndex::History, 3 => TabIndex::Queue,
4 => TabIndex::Health, 4 => TabIndex::History,
5 => TabIndex::Search, 5 => TabIndex::Health,
6 => TabIndex::Settings,
_ => TabIndex::Series, _ => TabIndex::Series,
} }
} }
fn to_index(self) -> usize { fn to_index(&self) -> usize {
match self { match self {
TabIndex::Series => 0, TabIndex::Series => 0,
TabIndex::Calendar => 1, TabIndex::Search => 1,
TabIndex::Queue => 2, TabIndex::Calendar => 2,
TabIndex::History => 3, TabIndex::Queue => 3,
TabIndex::Health => 4, TabIndex::History => 4,
TabIndex::Search => 5, TabIndex::Health => 5,
TabIndex::Settings => 6,
} }
} }
} }
@@ -87,6 +94,7 @@ pub struct App {
pub history_table_state: TableState, pub history_table_state: TableState,
pub health_list_state: ListState, pub health_list_state: ListState,
pub search_list_state: ListState, pub search_list_state: ListState,
pub settings_list_state: ListState,
pub series: Vec<Series>, pub series: Vec<Series>,
pub episodes: Vec<Episode>, pub episodes: Vec<Episode>,
pub queue: Vec<QueueItem>, pub queue: Vec<QueueItem>,
@@ -94,6 +102,7 @@ pub struct App {
pub health: Vec<HealthResource>, pub health: Vec<HealthResource>,
pub calendar: Vec<Episode>, pub calendar: Vec<Episode>,
pub system_status: Option<SystemStatus>, pub system_status: Option<SystemStatus>,
#[allow(dead_code)]
pub selected_series: Option<Series>, pub selected_series: Option<Series>,
pub loading: bool, pub loading: bool,
pub error_message: Option<String>, pub error_message: Option<String>,
@@ -102,6 +111,12 @@ pub struct App {
pub search_mode: bool, pub search_mode: bool,
pub show_details: bool, pub show_details: bool,
pub input_mode: 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 { impl Default for App {
@@ -115,6 +130,7 @@ impl Default for App {
history_table_state: TableState::default(), history_table_state: TableState::default(),
health_list_state: ListState::default(), health_list_state: ListState::default(),
search_list_state: ListState::default(), search_list_state: ListState::default(),
settings_list_state: ListState::default(),
series: Vec::new(), series: Vec::new(),
episodes: Vec::new(), episodes: Vec::new(),
queue: Vec::new(), queue: Vec::new(),
@@ -130,8 +146,15 @@ impl Default for App {
search_mode: false, search_mode: false,
show_details: false, show_details: false,
input_mode: 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.series_list_state.select(Some(0));
app.settings_list_state.select(Some(0));
app app
} }
} }
@@ -215,6 +238,9 @@ impl App {
self.search_list_state.select(Some(i)); 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)); self.search_list_state.select(Some(i));
} }
} }
TabIndex::Settings => {
self.previous_settings_item();
}
} }
} }
#[allow(dead_code)]
pub fn get_selected_series(&self) -> Option<&Series> { pub fn get_selected_series(&self) -> Option<&Series> {
if let Some(index) = self.series_list_state.selected() { if let Some(index) = self.series_list_state.selected() {
self.series.get(index) self.series.get(index)
@@ -327,6 +357,7 @@ impl App {
} }
} }
#[allow(dead_code)]
pub fn get_selected_search_result(&self) -> Option<&Series> { pub fn get_selected_search_result(&self) -> Option<&Series> {
if let Some(index) = self.search_list_state.selected() { if let Some(index) = self.search_list_state.selected() {
self.search_results.get(index) self.search_results.get(index)
@@ -361,9 +392,217 @@ impl App {
pub fn set_error(&mut self, error: String) { pub fn set_error(&mut self, error: String) {
self.error_message = Some(error); 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 // Setup terminal
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); 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 // Create app and run it
let mut app = App::default(); let mut app = App::default();
app.config = config;
let res = run_tui(&mut terminal, &mut app, client).await; let res = run_tui(&mut terminal, &mut app, client).await;
// Restore terminal // Restore terminal
@@ -588,9 +828,28 @@ async fn handle_input(
tx: mpsc::UnboundedSender<AppEvent>, tx: mpsc::UnboundedSender<AppEvent>,
) { ) {
if app.input_mode { 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 { match key.code {
KeyCode::Enter => { 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; app.loading = true;
let search_term = app.search_input.clone(); let search_term = app.search_input.clone();
let client_clone = client.clone(); let client_clone = client.clone();
@@ -601,43 +860,89 @@ async fn handle_input(
let _ = tx_clone.send(AppEvent::SearchResults(results)); let _ = tx_clone.send(AppEvent::SearchResults(results));
} }
Err(e) => { Err(e) => {
let _ = let _ = tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
} }
} }
}); });
}
app.input_mode = false; app.input_mode = false;
} }
}
KeyCode::Esc => { KeyCode::Esc => {
if app.editing_url || app.editing_api_key {
app.cancel_editing();
} else {
app.exit_search_mode(); app.exit_search_mode();
} }
}
KeyCode::Char(c) => { 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); app.search_input.push(c);
} }
}
KeyCode::Backspace => { 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(); 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 { match key.code {
KeyCode::Char('q') => app.should_quit = true, KeyCode::Char('q') => app.should_quit = true,
KeyCode::Tab => app.next_tab(), KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(), KeyCode::BackTab => app.previous_tab(),
KeyCode::Down | KeyCode::Char('j') => app.next_item(), KeyCode::Down | KeyCode::Char('j') => {
KeyCode::Up | KeyCode::Char('k') => app.previous_item(), 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('d') => app.toggle_details(),
KeyCode::Char('r') => { KeyCode::Char('r') => {
app.loading = true; app.loading = true;
app.clear_error(); app.clear_error();
load_all_data(client, tx); tokio::spawn(async move {
load_all_data(client, tx).await;
});
} }
KeyCode::Char('/') => { KeyCode::Char('/') => {
if app.current_tab == TabIndex::Search { if app.current_tab == TabIndex::Search {
app.enter_search_mode(); 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 => { KeyCode::Esc => {
app.clear_error(); app.clear_error();
if app.search_mode { 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) { 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::History => render_history_tab(f, chunks[1], app),
TabIndex::Health => render_health_tab(f, chunks[1], app), TabIndex::Health => render_health_tab(f, chunks[1], app),
TabIndex::Search => render_search_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 // Render footer
@@ -862,7 +1247,7 @@ fn render_queue_tab(f: &mut Frame, area: Rect, app: &App) {
.borders(Borders::ALL) .borders(Borders::ALL)
.title("Download Queue"), .title("Download Queue"),
) )
.highlight_style( .row_highlight_style(
Style::default() Style::default()
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .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))) .header(header.style(Style::default().fg(Color::Yellow)))
.block(Block::default().borders(Borders::ALL).title("History")) .block(Block::default().borders(Borders::ALL).title("History"))
.highlight_style( .row_highlight_style(
Style::default() Style::default()
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .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) { fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let help_text = if app.input_mode { 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 { } 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)]; 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 reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; 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)] #[derive(Clone)]
pub struct SonarrClient { 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 url = format!("{}/api/v3{}", self.base_url, endpoint);
let response = self let response = self
.client .client
@@ -41,18 +37,23 @@ impl SonarrClient {
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(ApiError::Api { let status = response.status();
message: format!("HTTP {}: {}", response.status(), response.text().await?), 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 text = response.text().await?;
let deser = &mut serde_json::Deserializer::from_str(&text); let result: T = serde_json::from_str(&text)?;
Ok(result)
serde_path_to_error::deserialize(deser).map_err(ApiError::from)
} }
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 url = format!("{}/api/v3{}", self.base_url, endpoint);
let response = self let response = self
.client .client
@@ -62,60 +63,72 @@ impl SonarrClient {
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(ApiError::Api { let status = response.status();
message: format!("HTTP {}: {}", response.status(), response.text().await?), 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?; let text = response.text().await?;
std::fs::write(endpoint.replace("/", "_"), &text); let _ = std::fs::write(endpoint.replace("/", "_"), &text);
let deser = &mut serde_json::Deserializer::from_str(&text); let result: T = serde_json::from_str(&text)?;
Ok(result)
serde_path_to_error::deserialize(deser).map_err(ApiError::from)
} }
#[allow(dead_code)]
async fn post<T: Serialize, R: for<'de> Deserialize<'de>>( async fn post<T: Serialize, R: for<'de> Deserialize<'de>>(
&self, &self,
endpoint: &str, endpoint: &str,
data: &T, body: &T,
) -> Result<R> { ) -> Result<R, ApiError> {
let url = format!("{}/api/v3{}", self.base_url, endpoint); let url = format!("{}/api/v3{}", self.base_url, endpoint);
let response = self let response = self
.client .client
.post(&url) .post(&url)
.header("X-Api-Key", &self.api_key) .header("X-Api-Key", &self.api_key)
.json(data) .json(body)
.send() .send()
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(ApiError::Api { let status = response.status();
message: format!("HTTP {}: {}", response.status(), response.text().await?), 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 text = response.text().await?;
let deser = &mut serde_json::Deserializer::from_str(&text); let result: R = serde_json::from_str(&text)?;
serde_path_to_error::deserialize(deser).map_err(ApiError::from) 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 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 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 self.get(&format!("/series/{}", id)).await
} }
#[allow(dead_code)]
pub async fn get_episodes( pub async fn get_episodes(
&self, &self,
series_id: Option<u32>, series_id: Option<u32>,
season_number: Option<u32>, season_number: Option<u32>,
) -> Result<Vec<Episode>> { ) -> Result<Vec<Episode>, ApiError> {
let mut query = Vec::new(); let mut query = Vec::new();
if let Some(id) = series_id { if let Some(id) = series_id {
query.push(format!("seriesId={}", id)); query.push(format!("seriesId={}", id));
@@ -137,7 +150,7 @@ impl SonarrClient {
&self, &self,
start: Option<&str>, start: Option<&str>,
end: Option<&str>, end: Option<&str>,
) -> Result<Vec<Episode>> { ) -> Result<Vec<Episode>, ApiError> {
let mut query = Vec::new(); let mut query = Vec::new();
if let Some(start_date) = start { if let Some(start_date) = start {
query.push(format!("start={}", start_date)); query.push(format!("start={}", start_date));
@@ -155,31 +168,33 @@ impl SonarrClient {
self.get(&format!("/calendar{}", query_string)).await 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 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 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 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 self.get("/health").await
} }
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>> { pub async fn search_series(&self, term: &str) -> Result<Vec<Series>, ApiError> {
self.get_debug(&format!( self.get(&format!(
"/series/lookup?term={}", "/series/lookup?term={}",
urlencoding::encode(term) urlencoding::encode(term)
)) ))
.await .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 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"

41
yarr.toml.example Normal file
View File

@@ -0,0 +1,41 @@
# Yarr Configuration File
# 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"
# 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"