Compare commits
11 Commits
48e26332a3
...
download
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04de80e14 | ||
|
|
92d69f13f0 | ||
|
|
37e682a162 | ||
|
|
f039977e94 | ||
|
|
b876626d3e | ||
|
|
e5521f2c3e | ||
|
|
9fcacdcb32 | ||
|
|
03fd2de38f | ||
|
|
e9ecd2a295 | ||
|
|
8139fe4cb3 | ||
|
|
a8f0ab160e |
634
Cargo.lock
generated
634
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
61
Cargo.toml
61
Cargo.toml
@@ -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]
|
[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]
|
||||||
|
# API client library
|
||||||
|
yarr-api = { workspace = true }
|
||||||
|
|
||||||
|
# CLI dependencies
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
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
|
# Configuration
|
||||||
config = "0.14"
|
config = "0.15"
|
||||||
toml = "0.8"
|
toml = "0.9"
|
||||||
dirs = "5.0"
|
dirs = "6.0"
|
||||||
|
|||||||
169
KEYBINDS.md
Normal file
169
KEYBINDS.md
Normal 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
176
README.md
@@ -1,6 +1,15 @@
|
|||||||
# Yarr
|
# 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
|
## Features
|
||||||
|
|
||||||
@@ -8,13 +17,27 @@ A Terminal User Interface (TUI) for managing Sonarr.
|
|||||||
- Browse series and episodes
|
- Browse series and episodes
|
||||||
- Monitor download queue
|
- Monitor download queue
|
||||||
- View download history
|
- 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
|
- Configurable via config files, environment variables, or CLI arguments
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Install the TUI Application
|
||||||
|
|
||||||
```bash
|
```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
|
## Configuration
|
||||||
@@ -39,6 +62,12 @@ Example configuration:
|
|||||||
[sonarr]
|
[sonarr]
|
||||||
url = "http://localhost:8989"
|
url = "http://localhost:8989"
|
||||||
api_key = "your-api-key-here"
|
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
|
### Environment Variables
|
||||||
@@ -48,6 +77,8 @@ Set these environment variables:
|
|||||||
```bash
|
```bash
|
||||||
export YARR_SONARR_URL="http://localhost:8989"
|
export YARR_SONARR_URL="http://localhost:8989"
|
||||||
export YARR_SONARR_API_KEY="your-api-key-here"
|
export YARR_SONARR_API_KEY="your-api-key-here"
|
||||||
|
export YARR_UI_KEYBIND_MODE="Vim"
|
||||||
|
export YARR_UI_SHOW_HELP="true"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command Line Arguments
|
### Command Line Arguments
|
||||||
@@ -68,6 +99,25 @@ yarr
|
|||||||
yarr tui
|
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
|
### Command Line Mode
|
||||||
|
|
||||||
List all series:
|
List all series:
|
||||||
@@ -114,6 +164,18 @@ Show configuration file search paths:
|
|||||||
yarr config 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
|
### Shell Completions
|
||||||
|
|
||||||
Generate shell completions:
|
Generate shell completions:
|
||||||
@@ -134,11 +196,69 @@ yarr completions powershell > yarr.ps1
|
|||||||
|
|
||||||
## TUI Controls
|
## TUI Controls
|
||||||
|
|
||||||
|
### Normal Mode (Default)
|
||||||
|
|
||||||
- `q` - Quit
|
- `q` - Quit
|
||||||
- `↑/↓` or `j/k` - Navigate up/down
|
- `↑/↓` or `j/k` - Navigate up/down
|
||||||
- `Enter` - Select/expand
|
- `Enter` - Select/expand item or show releases (in Search tab)
|
||||||
- `Tab` - Switch between panels
|
- `Tab` - Switch between tabs
|
||||||
|
- `d` - Toggle details
|
||||||
- `r` - Refresh data
|
- `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
|
## Getting Started
|
||||||
|
|
||||||
@@ -160,6 +280,50 @@ yarr completions powershell > yarr.ps1
|
|||||||
3. Find the "Security" section
|
3. Find the "Security" section
|
||||||
4. Copy the "API Key" value
|
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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -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
8526
sonarr.yaml
Normal file
8526
sonarr.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@ pub enum ConfigAction {
|
|||||||
|
|
||||||
/// Show possible config file locations
|
/// Show possible config file locations
|
||||||
Paths,
|
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 {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub sonarr: SonarrConfig,
|
pub sonarr: SonarrConfig,
|
||||||
|
pub ui: UiConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -13,6 +14,33 @@ pub struct SonarrConfig {
|
|||||||
pub api_key: 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 {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -20,6 +48,7 @@ impl Default for AppConfig {
|
|||||||
url: "http://localhost:8989".to_string(),
|
url: "http://localhost:8989".to_string(),
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
},
|
},
|
||||||
|
ui: UiConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +121,10 @@ impl AppConfig {
|
|||||||
url: "http://localhost:8989".to_string(),
|
url: "http://localhost:8989".to_string(),
|
||||||
api_key: "your-api-key-here".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)?;
|
let toml_content = toml::to_string_pretty(&sample_config)?;
|
||||||
@@ -144,7 +177,6 @@ impl AppConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
66
src/main.rs
66
src/main.rs
@@ -1,13 +1,13 @@
|
|||||||
mod api;
|
#[deny(warnings)]
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod errors;
|
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
use crate::api::SonarrClient;
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use std::process;
|
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>> {
|
||||||
@@ -61,7 +61,7 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
Some(cli::Commands::Tui) | None => {
|
Some(cli::Commands::Tui) | None => {
|
||||||
// Default to TUI mode
|
// Default to TUI mode
|
||||||
tui::run_app(client).await?;
|
tui::run_app(client, config).await?;
|
||||||
}
|
}
|
||||||
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
|
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
|
||||||
// Already handled above
|
// Already handled above
|
||||||
@@ -123,6 +123,64 @@ fn handle_config_command(
|
|||||||
println!(" YARR_SONARR_URL");
|
println!(" YARR_SONARR_URL");
|
||||||
println!(" YARR_SONARR_API_KEY");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
889
src/tui.rs
889
src/tui.rs
File diff suppressed because it is too large
Load Diff
33
yarr-api/Cargo.toml
Normal file
33
yarr-api/Cargo.toml
Normal 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
180
yarr-api/README.md
Normal 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
|
||||||
125
yarr-api/examples/basic_usage.rs
Normal file
125
yarr-api/examples/basic_usage.rs
Normal 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(())
|
||||||
|
}
|
||||||
189
yarr-api/examples/download_after_search.rs
Normal file
189
yarr-api/examples/download_after_search.rs
Normal 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
74
yarr-api/src/error.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,33 +168,293 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -553,3 +826,89 @@ pub struct HealthResource {
|
|||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
pub wiki_url: 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
336
yarr-api/src/tests.rs
Normal 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
52
yarr-vim.toml.example
Normal 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"
|
||||||
@@ -6,15 +6,36 @@
|
|||||||
[sonarr]
|
[sonarr]
|
||||||
# Sonarr server URL (required)
|
# Sonarr server URL (required)
|
||||||
# Example: "http://localhost:8989" or "https://sonarr.example.com"
|
# Example: "http://localhost:8989" or "https://sonarr.example.com"
|
||||||
|
# Can also be edited from the Settings tab in the application
|
||||||
url = "http://localhost:8989"
|
url = "http://localhost:8989"
|
||||||
|
|
||||||
# Sonarr API key (required)
|
# Sonarr API key (required)
|
||||||
# You can find this in Sonarr under Settings > General > Security > API Key
|
# 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"
|
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:
|
# Environment variables can also be used:
|
||||||
# YARR_SONARR_URL="http://localhost:8989"
|
# YARR_SONARR_URL="http://localhost:8989"
|
||||||
# YARR_SONARR_API_KEY="your-api-key-here"
|
# YARR_SONARR_API_KEY="your-api-key-here"
|
||||||
|
# YARR_UI_KEYBIND_MODE="Vim"
|
||||||
|
# YARR_UI_SHOW_HELP="false"
|
||||||
#
|
#
|
||||||
# Command line arguments take highest priority:
|
# Command line arguments take highest priority:
|
||||||
# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"
|
# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"
|
||||||
|
|||||||
Reference in New Issue
Block a user