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