feat(yarr): Added download option

This commit is contained in:
uttarayan21
2025-10-13 20:35:49 +05:30
parent 92d69f13f0
commit f04de80e14
7 changed files with 9733 additions and 12 deletions

View File

@@ -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

View File

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

View File

@@ -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
View File

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