use async_trait::async_trait;
use reqwest::{header::CONTENT_TYPE, Client, Request};
use serde::{Deserialize, Serialize};
use crate::{Category, ClientError, SearchProvider, SearchRequest, Torrent};
pub struct Knaben {
api_url: String,
}
impl Knaben {
pub fn new() -> Self {
Self {
api_url: "https://api.knaben.org/v1".to_string(),
}
}
pub fn with_url(url: impl Into<String>) -> Self {
Self {
api_url: url.into(),
}
}
}
impl Default for Knaben {
fn default() -> Self {
Knaben::new()
}
}
#[async_trait]
impl SearchProvider for Knaben {
fn build_request(
&self,
client: &Client,
request: SearchRequest<'_>,
) -> Result<Request, ClientError> {
let knaben_request = KnabenRequest::from_search_request(request);
let json = serde_json::to_value(&knaben_request)
.map_err(|e| ClientError::DataParseError(e.into()))?;
client
.post(self.api_url.clone())
.header(CONTENT_TYPE, "application/json")
.body(json.to_string())
.build()
.map_err(|e| ClientError::RequestBuildError {
source: e.into(),
url: self.api_url.clone(),
})
}
fn parse_response(&self, response: &str) -> Result<Vec<Torrent>, ClientError> {
let response: Response =
serde_json::from_str(response).map_err(|e| ClientError::DataParseError(e.into()))?;
let torrents = response
.entries
.iter()
.filter_map(|entry| {
entry.hash.as_ref().map(|hash| Torrent {
name: entry.title.to_owned(),
magnet_link: format!("magnet:?xt=urn:btih:{}", hash),
seeders: entry.seeders,
peers: entry.peers,
size_bytes: entry.bytes,
provider: format!("{} (via Knaben)", entry.tracker),
})
})
.collect();
Ok(torrents)
}
fn id(&self) -> String {
self.api_url.clone()
}
}
#[derive(Serialize, Deserialize, Debug)]
struct KnabenRequest {
search_type: String,
search_field: String,
query: String,
order_by: String,
order_direction: String,
categories: Option<Vec<u32>>,
size: u32,
hide_unsafe: bool,
hide_xxx: bool,
seconds_since_last_seen: u32,
}
impl KnabenRequest {
pub fn from_search_request(request: SearchRequest<'_>) -> Self {
let mut hide_xxx = true;
let categories: Option<Vec<u32>> = if request.categories.is_empty() {
None
} else {
Some(
request
.categories
.iter()
.map(|category| match category {
Category::Movies => 3000000,
Category::TvShows => 2000000,
Category::Games => 4001000,
Category::Software => 4002000,
Category::Audio => 1000000,
Category::Anime => 6000000,
Category::Xxx => {
hide_xxx = false;
5000000
}
})
.collect(),
)
};
Self {
search_type: "100%".to_string(),
search_field: "title".to_string(),
query: request.query.to_string(),
order_by: request.order_by.to_string(),
order_direction: "desc".to_string(),
categories,
size: 50,
hide_unsafe: true,
hide_xxx,
seconds_since_last_seen: 86400, }
}
}
#[derive(Debug, Deserialize)]
struct Response {
#[serde(rename = "hits")]
entries: Vec<ResponseEntry>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResponseEntry {
id: String,
title: String,
hash: Option<String>,
peers: u32,
seeders: u32,
bytes: u64,
date: String,
tracker: String,
category_id: Vec<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
use serde_json::{json, Value};
async fn setup_mock_provider() -> Knaben {
Knaben {
api_url: Server::new_async().await.url(),
}
}
#[tokio::test]
async fn test_build_request_with_categories() {
let provider = setup_mock_provider().await;
let client = Client::new();
let search_request = SearchRequest::new("ubuntu").add_category(Category::Movies);
let request = provider.build_request(&client, search_request);
assert!(request.is_ok());
let request = request.unwrap();
assert_eq!(request.method(), "POST");
assert_eq!(
request.headers().get(CONTENT_TYPE).unwrap(),
"application/json"
);
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap())
.expect("Body should be valid JSON");
assert_eq!(body["query"], "ubuntu");
assert_eq!(body["categories"], json![[3000000]]);
}
#[tokio::test]
async fn test_build_request_without_categories() {
let provider = setup_mock_provider().await;
let client = Client::new();
let search_request = SearchRequest::new("ubuntu");
let request = provider.build_request(&client, search_request);
assert!(request.is_ok());
let request = request.unwrap();
assert_eq!(request.method(), "POST");
let body: serde_json::Value =
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap())
.expect("Body should be valid JSON");
assert_eq!(body["query"], "ubuntu");
assert_eq!(body.get("categories").unwrap(), &Value::Null);
}
#[tokio::test]
async fn test_parse_response_valid() {
let provider = setup_mock_provider().await;
let response_body = r#"
{
"hits": [
{
"id": "1",
"title": "Ubuntu ISO",
"hash": "abc123",
"peers": 10,
"seeders": 20,
"bytes": 2048,
"date": "2024-01-01",
"tracker": "knaben",
"categoryId": [3000000]
}
]
}
"#;
let result = provider.parse_response(response_body);
assert!(result.is_ok());
let torrents = result.unwrap();
assert_eq!(torrents.len(), 1);
let torrent = &torrents[0];
assert_eq!(torrent.name, "Ubuntu ISO");
assert_eq!(torrent.magnet_link, "magnet:?xt=urn:btih:abc123");
assert_eq!(torrent.seeders, 20);
assert_eq!(torrent.peers, 10);
assert_eq!(torrent.size_bytes, 2048);
assert_eq!(torrent.provider, "knaben (via Knaben)");
}
#[tokio::test]
async fn test_parse_response_invalid_json() {
let provider = setup_mock_provider().await;
let invalid_response_body = r#"not a valid json"#;
let result = provider.parse_response(invalid_response_body);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ClientError::DataParseError(_)),
"Expected ClientError::DataParseError"
);
}
#[tokio::test]
async fn test_parse_response_invalid_entries() {
let provider = setup_mock_provider().await;
let response_body = r#"
{
"hits": [
{
"id": "1",
"title": "Ubuntu ISO",
"hash": null,
"peers": 10,
"seeders": 20,
"bytes": 2048,
"date": "2024-01-01",
"tracker": "knaben",
"categoryId": [3000000]
}
]
}
"#;
let result = provider.parse_response(response_body);
assert!(result.is_ok());
let torrents = result.unwrap();
assert!(
torrents.is_empty(),
"Expected empty results due to invalid entries"
);
}
#[tokio::test]
async fn test_parse_response_empty() {
let provider = setup_mock_provider().await;
let response_body = r#"{ "hits": [] }"#;
let result = provider.parse_response(response_body);
assert!(result.is_ok());
let torrents = result.unwrap();
assert!(torrents.is_empty(), "Expected empty results");
}
}