feat: add indexer and torrent REST controllers with service layer

- Add config module for yaml config (database, indexers, torrent)
- Add indexer module with Torznab protocol support
- Add IndexerService and TorrentService for business logic
- Add REST controllers for indexer search and torrent management
- Add Docker Compose for PostgreSQL and Jackett
- Add ERD documentation for database schema
This commit is contained in:
Alexander
2026-04-28 18:53:50 +02:00
parent f77806ba46
commit 1aaaab4640
23 changed files with 1841 additions and 51 deletions
+92
View File
@@ -0,0 +1,92 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::indexer::{MusicSearchCriteria, SearchResult};
use crate::services::IndexerInfo;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list_indexers))
.route("/search", post(search))
.route("/:name/test", get(test_indexer))
}
async fn list_indexers(State(state): State<AppState>) -> Json<Vec<IndexerInfo>> {
let state = state.read().await;
Json(state.indexer_service.list_indexers())
}
#[derive(Debug, Deserialize)]
pub struct SearchRequest {
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub limit: Option<u32>,
pub indexer: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub total: usize,
}
async fn search(
State(state): State<AppState>,
Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, (StatusCode, String)> {
let mut criteria = MusicSearchCriteria::new(&req.artist);
if let Some(album) = &req.album {
criteria = criteria.with_album(album);
}
if let Some(year) = req.year {
criteria = criteria.with_year(year);
}
if let Some(limit) = req.limit {
criteria = criteria.with_limit(limit);
}
let state = state.read().await;
let results = state
.indexer_service
.search(&criteria, req.indexer.as_deref())
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
let total = results.len();
Ok(Json(SearchResponse { results, total }))
}
#[derive(Debug, Serialize)]
pub struct TestResponse {
pub success: bool,
pub message: String,
}
async fn test_indexer(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<TestResponse>, (StatusCode, Json<TestResponse>)> {
let state = state.read().await;
match state.indexer_service.test_indexer(&name).await {
Ok(()) => Ok(Json(TestResponse {
success: true,
message: "Connection successful".to_string(),
})),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
Json(TestResponse {
success: false,
message: e.to_string(),
}),
)),
}
}
+28 -17
View File
@@ -1,3 +1,6 @@
mod indexer_controller;
mod torrent_controller;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
@@ -18,20 +21,22 @@ pub fn routes(state: AppState) -> Router {
.route("/tracks/:id", delete(delete_track))
.route("/tracks/search", get(search_tracks))
.route("/stats", get(get_stats))
.nest("/indexers", indexer_controller::routes())
.nest("/torrents", torrent_controller::routes())
.with_state(state)
}
async fn list_tracks(State(state): State<AppState>) -> Json<Vec<Track>> {
let agg = state.read().await;
Json(agg.get_all().to_vec())
let state = state.read().await;
Json(state.aggregator.get_all().to_vec())
}
async fn create_track(
State(state): State<AppState>,
Json(input): Json<CreateTrack>,
) -> (StatusCode, Json<Track>) {
let mut agg = state.write().await;
let track = agg.add_track(input.into());
let mut state = state.write().await;
let track = state.aggregator.add_track(input.into());
(StatusCode::CREATED, Json(track))
}
@@ -39,19 +44,18 @@ async fn get_track(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Track>, StatusCode> {
let agg = state.read().await;
agg.get_by_id(id)
let state = state.read().await;
state
.aggregator
.get_by_id(id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn delete_track(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> StatusCode {
let mut agg = state.write().await;
if agg.delete(id) {
async fn delete_track(State(state): State<AppState>, Path(id): Path<Uuid>) -> StatusCode {
let mut state = state.write().await;
if state.aggregator.delete(id) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
@@ -67,8 +71,15 @@ async fn search_tracks(
State(state): State<AppState>,
Query(query): Query<SearchQuery>,
) -> Json<Vec<Track>> {
let agg = state.read().await;
Json(agg.search_by_artist(&query.artist).into_iter().cloned().collect())
let state = state.read().await;
Json(
state
.aggregator
.search_by_artist(&query.artist)
.into_iter()
.cloned()
.collect(),
)
}
#[derive(serde::Serialize)]
@@ -78,9 +89,9 @@ struct Stats {
}
async fn get_stats(State(state): State<AppState>) -> Json<Stats> {
let agg = state.read().await;
let state = state.read().await;
Json(Stats {
track_count: agg.get_all().len(),
total_duration_secs: agg.total_duration(),
track_count: state.aggregator.get_all().len(),
total_duration_secs: state.aggregator.total_duration(),
})
}
+210
View File
@@ -0,0 +1,210 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::torrent::TorrentInfo;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list_torrents))
.route("/:hash", get(get_torrent))
.route("/:hash", delete(remove_torrent))
.route("/:hash/pause", post(pause_torrent))
.route("/:hash/resume", post(resume_torrent))
.route("/add/url", post(add_torrent_url))
.route("/add/file", post(add_torrent_file))
.route("/status", get(connection_status))
}
#[derive(Debug, Serialize)]
pub struct TorrentListResponse {
pub torrents: Vec<TorrentInfo>,
pub total: usize,
}
async fn list_torrents(
State(state): State<AppState>,
) -> Result<Json<TorrentListResponse>, (StatusCode, String)> {
let state = state.read().await;
let torrents = state
.torrent_service
.list_torrents()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
let total = torrents.len();
Ok(Json(TorrentListResponse { torrents, total }))
}
async fn get_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Json<TorrentInfo>, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.get_torrent(&hash)
.await
.map(Json)
.map_err(|e| {
let status = if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_GATEWAY
};
(status, e.to_string())
})
}
#[derive(Debug, Deserialize)]
pub struct RemoveQuery {
#[serde(default)]
pub delete_files: bool,
}
async fn remove_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
Query(query): Query<RemoveQuery>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.remove_torrent(&hash, query.delete_files)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
async fn pause_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.pause_torrent(&hash)
.await
.map(|_| StatusCode::OK)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
async fn resume_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.resume_torrent(&hash)
.await
.map(|_| StatusCode::OK)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
#[derive(Debug, Deserialize)]
pub struct AddUrlRequest {
pub url: String,
pub save_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AddResponse {
pub success: bool,
pub message: String,
}
async fn add_torrent_url(
State(state): State<AppState>,
Json(req): Json<AddUrlRequest>,
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
let state = state.read().await;
state
.torrent_service
.add_torrent_url(&req.url, req.save_path.as_deref())
.await
.map(|_| {
(
StatusCode::CREATED,
Json(AddResponse {
success: true,
message: "Torrent added successfully".to_string(),
}),
)
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: e.to_string(),
}),
)
})
}
#[derive(Debug, Deserialize)]
pub struct AddFileRequest {
pub torrent_base64: String,
pub save_path: Option<String>,
}
async fn add_torrent_file(
State(state): State<AppState>,
Json(req): Json<AddFileRequest>,
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD
.decode(&req.torrent_base64)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: format!("Invalid base64: {}", e),
}),
)
})?;
let state = state.read().await;
state
.torrent_service
.add_torrent_file(&data, req.save_path.as_deref())
.await
.map(|_| {
(
StatusCode::CREATED,
Json(AddResponse {
success: true,
message: "Torrent added successfully".to_string(),
}),
)
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: e.to_string(),
}),
)
})
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub connected: bool,
}
async fn connection_status(State(state): State<AppState>) -> Json<StatusResponse> {
let state = state.read().await;
Json(StatusResponse {
connected: state.torrent_service.is_connected().await,
})
}
+52
View File
@@ -0,0 +1,52 @@
use serde::Deserialize;
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
ReadError(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
ParseError(#[from] serde_yaml::Error),
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub database: DatabaseConfig,
pub indexers: Vec<IndexerConfig>,
pub torrent: TorrentConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IndexerConfig {
pub name: String,
pub url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TorrentConfig {
pub qbittorrent: Option<QBittorrentConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct QBittorrentConfig {
pub url: String,
pub username: String,
pub password: String,
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&content)?;
Ok(config)
}
}
+43
View File
@@ -0,0 +1,43 @@
mod search;
mod torznab;
pub use search::{MusicSearchCriteria, SearchResult};
pub use torznab::TorznabIndexer;
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IndexerError {
#[error("authentication failed: invalid API key")]
AuthenticationFailed,
#[error("rate limited: retry after {0} seconds")]
RateLimited(u64),
#[error("indexer unavailable: {0}")]
Unavailable(String),
#[error("search failed: {0}")]
SearchFailed(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
}
#[async_trait]
pub trait Indexer: Send + Sync {
fn name(&self) -> &str;
fn supports_music_search(&self) -> bool;
async fn search(
&self,
criteria: &MusicSearchCriteria,
) -> Result<Vec<SearchResult>, IndexerError>;
async fn test_connection(&self) -> Result<(), IndexerError>;
}
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct MusicSearchCriteria {
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub limit: u32,
pub offset: u32,
}
impl MusicSearchCriteria {
pub fn new(artist: impl Into<String>) -> Self {
Self {
artist: artist.into(),
album: None,
year: None,
limit: 100,
offset: 0,
}
}
pub fn with_album(mut self, album: impl Into<String>) -> Self {
self.album = Some(album.into());
self
}
pub fn with_year(mut self, year: u32) -> Self {
self.year = Some(year);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = limit;
self
}
pub fn with_offset(mut self, offset: u32) -> Self {
self.offset = offset;
self
}
pub fn clean_artist(&self) -> String {
normalize_query(&self.artist)
}
pub fn clean_album(&self) -> Option<String> {
self.album.as_ref().map(|a| normalize_query(a))
}
}
fn normalize_query(s: &str) -> String {
s.trim().replace("\"", "").replace("'", "").to_lowercase()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub guid: String,
pub title: String,
pub download_url: String,
pub info_url: Option<String>,
pub size: u64,
pub publish_date: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<u32>,
pub label: Option<String>,
pub seeders: Option<u32>,
pub leechers: Option<u32>,
pub grabs: Option<u32>,
pub infohash: Option<String>,
pub magnet_url: Option<String>,
pub indexer: String,
pub categories: Vec<u32>,
}
+222
View File
@@ -0,0 +1,222 @@
use async_trait::async_trait;
use reqwest::Client;
use url::Url;
use super::search::{MusicSearchCriteria, SearchResult};
use super::{Indexer, IndexerError};
pub struct TorznabIndexer {
name: String,
base_url: Url,
api_key: String,
categories: Vec<u32>,
http: Client,
}
impl TorznabIndexer {
pub fn new(
name: impl Into<String>,
base_url: &str,
api_key: impl Into<String>,
) -> Result<Self, IndexerError> {
let base_url = Url::parse(base_url)
.map_err(|e| IndexerError::SearchFailed(format!("invalid URL: {}", e)))?;
Ok(Self {
name: name.into(),
base_url,
api_key: api_key.into(),
categories: vec![3000, 3010, 3040],
http: Client::new(),
})
}
pub fn with_categories(mut self, categories: Vec<u32>) -> Self {
self.categories = categories;
self
}
fn build_search_url(&self, criteria: &MusicSearchCriteria) -> Result<Url, IndexerError> {
let mut url = self.base_url.clone();
{
let mut query = url.query_pairs_mut();
query.append_pair("t", "music");
query.append_pair("apikey", &self.api_key);
query.append_pair("extended", "1");
let cats = self
.categories
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(",");
query.append_pair("cat", &cats);
query.append_pair("artist", &criteria.clean_artist());
if let Some(album) = criteria.clean_album() {
query.append_pair("album", &album);
}
if let Some(year) = criteria.year {
query.append_pair("year", &year.to_string());
}
query.append_pair("limit", &criteria.limit.to_string());
query.append_pair("offset", &criteria.offset.to_string());
}
Ok(url)
}
fn parse_response(&self, xml: &str) -> Result<Vec<SearchResult>, IndexerError> {
let mut results = Vec::new();
let doc = roxmltree::Document::parse(xml)
.map_err(|e| IndexerError::ParseError(format!("XML parse error: {}", e)))?;
if let Some(error) = doc.descendants().find(|n| n.has_tag_name("error")) {
let code = error.attribute("code").unwrap_or("0");
let desc = error.attribute("description").unwrap_or("Unknown error");
if code.starts_with("1") {
return Err(IndexerError::AuthenticationFailed);
}
return Err(IndexerError::SearchFailed(desc.to_string()));
}
for item in doc.descendants().filter(|n| n.has_tag_name("item")) {
let result = self.parse_item(&item)?;
results.push(result);
}
Ok(results)
}
fn parse_item(&self, item: &roxmltree::Node) -> Result<SearchResult, IndexerError> {
let get_text = |tag: &str| -> Option<String> {
item.children()
.find(|n| n.has_tag_name(tag))
.and_then(|n| n.text())
.map(|s| s.to_string())
};
let get_attr = |name: &str| -> Option<String> {
item.children()
.filter(|n| n.has_tag_name("attr"))
.find(|n| n.attribute("name") == Some(name))
.and_then(|n| n.attribute("value"))
.map(|s| s.to_string())
};
let guid = get_text("guid").unwrap_or_default();
let title = get_text("title").unwrap_or_default();
let download_url = get_text("link").unwrap_or_default();
let size = get_attr("size")
.or_else(|| {
item.children()
.find(|n| n.has_tag_name("enclosure"))
.and_then(|n| n.attribute("length"))
.map(|s| s.to_string())
})
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mut categories = Vec::new();
for attr in item.children().filter(|n| n.has_tag_name("attr")) {
if attr.attribute("name") == Some("category") {
if let Some(val) = attr.attribute("value") {
if let Ok(cat) = val.parse::<u32>() {
categories.push(cat);
}
}
}
}
Ok(SearchResult {
guid,
title,
download_url,
info_url: get_text("comments"),
size,
publish_date: get_text("pubDate"),
artist: get_attr("artist"),
album: get_attr("album"),
year: get_attr("year").and_then(|s| s.parse().ok()),
label: get_attr("label"),
seeders: get_attr("seeders").and_then(|s| s.parse().ok()),
leechers: get_attr("leechers").and_then(|s| s.parse().ok()),
grabs: get_attr("grabs").and_then(|s| s.parse().ok()),
infohash: get_attr("infohash"),
magnet_url: get_attr("magneturl"),
indexer: self.name.clone(),
categories,
})
}
}
#[async_trait]
impl Indexer for TorznabIndexer {
fn name(&self) -> &str {
&self.name
}
fn supports_music_search(&self) -> bool {
true
}
async fn search(
&self,
criteria: &MusicSearchCriteria,
) -> Result<Vec<SearchResult>, IndexerError> {
let url = self.build_search_url(criteria)?;
let response = self.http.get(url).send().await?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
return Err(IndexerError::RateLimited(retry_after));
}
if !response.status().is_success() {
return Err(IndexerError::Unavailable(format!(
"HTTP {}",
response.status()
)));
}
let xml = response.text().await?;
self.parse_response(&xml)
}
async fn test_connection(&self) -> Result<(), IndexerError> {
let mut url = self.base_url.clone();
url.query_pairs_mut()
.append_pair("t", "caps")
.append_pair("apikey", &self.api_key);
let response = self.http.get(url).send().await?;
if !response.status().is_success() {
return Err(IndexerError::Unavailable(format!(
"HTTP {}",
response.status()
)));
}
let xml = response.text().await?;
if xml.contains("<error") && xml.contains("code=\"1") {
return Err(IndexerError::AuthenticationFailed);
}
Ok(())
}
}
+30
View File
@@ -0,0 +1,30 @@
pub mod api;
pub mod config;
pub mod indexer;
pub mod models;
pub mod services;
pub mod torrent;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct AppServices {
pub aggregator: services::Aggregator,
pub indexer_service: services::IndexerService,
pub torrent_service: services::TorrentService,
}
impl AppServices {
pub fn new(
indexer_service: services::IndexerService,
torrent_service: services::TorrentService,
) -> Self {
Self {
aggregator: services::Aggregator::new(),
indexer_service,
torrent_service,
}
}
}
pub type AppState = Arc<RwLock<AppServices>>;
+51 -10
View File
@@ -1,20 +1,16 @@
mod api;
mod models;
mod services;
mod torrent;
use std::sync::Arc;
use tokio::sync::RwLock;
use axum::Router;
use music_agregator::{
api, config,
services::{IndexerService, TorrentService},
AppServices, AppState,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::services::Aggregator;
pub type AppState = Arc<RwLock<Aggregator>>;
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
@@ -22,7 +18,52 @@ async fn main() {
.with(tracing_subscriber::EnvFilter::from_default_env())
.init();
let state: AppState = Arc::new(RwLock::new(Aggregator::new()));
let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "config.yaml".to_string());
let config = match config::Config::load(&config_path) {
Ok(cfg) => {
tracing::info!("loaded config from {}", config_path);
cfg
}
Err(e) => {
tracing::error!("failed to load config: {}", e);
std::process::exit(1);
}
};
let indexer_service = match IndexerService::from_config(&config.indexers) {
Ok(svc) => {
tracing::info!("initialized {} indexer(s)", config.indexers.len());
svc
}
Err(e) => {
tracing::error!("failed to initialize indexer service: {}", e);
std::process::exit(1);
}
};
let torrent_service = if let Some(qbit_config) = &config.torrent.qbittorrent {
match TorrentService::from_qbittorrent_config(qbit_config).await {
Ok(svc) => {
tracing::info!("connected to qBittorrent at {}", qbit_config.url);
svc
}
Err(e) => {
tracing::warn!(
"failed to connect to qBittorrent: {} (continuing without torrent client)",
e
);
TorrentService::new()
}
}
} else {
tracing::info!("no torrent client configured");
TorrentService::new()
};
let state: AppState = Arc::new(RwLock::new(AppServices::new(
indexer_service,
torrent_service,
)));
let cors = CorsLayer::new()
.allow_origin(Any)
+95
View File
@@ -0,0 +1,95 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::config::IndexerConfig;
use crate::indexer::{Indexer, IndexerError, MusicSearchCriteria, SearchResult, TorznabIndexer};
pub struct IndexerService {
indexers: HashMap<String, Arc<dyn Indexer>>,
}
impl IndexerService {
pub fn new() -> Self {
Self {
indexers: HashMap::new(),
}
}
pub fn from_config(configs: &[IndexerConfig]) -> Result<Self, IndexerError> {
let mut service = Self::new();
for config in configs {
let indexer = TorznabIndexer::new(&config.name, &config.url, &config.api_key)?;
service.add_indexer(Arc::new(indexer));
}
Ok(service)
}
pub fn add_indexer(&mut self, indexer: Arc<dyn Indexer>) {
self.indexers.insert(indexer.name().to_string(), indexer);
}
pub fn get_indexer(&self, name: &str) -> Option<Arc<dyn Indexer>> {
self.indexers.get(name).cloned()
}
pub fn list_indexers(&self) -> Vec<IndexerInfo> {
self.indexers
.values()
.map(|i| IndexerInfo {
name: i.name().to_string(),
supports_music: i.supports_music_search(),
})
.collect()
}
pub async fn search(
&self,
criteria: &MusicSearchCriteria,
indexer_name: Option<&str>,
) -> Result<Vec<SearchResult>, IndexerError> {
match indexer_name {
Some(name) => {
let indexer = self.indexers.get(name).ok_or_else(|| {
IndexerError::Unavailable(format!("indexer not found: {}", name))
})?;
indexer.search(criteria).await
}
None => {
let mut all_results = Vec::new();
for indexer in self.indexers.values() {
if indexer.supports_music_search() {
match indexer.search(criteria).await {
Ok(results) => all_results.extend(results),
Err(e) => {
tracing::warn!("indexer {} failed: {}", indexer.name(), e);
}
}
}
}
Ok(all_results)
}
}
}
pub async fn test_indexer(&self, name: &str) -> Result<(), IndexerError> {
let indexer = self
.indexers
.get(name)
.ok_or_else(|| IndexerError::Unavailable(format!("indexer not found: {}", name)))?;
indexer.test_connection().await
}
}
impl Default for IndexerService {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct IndexerInfo {
pub name: String,
pub supports_music: bool,
}
+6
View File
@@ -1,3 +1,9 @@
mod indexer_service;
mod torrent_service;
pub use indexer_service::{IndexerInfo, IndexerService};
pub use torrent_service::TorrentService;
use uuid::Uuid;
use crate::models::Track;
+81
View File
@@ -0,0 +1,81 @@
use std::sync::Arc;
use crate::config::QBittorrentConfig;
use crate::torrent::{QBittorrentClient, TorrentClient, TorrentClientError, TorrentInfo};
pub struct TorrentService {
client: Option<Arc<dyn TorrentClient>>,
}
impl TorrentService {
pub fn new() -> Self {
Self { client: None }
}
pub async fn from_qbittorrent_config(
config: &QBittorrentConfig,
) -> Result<Self, TorrentClientError> {
let mut client = QBittorrentClient::new(&config.url, &config.username, &config.password)?;
client.connect().await?;
Ok(Self {
client: Some(Arc::new(client)),
})
}
fn client(&self) -> Result<&Arc<dyn TorrentClient>, TorrentClientError> {
self.client
.as_ref()
.ok_or_else(|| TorrentClientError::ConnectionFailed("no client configured".into()))
}
pub async fn is_connected(&self) -> bool {
self.client.is_some()
}
pub async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.client()?.list_torrents().await
}
pub async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
self.client()?.get_torrent(hash).await
}
pub async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.client()?.add_torrent_url(url, save_path).await
}
pub async fn add_torrent_file(
&self,
data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.client()?.add_torrent_file(data, save_path).await
}
pub async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
self.client()?.remove_torrent(hash, delete_files).await
}
pub async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.client()?.pause_torrent(hash).await
}
pub async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.client()?.resume_torrent(hash).await
}
}
impl Default for TorrentService {
fn default() -> Self {
Self::new()
}
}
+15 -3
View File
@@ -57,11 +57,23 @@ pub trait TorrentClient: Send + Sync {
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>;
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError>;
async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError>;
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError>;
async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError>;
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError>;
async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError>;
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
+24 -21
View File
@@ -29,12 +29,10 @@ struct QBTorrent {
impl QBittorrentClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> {
let base_url = Url::parse(base_url)
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let base_url =
Url::parse(base_url).map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let http = Client::builder()
.cookie_store(true)
.build()?;
let http = Client::builder().cookie_store(true).build()?;
Ok(Self {
base_url,
@@ -99,7 +97,7 @@ impl TorrentClient for QBittorrentClient {
.await?;
let text = resp.text().await?;
if text == "Ok." {
*self.connected.write().await = true;
Ok(())
@@ -109,11 +107,8 @@ impl TorrentClient for QBittorrentClient {
}
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
self.http
.post(self.api_url("/auth/logout"))
.send()
.await?;
self.http.post(self.api_url("/auth/logout")).send().await?;
*self.connected.write().await = false;
Ok(())
}
@@ -121,11 +116,7 @@ impl TorrentClient for QBittorrentClient {
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.ensure_connected().await?;
let resp = self
.http
.get(self.api_url("/torrents/info"))
.send()
.await?;
let resp = self.http.get(self.api_url("/torrents/info")).send().await?;
let torrents: Vec<QBTorrent> = resp.json().await?;
Ok(torrents.into_iter().map(Self::map_torrent).collect())
@@ -149,11 +140,15 @@ impl TorrentClient for QBittorrentClient {
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
}
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError> {
async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let mut form = multipart::Form::new().text("urls", url.to_string());
if let Some(path) = save_path {
form = form.text("savepath", path.to_string());
}
@@ -174,7 +169,11 @@ impl TorrentClient for QBittorrentClient {
}
}
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError> {
async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let part = multipart::Part::bytes(torrent_data.to_vec())
@@ -183,7 +182,7 @@ impl TorrentClient for QBittorrentClient {
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let mut form = multipart::Form::new().part("torrents", part);
if let Some(path) = save_path {
form = form.text("savepath", path.to_string());
}
@@ -204,7 +203,11 @@ impl TorrentClient for QBittorrentClient {
}
}
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError> {
async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?;
let resp = self