feat(yarr): Added download option
This commit is contained in:
44
README.md
44
README.md
@@ -17,6 +17,7 @@ This workspace contains two crates:
|
||||
- Browse series and episodes
|
||||
- Monitor download queue
|
||||
- View download history
|
||||
- **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
|
||||
@@ -98,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:
|
||||
@@ -180,11 +200,12 @@ yarr completions powershell > yarr.ps1
|
||||
|
||||
- `q` - Quit
|
||||
- `↑/↓` or `j/k` - Navigate up/down
|
||||
- `Enter` - Select/expand
|
||||
- `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
|
||||
@@ -198,6 +219,8 @@ yarr completions powershell > yarr.ps1
|
||||
- `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
|
||||
@@ -210,6 +233,23 @@ Use the Settings tab to:
|
||||
|
||||
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:
|
||||
@@ -261,7 +301,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
```
|
||||
|
||||
See the [yarr-api README](yarr-api/README.md) for detailed API documentation and examples.
|
||||
See the [yarr-api README](yarr-api/README.md) for detailed API documentation and examples, including the new download after search functionality.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
8526
sonarr.yaml
Normal file
8526
sonarr.yaml
Normal file
File diff suppressed because it is too large
Load Diff
261
src/tui.rs
261
src/tui.rs
@@ -22,7 +22,8 @@ use tokio::sync::mpsc;
|
||||
|
||||
use crate::config::{AppConfig, KeybindMode};
|
||||
use yarr_api::{
|
||||
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
|
||||
Episode, HealthResource, HistoryItem, QueueItem, ReleaseResource, Series, SonarrClient,
|
||||
SystemStatus,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -39,6 +40,8 @@ pub enum AppEvent {
|
||||
HealthLoaded(Vec<HealthResource>),
|
||||
CalendarLoaded(Vec<Episode>),
|
||||
SearchResults(Vec<Series>),
|
||||
ReleasesLoaded(Vec<ReleaseResource>),
|
||||
ReleaseDownloaded(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -95,6 +98,7 @@ pub struct App {
|
||||
pub health_list_state: ListState,
|
||||
pub search_list_state: ListState,
|
||||
pub settings_list_state: ListState,
|
||||
pub releases_list_state: ListState,
|
||||
pub series: Vec<Series>,
|
||||
pub episodes: Vec<Episode>,
|
||||
pub queue: Vec<QueueItem>,
|
||||
@@ -117,6 +121,10 @@ pub struct App {
|
||||
pub editing_api_key: bool,
|
||||
pub url_input: String,
|
||||
pub api_key_input: String,
|
||||
pub show_releases_popup: bool,
|
||||
pub releases: Vec<ReleaseResource>,
|
||||
pub selected_search_series: Option<Series>,
|
||||
pub loading_releases: bool,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
@@ -131,6 +139,7 @@ impl Default for App {
|
||||
health_list_state: ListState::default(),
|
||||
search_list_state: ListState::default(),
|
||||
settings_list_state: ListState::default(),
|
||||
releases_list_state: ListState::default(),
|
||||
series: Vec::new(),
|
||||
episodes: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
@@ -152,9 +161,14 @@ impl Default for App {
|
||||
editing_api_key: false,
|
||||
url_input: String::new(),
|
||||
api_key_input: String::new(),
|
||||
show_releases_popup: false,
|
||||
releases: Vec::new(),
|
||||
selected_search_series: None,
|
||||
loading_releases: false,
|
||||
};
|
||||
app.series_list_state.select(Some(0));
|
||||
app.settings_list_state.select(Some(0));
|
||||
app.releases_list_state.select(Some(0));
|
||||
app
|
||||
}
|
||||
}
|
||||
@@ -177,6 +191,11 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn next_item(&mut self) {
|
||||
if self.show_releases_popup {
|
||||
self.next_release();
|
||||
return;
|
||||
}
|
||||
|
||||
match self.current_tab {
|
||||
TabIndex::Series => {
|
||||
let len = self.series.len();
|
||||
@@ -245,6 +264,11 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn previous_item(&mut self) {
|
||||
if self.show_releases_popup {
|
||||
self.previous_release();
|
||||
return;
|
||||
}
|
||||
|
||||
match self.current_tab {
|
||||
TabIndex::Series => {
|
||||
let len = self.series.len();
|
||||
@@ -366,6 +390,55 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_selected_release(&self) -> Option<&ReleaseResource> {
|
||||
if let Some(index) = self.releases_list_state.selected() {
|
||||
self.releases.get(index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_releases_popup(&mut self, series: Series) {
|
||||
self.show_releases_popup = true;
|
||||
self.selected_search_series = Some(series);
|
||||
self.releases.clear();
|
||||
self.releases_list_state.select(Some(0));
|
||||
self.loading_releases = true;
|
||||
}
|
||||
|
||||
pub fn hide_releases_popup(&mut self) {
|
||||
self.show_releases_popup = false;
|
||||
self.selected_search_series = None;
|
||||
self.releases.clear();
|
||||
self.loading_releases = false;
|
||||
}
|
||||
|
||||
pub fn next_release(&mut self) {
|
||||
if !self.releases.is_empty() {
|
||||
let i = match self.releases_list_state.selected() {
|
||||
Some(i) => (i + 1) % self.releases.len(),
|
||||
None => 0,
|
||||
};
|
||||
self.releases_list_state.select(Some(i));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_release(&mut self) {
|
||||
if !self.releases.is_empty() {
|
||||
let i = match self.releases_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
self.releases.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.releases_list_state.select(Some(i));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enter_search_mode(&mut self) {
|
||||
self.search_mode = true;
|
||||
self.input_mode = true;
|
||||
@@ -721,6 +794,18 @@ async fn run_tui<B: Backend>(
|
||||
app.search_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppEvent::ReleasesLoaded(releases) => {
|
||||
app.releases = releases;
|
||||
app.loading_releases = false;
|
||||
if !app.releases.is_empty() {
|
||||
app.releases_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppEvent::ReleaseDownloaded(title) => {
|
||||
app.hide_releases_popup();
|
||||
app.set_error(format!("Successfully started download: {}", title));
|
||||
app.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,14 +1024,61 @@ async fn handle_normal_mode(
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if app.current_tab == TabIndex::Settings {
|
||||
if app.show_releases_popup {
|
||||
// Download the selected release
|
||||
if let Some(release) = app.get_selected_release().cloned() {
|
||||
let client_clone = client.clone();
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match client_clone.download_release(&release).await {
|
||||
Ok(()) => {
|
||||
let title = release.title.unwrap_or_default();
|
||||
let _ = tx_clone.send(AppEvent::ReleaseDownloaded(title));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx_clone
|
||||
.send(AppEvent::Error(format!("Download failed: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
app.hide_releases_popup();
|
||||
}
|
||||
} else if app.current_tab == TabIndex::Search && !app.search_results.is_empty() {
|
||||
// Show releases popup for selected search result
|
||||
if let Some(selected_series) = app.get_selected_search_result().cloned() {
|
||||
app.show_releases_popup(selected_series.clone());
|
||||
let client_clone = client.clone();
|
||||
let tx_clone = tx.clone();
|
||||
let series_id = selected_series.id.unwrap_or(0);
|
||||
tokio::spawn(async move {
|
||||
match client_clone
|
||||
.search_releases(Some(series_id), None, None)
|
||||
.await
|
||||
{
|
||||
Ok(releases) => {
|
||||
let _ = tx_clone.send(AppEvent::ReleasesLoaded(releases));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx_clone.send(AppEvent::Error(format!(
|
||||
"Failed to load releases: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if app.current_tab == TabIndex::Settings {
|
||||
app.handle_settings_input();
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.clear_error();
|
||||
if app.search_mode {
|
||||
app.exit_search_mode();
|
||||
if app.show_releases_popup {
|
||||
app.hide_releases_popup();
|
||||
} else {
|
||||
app.clear_error();
|
||||
if app.search_mode {
|
||||
app.exit_search_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1063,6 +1195,11 @@ fn ui(f: &mut Frame, app: &App) {
|
||||
// Render footer
|
||||
render_footer(f, chunks[2], app);
|
||||
|
||||
// Render releases popup if it should be shown
|
||||
if app.show_releases_popup {
|
||||
render_releases_popup(f, app);
|
||||
}
|
||||
|
||||
// Render error popup if there's an error
|
||||
if app.error_message.is_some() {
|
||||
render_error_popup(f, size, app);
|
||||
@@ -1397,7 +1534,7 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Search Series (Press '/' to search, Enter to execute)"),
|
||||
.title("Search Series (Press '/' to search, Enter to execute, Enter on result to view releases)"),
|
||||
);
|
||||
f.render_widget(input, chunks[0]);
|
||||
|
||||
@@ -1406,9 +1543,9 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
||||
let text = if app.loading {
|
||||
"Searching..."
|
||||
} else if app.search_input.is_empty() {
|
||||
"Enter a search term and press Enter"
|
||||
"Enter a search term and press Enter to search for series"
|
||||
} else {
|
||||
"No results found"
|
||||
"No results found - try a different search term"
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(text)
|
||||
@@ -1445,7 +1582,7 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Search Results"),
|
||||
.title("Search Results (Press Enter on a series to view available releases)"),
|
||||
)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
@@ -1574,7 +1711,9 @@ fn render_settings_input(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
let help_text = if app.input_mode {
|
||||
let help_text = if app.show_releases_popup {
|
||||
"Enter: Download | Esc: Close | ↑↓/jk: Navigate"
|
||||
} else if app.input_mode {
|
||||
if app.editing_url || app.editing_api_key {
|
||||
"ESC: Cancel | Enter: Save | Type to enter value"
|
||||
} else {
|
||||
@@ -1582,6 +1721,8 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
} else if !app.config.ui.show_help {
|
||||
"" // Don't show help if disabled
|
||||
} else if app.current_tab == TabIndex::Search && !app.search_results.is_empty() {
|
||||
"Enter: Show Releases | ↑↓/jk: Navigate | /: Search | Other: Normal keys"
|
||||
} else {
|
||||
match app.config.ui.keybind_mode {
|
||||
KeybindMode::Normal => {
|
||||
@@ -1638,6 +1779,106 @@ fn render_error_popup(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_releases_popup(f: &mut Frame, app: &App) {
|
||||
let area = centered_rect(80, 70, f.area());
|
||||
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let title = if let Some(ref series) = app.selected_search_series {
|
||||
format!(
|
||||
"Releases for: {}",
|
||||
series.title.as_deref().unwrap_or("Unknown")
|
||||
)
|
||||
} else {
|
||||
"Releases".to_string()
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
|
||||
if app.loading_releases {
|
||||
let loading_text = Paragraph::new("Loading releases...")
|
||||
.block(block)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(loading_text, area);
|
||||
} else if app.releases.is_empty() {
|
||||
let no_releases_text = Paragraph::new("No releases found")
|
||||
.block(block)
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(no_releases_text, area);
|
||||
} else {
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(3)])
|
||||
.split(inner_area);
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.releases
|
||||
.iter()
|
||||
.map(|release| {
|
||||
let title = release.title.as_deref().unwrap_or("Unknown");
|
||||
let size = format_size(release.size.unwrap_or(0));
|
||||
let quality = release.quality_weight.unwrap_or(0);
|
||||
let indexer = release.indexer.as_deref().unwrap_or("Unknown");
|
||||
let seeds = release.seeders.unwrap_or(0);
|
||||
let peers = release.leechers.unwrap_or(0);
|
||||
|
||||
let status = if release.download_allowed.unwrap_or(false) {
|
||||
if release.rejected.unwrap_or(false) {
|
||||
"❌ Rejected"
|
||||
} else {
|
||||
"✅ Available"
|
||||
}
|
||||
} else {
|
||||
"⛔ Not Allowed"
|
||||
};
|
||||
|
||||
ListItem::new(format!(
|
||||
"{} | {} | Q:{} | {} | S:{} P:{} | {}",
|
||||
status, size, quality, indexer, seeds, peers, title
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
f.render_stateful_widget(list, chunks[0], &mut app.releases_list_state.clone());
|
||||
|
||||
let help_text = Paragraph::new("Enter: Download | Esc: Close | ↑↓: Navigate")
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::TOP));
|
||||
f.render_widget(help_text, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
|
||||
@@ -12,6 +12,8 @@ A Rust client library for the Sonarr API.
|
||||
- **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
|
||||
@@ -48,6 +50,12 @@ async fn main() -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
```
|
||||
@@ -73,6 +81,12 @@ async fn main() -> Result<()> {
|
||||
- ✅ 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:
|
||||
@@ -81,11 +95,39 @@ See the `examples/` directory for more comprehensive usage examples:
|
||||
# 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:
|
||||
@@ -121,6 +163,9 @@ All Sonarr API responses are represented as strongly-typed Rust structs:
|
||||
- `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
|
||||
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,264 @@ impl SonarrClient {
|
||||
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)]
|
||||
@@ -568,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
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user