feat: add artist sync flow and stub torrent client

- Add DownloadService to orchestrate metadata → indexer → torrent flow
- Add POST /api/sync/artist endpoint for syncing artist albums
- Add StubTorrentClient for testing (logs requests to file)
- Refactor TorrentConfig to tagged enum (client_type: qbittorrent|stub|none)
- Add POST /api/reload endpoint for hot config reload
- Add chrono dependency for timestamps
This commit is contained in:
Alexander
2026-04-28 21:40:11 +02:00
parent 925c7c3703
commit 3aaeade4d3
13 changed files with 697 additions and 37 deletions
+180
View File
@@ -0,0 +1,180 @@
use serde::Serialize;
use crate::indexer::SearchResult;
use super::{IndexerService, MetadataService, TorrentService};
#[derive(Debug, Serialize)]
pub struct AlbumDownloadResult {
pub album_id: String,
pub album_title: String,
pub artist_name: String,
pub status: DownloadStatus,
pub torrent_hash: Option<String>,
pub indexer: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DownloadStatus {
Added,
NoResults,
Failed,
Skipped,
}
#[derive(Debug, Serialize)]
pub struct ArtistSyncResult {
pub artist_id: String,
pub artist_name: String,
pub total_albums: usize,
pub albums_added: usize,
pub albums_failed: usize,
pub albums_no_results: usize,
pub results: Vec<AlbumDownloadResult>,
}
pub struct DownloadService;
impl DownloadService {
pub async fn sync_artist(
artist_name: &str,
metadata: &MetadataService,
indexers: &IndexerService,
torrent: &TorrentService,
) -> Result<ArtistSyncResult, String> {
let search_result = metadata
.search_artists(artist_name, Some(1), None)
.await
.map_err(|e| format!("metadata search failed: {}", e))?;
let artist = search_result
.artists
.first()
.ok_or_else(|| format!("artist '{}' not found", artist_name))?;
let albums_response = metadata
.get_artist_albums(&artist.id, Some(100), None)
.await
.map_err(|e| format!("failed to get albums: {}", e))?;
let mut results = Vec::new();
let mut albums_added = 0;
let mut albums_failed = 0;
let mut albums_no_results = 0;
for album in &albums_response.albums {
let result = Self::download_album(
&artist.name,
&album.id,
&album.title,
album
.release_date
.split('-')
.next()
.and_then(|y| y.parse().ok()),
indexers,
torrent,
)
.await;
match result.status {
DownloadStatus::Added => albums_added += 1,
DownloadStatus::NoResults => albums_no_results += 1,
DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1,
}
results.push(result);
}
Ok(ArtistSyncResult {
artist_id: artist.id.clone(),
artist_name: artist.name.clone(),
total_albums: albums_response.albums.len(),
albums_added,
albums_failed,
albums_no_results,
results,
})
}
async fn download_album(
artist_name: &str,
album_id: &str,
album_title: &str,
year: Option<u32>,
indexers: &IndexerService,
torrent: &TorrentService,
) -> AlbumDownloadResult {
let criteria = crate::indexer::MusicSearchCriteria {
artist: artist_name.to_string(),
album: Some(album_title.to_string()),
year,
limit: 20,
offset: 0,
};
let search_results = match indexers.search(&criteria, None).await {
Ok(r) => r,
Err(e) => {
return AlbumDownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::Failed,
torrent_hash: None,
indexer: None,
error: Some(format!("indexer search failed: {}", e)),
};
}
};
if search_results.is_empty() {
return AlbumDownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::NoResults,
torrent_hash: None,
indexer: None,
error: None,
};
}
let best = Self::select_best_result(&search_results);
match torrent.add_torrent_url(&best.download_url, None).await {
Ok(()) => AlbumDownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::Added,
torrent_hash: best.infohash.clone(),
indexer: Some(best.indexer.clone()),
error: None,
},
Err(e) => AlbumDownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::Failed,
torrent_hash: None,
indexer: Some(best.indexer.clone()),
error: Some(format!("failed to add torrent: {}", e)),
},
}
}
fn select_best_result(results: &[SearchResult]) -> &SearchResult {
results
.iter()
.max_by_key(|r| {
let seeders = r.seeders.unwrap_or(0);
let is_flac = r.title.to_lowercase().contains("flac");
let score = seeders as i64 + if is_flac { 1000 } else { 0 };
score
})
.unwrap()
}
}