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:
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user