Compare commits

...

11 Commits

Author SHA1 Message Date
uttarayan21
f04de80e14 feat(yarr): Added download option 2025-10-13 20:35:49 +05:30
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
21 changed files with 11572 additions and 457 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`

176
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,27 @@ A Terminal User Interface (TUI) for managing Sonarr.
- Browse series and episodes
- Monitor download queue
- View download history
- Interactive TUI interface
- **Search for new series and download releases** - Interactive search with release selection and download
- 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 +62,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 +77,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
@@ -68,6 +99,25 @@ yarr
yarr tui
```
#### Search and Download Functionality
The Search tab provides powerful functionality for finding and downloading new series:
1. **Search for Series**: Press `/` to enter search mode, type a series name, and press Enter
2. **View Releases**: Navigate to a search result and press Enter to view available releases
3. **Download**: In the releases popup, select a release and press Enter to download it
**Release Information Display**:
- Status indicators: ✅ Available, ❌ Rejected, ⛔ Not Allowed
- File size, quality rating, indexer name
- Seeders/peers count for torrent releases
- Full release title for identification
**Download Selection**:
- Only releases marked as "Available" can be downloaded
- Choose based on quality, file size, and seed count
- Downloads are sent to your configured download client
### Command Line Mode
List all series:
@@ -114,6 +164,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 +196,69 @@ 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
- `Enter` - Select/expand item or show releases (in Search tab)
- `Tab` - Switch between tabs
- `d` - Toggle details
- `r` - Refresh data
- `/` - Search (in Search tab)
- `Esc` - Close popups or cancel search
- `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
- `Enter` - Select/expand item or show releases (in Search tab)
- `Esc` - Close popups or cancel operations
- `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.
### Search Tab Usage
The Search tab provides comprehensive series search and download functionality:
1. **Enter Search Mode**: Press `/` to start typing a search query
2. **Search**: Type the series name and press Enter to search
3. **Browse Results**: Use ↑/↓ or j/k to navigate through search results
4. **View Releases**: Press Enter on a search result to open the releases popup
5. **Download**: In the releases popup, select a release and press Enter to download
6. **Close Popup**: Press Esc to close the releases popup
**Release Popup Features**:
- Shows all available releases for the selected series
- Displays quality, size, indexer, and availability status
- Color-coded status indicators for easy identification
- Download progress feedback through status messages
### 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 +280,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, including the new download after search functionality.
## 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
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

8526
sonarr.yaml Normal file

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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 }

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

@@ -0,0 +1,180 @@
# 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
- **Download after search** - Search for releases and automatically download the best quality
- **Release management** - Full support for searching, filtering, and downloading releases
- **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());
// Search and download the best release for a series
let downloaded = client.search_and_download_best(Some(1), None, None).await?;
if let Some(release) = downloaded {
println!("Downloaded: {}", release.title.unwrap_or_default());
}
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
### Releases
- ✅ Search for releases by series/episode/season
- ✅ Download specific releases
- ✅ Automatic best quality selection
- ✅ Advanced filtering and sorting
## Examples
See the `examples/` directory for more comprehensive usage examples:
```bash
# Run the basic usage example
cargo run --example basic_usage
# Run the download after search example
cargo run --example download_after_search
# 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"
```
### Download After Search
The library provides powerful functionality for searching and automatically downloading releases:
```rust
use yarr_api::SonarrClient;
let client = SonarrClient::new(url, api_key);
// Search and download the first available release
let success = client.search_and_download(Some(series_id), None, None).await?;
// Search and download the best quality release
let best_release = client.search_and_download_best(Some(series_id), None, Some(season)).await?;
// Manual search with custom filtering
let releases = client.search_releases(Some(series_id), Some(episode_id), None).await?;
for release in releases {
if release.download_allowed.unwrap_or(false) && !release.rejected.unwrap_or(true) {
client.download_release(&release).await?;
break;
}
}
```
## 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
- `ReleaseResource` - Release/torrent information with download metadata
- `QualityModel` - Quality information and settings
- `CustomFormatResource` - Custom format definitions
## 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(())
}

View File

@@ -0,0 +1,189 @@
//! Example demonstrating download after search functionality
//!
//! This example shows how to:
//! 1. Search for available releases for a specific series/episode
//! 2. Download the best quality release automatically
//! 3. Handle different search scenarios
use std::env;
use yarr_api::{ApiError, SonarrClient};
#[tokio::main]
async fn main() -> Result<(), ApiError> {
// Initialize the client with environment variables
let base_url = env::var("SONARR_URL").unwrap_or_else(|_| "http://localhost:8989".to_string());
let api_key =
env::var("SONARR_API_KEY").expect("SONARR_API_KEY environment variable is required");
let client = SonarrClient::new(base_url, api_key);
println!("🔍 Sonarr Download After Search Example");
println!("========================================");
// Example 1: Search and download best release for a specific series
println!("\n📺 Example 1: Search and download for series ID 1");
match search_and_download_for_series(&client, 1).await {
Ok(success) => {
if success {
println!("✅ Successfully found and downloaded a release!");
} else {
println!("❌ No suitable releases found for download");
}
}
Err(e) => println!("❌ Error: {}", e),
}
// Example 2: Search releases for a specific episode
println!("\n🎬 Example 2: Search releases for episode ID 123");
match search_releases_for_episode(&client, 123).await {
Ok(releases) => {
println!("📋 Found {} releases:", releases.len());
for (i, release) in releases.iter().enumerate().take(5) {
println!(
" {}. {} ({})",
i + 1,
release.title.as_deref().unwrap_or("Unknown"),
format_size(release.size.unwrap_or(0))
);
}
}
Err(e) => println!("❌ Error: {}", e),
}
// Example 3: Search and download best quality for a season
println!("\n📀 Example 3: Search and download best for season 1 of series 1");
match search_and_download_best_for_season(&client, 1, 1).await {
Ok(release_opt) => match release_opt {
Some(release) => {
println!(
"✅ Downloaded: {}",
release.title.as_deref().unwrap_or("Unknown")
);
println!(" Quality: {}", release.quality_weight.unwrap_or(0));
println!(" Size: {}", format_size(release.size.unwrap_or(0)));
}
None => println!("❌ No suitable releases found"),
},
Err(e) => println!("❌ Error: {}", e),
}
// Example 4: Manual search and selective download
println!("\n🎯 Example 4: Manual search and filter");
match manual_search_and_filter(&client, 1).await {
Ok(()) => println!("✅ Manual search completed"),
Err(e) => println!("❌ Error: {}", e),
}
Ok(())
}
/// Search and download the first available release for a series
async fn search_and_download_for_series(
client: &SonarrClient,
series_id: u32,
) -> Result<bool, ApiError> {
client
.search_and_download(Some(series_id), None, None)
.await
}
/// Search for releases for a specific episode (without downloading)
async fn search_releases_for_episode(
client: &SonarrClient,
episode_id: u32,
) -> Result<Vec<yarr_api::ReleaseResource>, ApiError> {
client.search_releases(None, Some(episode_id), None).await
}
/// Search and download the best quality release for a season
async fn search_and_download_best_for_season(
client: &SonarrClient,
series_id: u32,
season_number: i32,
) -> Result<Option<yarr_api::ReleaseResource>, ApiError> {
client
.search_and_download_best(Some(series_id), None, Some(season_number))
.await
}
/// Manual search with custom filtering logic
async fn manual_search_and_filter(client: &SonarrClient, series_id: u32) -> Result<(), ApiError> {
let releases = client.search_releases(Some(series_id), None, None).await?;
println!("🔍 Found {} total releases", releases.len());
// Custom filtering: prefer releases with specific criteria
let filtered_releases: Vec<_> = releases
.into_iter()
.filter(|r| {
// Only downloadable releases
if !r.download_allowed.unwrap_or(false) || r.rejected.unwrap_or(true) {
return false;
}
// Prefer releases with higher seeds (for torrents)
if let Some(seeders) = r.seeders {
if seeders < 5 {
return false;
}
}
// Avoid very large files (over 10GB)
if let Some(size) = r.size {
if size > 10 * 1024 * 1024 * 1024 {
return false;
}
}
true
})
.collect();
println!("📋 {} releases match our criteria", filtered_releases.len());
if let Some(best_release) = filtered_releases.first() {
println!(
"🎯 Downloading: {}",
best_release.title.as_deref().unwrap_or("Unknown")
);
client.download_release(best_release).await?;
println!("✅ Download initiated successfully!");
} else {
println!("❌ No releases match our filtering criteria");
}
Ok(())
}
/// Format file size in human-readable format
fn format_size(bytes: i64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.1} {}", size, UNITS[unit_index])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(1048576), "1.0 MB");
assert_eq!(format_size(1073741824), "1.0 GB");
}
}

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,33 +168,293 @@ 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
}
/// Search for available releases/torrents for download
///
/// This method searches for releases that match the specified criteria.
/// You can search by series ID, episode ID, season number, or any combination.
///
/// # Arguments
///
/// * `series_id` - Optional series ID to search for releases
/// * `episode_id` - Optional specific episode ID to search for releases
/// * `season_number` - Optional season number to search for releases
///
/// # Returns
///
/// A vector of `ReleaseResource` objects containing release information including:
/// - Title, size, quality, and indexer information
/// - Download URLs and availability status
/// - Quality scores and custom format information
/// - Rejection reasons (if any)
///
/// # Examples
///
/// ```rust,no_run
/// # use yarr_api::{SonarrClient, ApiError};
/// # #[tokio::main]
/// # async fn main() -> Result<(), ApiError> {
/// let client = SonarrClient::new("http://localhost:8989".to_string(), "api-key".to_string());
///
/// // Search for all releases for series ID 1
/// let releases = client.search_releases(Some(1), None, None).await?;
///
/// // Search for releases for a specific episode
/// let episode_releases = client.search_releases(None, Some(123), None).await?;
///
/// // Search for releases for season 2 of series 1
/// let season_releases = client.search_releases(Some(1), None, Some(2)).await?;
/// # Ok(())
/// # }
/// ```
pub async fn search_releases(
&self,
series_id: Option<u32>,
episode_id: Option<u32>,
season_number: Option<i32>,
) -> Result<Vec<ReleaseResource>, ApiError> {
let mut query_params = Vec::new();
if let Some(id) = series_id {
query_params.push(format!("seriesId={}", id));
}
if let Some(id) = episode_id {
query_params.push(format!("episodeId={}", id));
}
if let Some(season) = season_number {
query_params.push(format!("seasonNumber={}", season));
}
let endpoint = if query_params.is_empty() {
"/release".to_string()
} else {
format!("/release?{}", query_params.join("&"))
};
self.get(&endpoint).await
}
/// Download/grab a specific release
///
/// This method instructs Sonarr to download the specified release.
/// The release will be added to the download queue and handled by
/// the configured download client.
///
/// # Arguments
///
/// * `release` - The release to download, typically obtained from `search_releases()`
///
/// # Returns
///
/// Returns `Ok(())` if the download was successfully queued, or an `ApiError` if:
/// - The release is not downloadable (`download_allowed` is false)
/// - The release has been rejected
/// - There's a network or API error
///
/// # Examples
///
/// ```rust,no_run
/// # use yarr_api::{SonarrClient, ApiError};
/// # #[tokio::main]
/// # async fn main() -> Result<(), ApiError> {
/// let client = SonarrClient::new("http://localhost:8989".to_string(), "api-key".to_string());
///
/// let releases = client.search_releases(Some(1), None, None).await?;
/// if let Some(release) = releases.first() {
/// if release.download_allowed.unwrap_or(false) {
/// client.download_release(release).await?;
/// println!("Download started!");
/// }
/// }
/// # Ok(())
/// # }
/// ```
pub async fn download_release(&self, release: &ReleaseResource) -> Result<(), ApiError> {
let url = format!("{}/api/v3/release", self.base_url);
let response = self
.client
.post(&url)
.header("X-Api-Key", &self.api_key)
.json(release)
.send()
.await?;
if !response.status().is_success() {
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),
});
}
Ok(())
}
/// Search for releases and download the first available one
///
/// This is a convenience method that combines searching and downloading.
/// It will search for releases matching the criteria and download the first
/// release that is allowed and not rejected.
///
/// # Arguments
///
/// * `series_id` - Optional series ID to search for releases
/// * `episode_id` - Optional specific episode ID to search for releases
/// * `season_number` - Optional season number to search for releases
///
/// # Returns
///
/// Returns `true` if a suitable release was found and download was initiated,
/// `false` if no suitable releases were available.
///
/// # Examples
///
/// ```rust,no_run
/// # use yarr_api::{SonarrClient, ApiError};
/// # #[tokio::main]
/// # async fn main() -> Result<(), ApiError> {
/// let client = SonarrClient::new("http://localhost:8989".to_string(), "api-key".to_string());
///
/// let success = client.search_and_download(Some(1), None, None).await?;
/// if success {
/// println!("Download started!");
/// } else {
/// println!("No suitable releases found");
/// }
/// # Ok(())
/// # }
/// ```
pub async fn search_and_download(
&self,
series_id: Option<u32>,
episode_id: Option<u32>,
season_number: Option<i32>,
) -> Result<bool, ApiError> {
let releases = self
.search_releases(series_id, episode_id, season_number)
.await?;
// Find the first downloadable release
for release in releases {
if release.download_allowed.unwrap_or(false) && !release.rejected.unwrap_or(true) {
self.download_release(&release).await?;
return Ok(true);
}
}
Ok(false) // No suitable release found
}
/// Search for releases and download the best quality one
///
/// This method searches for releases and automatically selects and downloads
/// the best quality release based on quality weight and custom format scores.
/// Only releases that are downloadable and not rejected are considered.
///
/// The selection algorithm prioritizes:
/// 1. Higher quality weight (better video/audio quality)
/// 2. Higher custom format scores (preferred formats/sources)
///
/// # Arguments
///
/// * `series_id` - Optional series ID to search for releases
/// * `episode_id` - Optional specific episode ID to search for releases
/// * `season_number` - Optional season number to search for releases
///
/// # Returns
///
/// Returns `Some(ReleaseResource)` with the downloaded release information,
/// or `None` if no suitable releases were found.
///
/// # Examples
///
/// ```rust,no_run
/// # use yarr_api::{SonarrClient, ApiError};
/// # #[tokio::main]
/// # async fn main() -> Result<(), ApiError> {
/// let client = SonarrClient::new("http://localhost:8989".to_string(), "api-key".to_string());
///
/// match client.search_and_download_best(Some(1), None, Some(2)).await? {
/// Some(release) => {
/// println!("Downloaded: {}", release.title.unwrap_or_default());
/// println!("Quality: {}", release.quality_weight.unwrap_or(0));
/// }
/// None => println!("No suitable releases found"),
/// }
/// # Ok(())
/// # }
/// ```
pub async fn search_and_download_best(
&self,
series_id: Option<u32>,
episode_id: Option<u32>,
season_number: Option<i32>,
) -> Result<Option<ReleaseResource>, ApiError> {
let releases = self
.search_releases(series_id, episode_id, season_number)
.await?;
// Filter downloadable releases and sort by quality weight and custom format score
let mut downloadable_releases: Vec<_> = releases
.into_iter()
.filter(|r| r.download_allowed.unwrap_or(false) && !r.rejected.unwrap_or(true))
.collect();
if downloadable_releases.is_empty() {
return Ok(None);
}
// Sort by quality weight (higher is better) and custom format score (higher is better)
downloadable_releases.sort_by(|a, b| {
let quality_cmp = b
.quality_weight
.unwrap_or(0)
.cmp(&a.quality_weight.unwrap_or(0));
if quality_cmp == std::cmp::Ordering::Equal {
b.custom_format_score
.unwrap_or(0)
.cmp(&a.custom_format_score.unwrap_or(0))
} else {
quality_cmp
}
});
let best_release = downloadable_releases.into_iter().next().unwrap();
self.download_release(&best_release).await?;
Ok(Some(best_release))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -553,3 +826,89 @@ pub struct HealthResource {
pub message: Option<String>,
pub wiki_url: Option<String>,
}
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseResource {
pub id: Option<i32>,
pub guid: Option<String>,
pub quality: Option<QualityModel>,
pub quality_weight: Option<i32>,
pub age: Option<i32>,
pub age_hours: Option<f64>,
pub age_minutes: Option<f64>,
pub size: Option<i64>,
pub indexer_id: Option<i32>,
pub indexer: Option<String>,
pub release_group: Option<String>,
pub sub_group: Option<String>,
pub release_hash: Option<String>,
pub title: Option<String>,
pub full_season: Option<bool>,
pub scene_source: Option<bool>,
pub season_number: Option<i32>,
pub languages: Option<Vec<Language>>,
pub language_weight: Option<i32>,
pub air_date: Option<String>,
pub series_title: Option<String>,
pub episode_numbers: Option<Vec<i32>>,
pub absolute_episode_numbers: Option<Vec<i32>>,
pub mapped_season_number: Option<i32>,
pub mapped_episode_numbers: Option<Vec<i32>>,
pub mapped_absolute_episode_numbers: Option<Vec<i32>>,
pub mapped_series_id: Option<i32>,
pub approved: Option<bool>,
pub temporarily_rejected: Option<bool>,
pub rejected: Option<bool>,
pub tvdb_id: Option<i32>,
pub tv_rage_id: Option<i32>,
pub imdb_id: Option<String>,
pub rejections: Option<Vec<String>>,
pub publish_date: Option<chrono::DateTime<chrono::Utc>>,
pub comment_url: Option<String>,
pub download_url: Option<String>,
pub info_url: Option<String>,
pub episode_requested: Option<bool>,
pub download_allowed: Option<bool>,
pub release_weight: Option<i32>,
pub custom_formats: Option<Vec<CustomFormatResource>>,
pub custom_format_score: Option<i32>,
pub magnet_url: Option<String>,
pub info_hash: Option<String>,
pub seeders: Option<i32>,
pub leechers: Option<i32>,
pub protocol: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QualityModel {
pub quality: Option<QualityDefinition>,
pub revision: Option<Revision>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomFormatResource {
pub id: Option<i32>,
pub name: Option<String>,
pub include_custom_format_when_renaming: Option<bool>,
pub specifications: Option<Vec<HashMap<String, serde_json::Value>>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseEpisodeResource {
pub id: Option<i32>,
pub episode_file_id: Option<i32>,
pub season_number: Option<i32>,
pub episode_number: Option<i32>,
pub absolute_episode_number: Option<i32>,
pub title: Option<String>,
pub scene_season_number: Option<i32>,
pub scene_episode_number: Option<i32>,
pub scene_absolute_episode_number: Option<i32>,
}

336
yarr-api/src/tests.rs Normal file
View File

@@ -0,0 +1,336 @@
//! Unit tests for the yarr-api crate
//!
//! These tests verify the functionality of the Sonarr API client,
//! particularly the download after search features.
#[cfg(test)]
mod tests {
use crate::{
CustomFormatResource, Language, QualityDefinition, QualityModel, ReleaseResource, Revision,
SonarrClient,
};
fn create_test_client() -> SonarrClient {
SonarrClient::new(
"http://localhost:8989".to_string(),
"test-api-key".to_string(),
)
}
fn create_test_release(
id: i32,
title: &str,
quality_weight: i32,
custom_format_score: i32,
download_allowed: bool,
rejected: bool,
size: i64,
) -> ReleaseResource {
ReleaseResource {
id: Some(id),
guid: Some(format!("test-guid-{}", id)),
quality: Some(QualityModel {
quality: Some(QualityDefinition {
id: 1,
name: Some("HDTV-1080p".to_string()),
source: "HDTV".to_string(),
resolution: 1080,
}),
revision: Some(Revision {
version: 1,
real: 0,
is_repack: false,
}),
}),
quality_weight: Some(quality_weight),
age: Some(1),
age_hours: Some(24.0),
age_minutes: Some(1440.0),
size: Some(size),
indexer_id: Some(1),
indexer: Some("Test Indexer".to_string()),
release_group: Some("TestGroup".to_string()),
sub_group: None,
release_hash: Some("test-hash".to_string()),
title: Some(title.to_string()),
full_season: Some(false),
scene_source: Some(false),
season_number: Some(1),
languages: Some(vec![Language {
id: 1,
name: Some("English".to_string()),
}]),
language_weight: Some(100),
air_date: Some("2024-01-01".to_string()),
series_title: Some("Test Series".to_string()),
episode_numbers: Some(vec![1]),
absolute_episode_numbers: None,
mapped_season_number: Some(1),
mapped_episode_numbers: Some(vec![1]),
mapped_absolute_episode_numbers: None,
mapped_series_id: Some(1),
approved: Some(true),
temporarily_rejected: Some(false),
rejected: Some(rejected),
tvdb_id: Some(12345),
tv_rage_id: Some(67890),
imdb_id: Some("tt1234567".to_string()),
rejections: if rejected {
Some(vec!["Test rejection reason".to_string()])
} else {
None
},
publish_date: Some(chrono::Utc::now()),
comment_url: Some("http://example.com/comments".to_string()),
download_url: Some("http://example.com/download".to_string()),
info_url: Some("http://example.com/info".to_string()),
episode_requested: Some(true),
download_allowed: Some(download_allowed),
release_weight: Some(quality_weight + custom_format_score),
custom_formats: Some(vec![CustomFormatResource {
id: Some(1),
name: Some("Test Format".to_string()),
include_custom_format_when_renaming: Some(true),
specifications: None,
}]),
custom_format_score: Some(custom_format_score),
magnet_url: Some("magnet:?xt=urn:btih:test".to_string()),
info_hash: Some("test-info-hash".to_string()),
seeders: Some(10),
leechers: Some(2),
protocol: Some("torrent".to_string()),
}
}
#[test]
fn test_client_creation() {
let client = create_test_client();
assert_eq!(client.base_url, "http://localhost:8989");
assert_eq!(client.api_key, "test-api-key");
}
#[test]
fn test_release_resource_serialization() {
let release = create_test_release(
1,
"Test Release",
1000,
100,
true,
false,
2_000_000_000, // 2GB
);
// Test serialization
let json = serde_json::to_string(&release).unwrap();
assert!(json.contains("Test Release"));
assert!(json.contains("downloadAllowed"));
// Test deserialization
let deserialized: ReleaseResource = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.title, Some("Test Release".to_string()));
assert_eq!(deserialized.download_allowed, Some(true));
assert_eq!(deserialized.quality_weight, Some(1000));
}
#[test]
fn test_quality_model_creation() {
let quality = QualityModel {
quality: Some(QualityDefinition {
id: 1,
name: Some("HDTV-1080p".to_string()),
source: "HDTV".to_string(),
resolution: 1080,
}),
revision: Some(Revision {
version: 1,
real: 0,
is_repack: false,
}),
};
assert!(quality.quality.is_some());
assert!(quality.revision.is_some());
assert_eq!(quality.quality.as_ref().unwrap().resolution, 1080);
}
#[test]
fn test_release_filtering_logic() {
let releases = vec![
create_test_release(1, "Low Quality", 500, 0, true, false, 1_000_000_000),
create_test_release(2, "High Quality", 1500, 200, true, false, 3_000_000_000),
create_test_release(3, "Rejected", 2000, 300, true, true, 2_000_000_000),
create_test_release(4, "Not Allowed", 1800, 150, false, false, 2_500_000_000),
create_test_release(5, "Best Quality", 2000, 500, true, false, 4_000_000_000),
];
// Filter downloadable releases
let downloadable: Vec<_> = releases
.iter()
.filter(|r| r.download_allowed.unwrap_or(false) && !r.rejected.unwrap_or(true))
.collect();
assert_eq!(downloadable.len(), 3); // Should exclude rejected and not allowed
// Sort by quality weight and custom format score (simulate best quality selection)
let mut sorted_releases = downloadable.clone();
sorted_releases.sort_by(|a, b| {
let quality_cmp = b
.quality_weight
.unwrap_or(0)
.cmp(&a.quality_weight.unwrap_or(0));
if quality_cmp == std::cmp::Ordering::Equal {
b.custom_format_score
.unwrap_or(0)
.cmp(&a.custom_format_score.unwrap_or(0))
} else {
quality_cmp
}
});
// Best release should be "Best Quality" (highest quality_weight + custom_format_score)
assert_eq!(sorted_releases[0].title, Some("Best Quality".to_string()));
}
#[test]
fn test_search_endpoint_building() {
// Test endpoint building logic (simulates what would happen in search_releases)
let mut query_params = Vec::new();
let series_id = Some(123u32);
let episode_id = Some(456u32);
let season_number = Some(2i32);
if let Some(id) = series_id {
query_params.push(format!("seriesId={}", id));
}
if let Some(id) = episode_id {
query_params.push(format!("episodeId={}", id));
}
if let Some(season) = season_number {
query_params.push(format!("seasonNumber={}", season));
}
let endpoint = if query_params.is_empty() {
"/release".to_string()
} else {
format!("/release?{}", query_params.join("&"))
};
assert_eq!(
endpoint,
"/release?seriesId=123&episodeId=456&seasonNumber=2"
);
}
#[test]
fn test_empty_search_parameters() {
let query_params: Vec<String> = Vec::new();
let endpoint = if query_params.is_empty() {
"/release".to_string()
} else {
format!("/release?{}", query_params.join("&"))
};
assert_eq!(endpoint, "/release");
}
#[test]
fn test_release_size_handling() {
let release = create_test_release(1, "Big Release", 1000, 100, true, false, 15_000_000_000); // 15GB
// Test size filtering logic
let is_too_large = release.size.unwrap_or(0) > 10 * 1024 * 1024 * 1024; // > 10GB
assert!(is_too_large);
let small_release =
create_test_release(2, "Small Release", 1000, 100, true, false, 2_000_000_000); // 2GB
let is_acceptable_size = small_release.size.unwrap_or(0) <= 10 * 1024 * 1024 * 1024; // <= 10GB
assert!(is_acceptable_size);
}
#[test]
fn test_seeder_filtering() {
let low_seed_release =
create_test_release(1, "Low Seeds", 1000, 100, true, false, 2_000_000_000);
let mut test_release = low_seed_release;
test_release.seeders = Some(2);
let has_enough_seeders = test_release.seeders.unwrap_or(0) >= 5;
assert!(!has_enough_seeders);
test_release.seeders = Some(10);
let has_enough_seeders = test_release.seeders.unwrap_or(0) >= 5;
assert!(has_enough_seeders);
}
#[test]
fn test_custom_format_score_comparison() {
let release_a = create_test_release(1, "Release A", 1000, 100, true, false, 2_000_000_000);
let release_b = create_test_release(2, "Release B", 1000, 200, true, false, 2_000_000_000);
// Same quality weight, but B has higher custom format score
let score_a = release_a.custom_format_score.unwrap_or(0);
let score_b = release_b.custom_format_score.unwrap_or(0);
assert!(score_b > score_a);
}
#[test]
fn test_release_rejection_reasons() {
let rejected_release =
create_test_release(1, "Rejected", 1000, 100, true, true, 2_000_000_000);
assert!(rejected_release.rejected.unwrap_or(false));
assert!(rejected_release.rejections.is_some());
assert!(!rejected_release.rejections.as_ref().unwrap().is_empty());
}
#[test]
fn test_language_handling() {
let release = create_test_release(1, "Test", 1000, 100, true, false, 2_000_000_000);
assert!(release.languages.is_some());
let languages = release.languages.unwrap();
assert_eq!(languages.len(), 1);
assert_eq!(languages[0].name, Some("English".to_string()));
}
#[test]
fn test_episode_number_handling() {
let release = create_test_release(1, "Test", 1000, 100, true, false, 2_000_000_000);
assert!(release.episode_numbers.is_some());
let episodes = release.episode_numbers.unwrap();
assert_eq!(episodes, vec![1]);
}
// Helper function tests
#[test]
fn test_format_size_helper() {
fn format_size(bytes: i64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.1} {}", size, UNITS[unit_index])
}
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(1048576), "1.0 MB");
assert_eq!(format_size(1073741824), "1.0 GB");
assert_eq!(format_size(2_000_000_000), "1.9 GB");
}
}

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"