refactor: rewrite project from Rust to Go
- Replace Axum with Chi router - Replace sqlx with pgx for PostgreSQL - Replace tonic/prost with grpc-go - Replace tracing with zerolog - Update flake.nix for Go build with protoc generation - Preserve all existing endpoints and functionality Stack: Chi, pgx, grpc-go, zerolog, yaml.v3
This commit is contained in:
@@ -1,92 +0,0 @@
|
||||
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(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::services::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/artists", get(list_artists))
|
||||
.route("/artists/{id}/albums", get(list_artist_albums))
|
||||
.route("/albums", get(list_albums))
|
||||
.route("/stats", get(library_stats))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ArtistsResponse {
|
||||
artists: Vec<ArtistMetadataRow>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
async fn list_artists(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ArtistsResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
|
||||
let db = state.db_service.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"database not connected".to_string(),
|
||||
))?;
|
||||
|
||||
let artists = db
|
||||
.list_artists()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = artists.len();
|
||||
Ok(Json(ArtistsResponse { artists, total }))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ArtistAlbumsResponse {
|
||||
albums: Vec<AlbumRow>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
async fn list_artist_albums(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ArtistAlbumsResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
|
||||
let db = state.db_service.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"database not connected".to_string(),
|
||||
))?;
|
||||
|
||||
let albums = db
|
||||
.list_albums_by_artist(id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = albums.len();
|
||||
Ok(Json(ArtistAlbumsResponse { albums, total }))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AlbumsResponse {
|
||||
albums: Vec<AlbumWithArtistRow>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
async fn list_albums(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<AlbumsResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
|
||||
let db = state.db_service.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"database not connected".to_string(),
|
||||
))?;
|
||||
|
||||
let albums = db
|
||||
.list_all_albums()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = albums.len();
|
||||
Ok(Json(AlbumsResponse { albums, total }))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LibraryStats {
|
||||
artists: i64,
|
||||
albums: i64,
|
||||
}
|
||||
|
||||
async fn library_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<LibraryStats>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
|
||||
let db = state.db_service.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"database not connected".to_string(),
|
||||
))?;
|
||||
|
||||
let artists = db
|
||||
.count_artists()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let albums = db
|
||||
.count_albums()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(LibraryStats { artists, albums }))
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::metadata::proto::{Album, Artist, ArtistCredit, ExternalId, Genre, Label, Track, Work};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/artists/search", get(search_artists))
|
||||
.route("/artists/{id}", get(get_artist))
|
||||
.route("/artists/{id}/albums", get(get_artist_albums))
|
||||
.route("/artists/sync", post(sync_artist))
|
||||
.route("/albums/{id}", get(get_album))
|
||||
.route("/albums/{id}/tracks", get(get_album_tracks))
|
||||
.route("/status", get(connection_status))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: String,
|
||||
pub limit: Option<i32>,
|
||||
pub offset: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ArtistResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub sort_name: String,
|
||||
pub artist_type: String,
|
||||
pub country: String,
|
||||
pub formed_date: String,
|
||||
pub disbanded_date: String,
|
||||
pub description: String,
|
||||
pub image_url: String,
|
||||
pub genres: Vec<GenreResponse>,
|
||||
pub external_ids: Vec<ExternalIdResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenreResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExternalIdResponse {
|
||||
pub source: String,
|
||||
pub source_id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AlbumResponse {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub album_type: String,
|
||||
pub release_date: String,
|
||||
pub upc: String,
|
||||
pub total_tracks: i32,
|
||||
pub total_discs: i32,
|
||||
pub cover_url: String,
|
||||
pub artists: Vec<ArtistCreditResponse>,
|
||||
pub label: Option<LabelResponse>,
|
||||
pub genres: Vec<GenreResponse>,
|
||||
pub external_ids: Vec<ExternalIdResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ArtistCreditResponse {
|
||||
pub artist: Option<ArtistResponse>,
|
||||
pub role: String,
|
||||
pub position: i32,
|
||||
pub join_phrase: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LabelResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub country: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrackResponse {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub duration_ms: i32,
|
||||
pub isrc: String,
|
||||
pub explicit: bool,
|
||||
pub disc_number: i32,
|
||||
pub track_number: i32,
|
||||
pub artists: Vec<ArtistCreditResponse>,
|
||||
pub work: Option<WorkResponse>,
|
||||
pub external_ids: Vec<ExternalIdResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WorkResponse {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub work_type: String,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchArtistsResponse {
|
||||
pub artists: Vec<ArtistResponse>,
|
||||
pub total: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ArtistAlbumsResponse {
|
||||
pub albums: Vec<AlbumResponse>,
|
||||
pub total: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AlbumTracksResponse {
|
||||
pub tracks: Vec<TrackResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SyncResponse {
|
||||
pub artist: Option<ArtistResponse>,
|
||||
pub albums_synced: i32,
|
||||
pub tracks_synced: i32,
|
||||
}
|
||||
|
||||
fn map_genre(g: &Genre) -> GenreResponse {
|
||||
GenreResponse {
|
||||
id: g.id.clone(),
|
||||
name: g.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_external_id(e: &ExternalId) -> ExternalIdResponse {
|
||||
ExternalIdResponse {
|
||||
source: e.source.clone(),
|
||||
source_id: e.source_id.clone(),
|
||||
url: e.url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_artist(a: &Artist) -> ArtistResponse {
|
||||
ArtistResponse {
|
||||
id: a.id.clone(),
|
||||
name: a.name.clone(),
|
||||
sort_name: a.sort_name.clone(),
|
||||
artist_type: a.artist_type.clone(),
|
||||
country: a.country.clone(),
|
||||
formed_date: a.formed_date.clone(),
|
||||
disbanded_date: a.disbanded_date.clone(),
|
||||
description: a.description.clone(),
|
||||
image_url: a.image_url.clone(),
|
||||
genres: a.genres.iter().map(map_genre).collect(),
|
||||
external_ids: a.external_ids.iter().map(map_external_id).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_label(l: &Label) -> LabelResponse {
|
||||
LabelResponse {
|
||||
id: l.id.clone(),
|
||||
name: l.name.clone(),
|
||||
country: l.country.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_artist_credit(c: &ArtistCredit) -> ArtistCreditResponse {
|
||||
ArtistCreditResponse {
|
||||
artist: c.artist.as_ref().map(map_artist),
|
||||
role: c.role.clone(),
|
||||
position: c.position,
|
||||
join_phrase: c.join_phrase.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_album(a: &Album) -> AlbumResponse {
|
||||
AlbumResponse {
|
||||
id: a.id.clone(),
|
||||
title: a.title.clone(),
|
||||
album_type: a.album_type.clone(),
|
||||
release_date: a.release_date.clone(),
|
||||
upc: a.upc.clone(),
|
||||
total_tracks: a.total_tracks,
|
||||
total_discs: a.total_discs,
|
||||
cover_url: a.cover_url.clone(),
|
||||
artists: a.artists.iter().map(map_artist_credit).collect(),
|
||||
label: a.label.as_ref().map(map_label),
|
||||
genres: a.genres.iter().map(map_genre).collect(),
|
||||
external_ids: a.external_ids.iter().map(map_external_id).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_work(w: &Work) -> WorkResponse {
|
||||
WorkResponse {
|
||||
id: w.id.clone(),
|
||||
title: w.title.clone(),
|
||||
work_type: w.work_type.clone(),
|
||||
language: w.language.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_track(t: &Track) -> TrackResponse {
|
||||
TrackResponse {
|
||||
id: t.id.clone(),
|
||||
title: t.title.clone(),
|
||||
duration_ms: t.duration_ms,
|
||||
isrc: t.isrc.clone(),
|
||||
explicit: t.explicit,
|
||||
disc_number: t.disc_number,
|
||||
track_number: t.track_number,
|
||||
artists: t.artists.iter().map(map_artist_credit).collect(),
|
||||
work: t.work.as_ref().map(map_work),
|
||||
external_ids: t.external_ids.iter().map(map_external_id).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_artists(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Result<Json<SearchArtistsResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let response = state
|
||||
.metadata_service
|
||||
.search_artists(&query.q, query.limit, query.offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
Ok(Json(SearchArtistsResponse {
|
||||
artists: response.artists.iter().map(map_artist).collect(),
|
||||
total: response.total,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_artist(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ArtistResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let artist = state
|
||||
.metadata_service
|
||||
.get_artist(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
Ok(Json(map_artist(&artist)))
|
||||
}
|
||||
|
||||
async fn get_artist_albums(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<PaginationQuery>,
|
||||
) -> Result<Json<ArtistAlbumsResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let response = state
|
||||
.metadata_service
|
||||
.get_artist_albums(&id, query.limit, query.offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
Ok(Json(ArtistAlbumsResponse {
|
||||
albums: response.albums.iter().map(map_album).collect(),
|
||||
total: response.total,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PaginationQuery {
|
||||
pub limit: Option<i32>,
|
||||
pub offset: Option<i32>,
|
||||
}
|
||||
|
||||
async fn get_album(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<AlbumResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let album = state
|
||||
.metadata_service
|
||||
.get_album(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
Ok(Json(map_album(&album)))
|
||||
}
|
||||
|
||||
async fn get_album_tracks(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<AlbumTracksResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let response = state
|
||||
.metadata_service
|
||||
.get_album_tracks(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
Ok(Json(AlbumTracksResponse {
|
||||
tracks: response.tracks.iter().map(map_track).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn sync_artist(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SyncRequest>,
|
||||
) -> Result<Json<SyncResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let response = state
|
||||
.metadata_service
|
||||
.sync_artist(&req.name)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
Ok(Json(SyncResponse {
|
||||
artist: response.artist.as_ref().map(map_artist),
|
||||
albums_synced: response.albums_synced,
|
||||
tracks_synced: response.tracks_synced,
|
||||
}))
|
||||
}
|
||||
|
||||
#[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.metadata_service.is_connected(),
|
||||
})
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
mod indexer_controller;
|
||||
mod library_controller;
|
||||
mod metadata_controller;
|
||||
mod sync_controller;
|
||||
mod torrent_controller;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{CreateTrack, Track};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/reload", post(reload))
|
||||
.route("/tracks", get(list_tracks))
|
||||
.route("/tracks", post(create_track))
|
||||
.route("/tracks/{id}", get(get_track))
|
||||
.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())
|
||||
.nest("/metadata", metadata_controller::routes())
|
||||
.nest("/sync", sync_controller::routes())
|
||||
.nest("/library", library_controller::routes())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Health {
|
||||
status: &'static str,
|
||||
services: ServiceStatus,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ServiceStatus {
|
||||
torrent: bool,
|
||||
metadata: bool,
|
||||
indexers: Vec<String>,
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> Json<Health> {
|
||||
let state = state.read().await;
|
||||
let indexers = state
|
||||
.indexer_service
|
||||
.list_indexers()
|
||||
.into_iter()
|
||||
.map(|i| i.name)
|
||||
.collect();
|
||||
Json(Health {
|
||||
status: "ok",
|
||||
services: ServiceStatus {
|
||||
torrent: state.torrent_service.is_connected().await,
|
||||
metadata: state.metadata_service.is_connected(),
|
||||
indexers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ReloadResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn reload(State(state): State<AppState>) -> Json<ReloadResponse> {
|
||||
let mut state = state.write().await;
|
||||
match state.reload().await {
|
||||
Ok(()) => Json(ReloadResponse {
|
||||
success: true,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Json(ReloadResponse {
|
||||
success: false,
|
||||
error: Some(e),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tracks(State(state): State<AppState>) -> Json<Vec<Track>> {
|
||||
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 state = state.write().await;
|
||||
let track = state.aggregator.add_track(input.into());
|
||||
(StatusCode::CREATED, Json(track))
|
||||
}
|
||||
|
||||
async fn get_track(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Track>, StatusCode> {
|
||||
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 state = state.write().await;
|
||||
if state.aggregator.delete(id) {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchQuery {
|
||||
artist: String,
|
||||
}
|
||||
|
||||
async fn search_tracks(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Json<Vec<Track>> {
|
||||
let state = state.read().await;
|
||||
Json(
|
||||
state
|
||||
.aggregator
|
||||
.search_by_artist(&query.artist)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Stats {
|
||||
track_count: usize,
|
||||
total_duration_secs: u32,
|
||||
}
|
||||
|
||||
async fn get_stats(State(state): State<AppState>) -> Json<Stats> {
|
||||
let state = state.read().await;
|
||||
Json(Stats {
|
||||
track_count: state.aggregator.get_all().len(),
|
||||
total_duration_secs: state.aggregator.total_duration(),
|
||||
})
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::services::{DownloadService, SyncOptions, SyncResult};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/", post(sync))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncRequest {
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub download: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub store: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn sync(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SyncRequest>,
|
||||
) -> Result<Json<SyncResult>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
|
||||
let options = SyncOptions {
|
||||
artist: req.artist,
|
||||
album: req.album,
|
||||
download: req.download,
|
||||
store: req.store,
|
||||
};
|
||||
|
||||
let result = DownloadService::sync(
|
||||
options,
|
||||
&state.metadata_service,
|
||||
&state.indexer_service,
|
||||
&state.torrent_service,
|
||||
state.db_service.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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 {
|
||||
#[serde(default)]
|
||||
pub app: AppConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub metadata: MetadataConfig,
|
||||
pub indexers: Vec<IndexerConfig>,
|
||||
pub torrent: TorrentConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
3000
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MetadataConfig {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IndexerType {
|
||||
#[default]
|
||||
Jackett,
|
||||
Prowlarr,
|
||||
Torznab,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IndexerConfig {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub indexer_type: IndexerType,
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "client_type", rename_all = "lowercase")]
|
||||
pub enum TorrentConfig {
|
||||
QBittorrent {
|
||||
url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
Stub {
|
||||
log_path: String,
|
||||
#[serde(default = "default_stub_save_path")]
|
||||
save_path: String,
|
||||
},
|
||||
None,
|
||||
}
|
||||
|
||||
impl Default for TorrentConfig {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stub_save_path() -> String {
|
||||
"/tmp/downloads".to_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)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>,
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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);
|
||||
|
||||
let mut q_parts = vec![criteria.clean_artist()];
|
||||
if let Some(album) = criteria.clean_album() {
|
||||
q_parts.push(album);
|
||||
}
|
||||
if let Some(year) = criteria.year {
|
||||
q_parts.push(year.to_string());
|
||||
}
|
||||
query.append_pair("q", &q_parts.join(" "));
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod indexer;
|
||||
pub mod metadata;
|
||||
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,
|
||||
pub metadata_service: services::MetadataService,
|
||||
pub db_service: Option<services::DbService>,
|
||||
config_path: String,
|
||||
}
|
||||
|
||||
impl AppServices {
|
||||
pub fn new(
|
||||
indexer_service: services::IndexerService,
|
||||
torrent_service: services::TorrentService,
|
||||
metadata_service: services::MetadataService,
|
||||
db_service: Option<services::DbService>,
|
||||
config_path: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
aggregator: services::Aggregator::new(),
|
||||
indexer_service,
|
||||
torrent_service,
|
||||
metadata_service,
|
||||
db_service,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reload(&mut self) -> Result<(), String> {
|
||||
let cfg = config::Config::load(&self.config_path).map_err(|e| e.to_string())?;
|
||||
|
||||
self.indexer_service =
|
||||
services::IndexerService::from_config(&cfg.indexers).map_err(|e| e.to_string())?;
|
||||
|
||||
match services::TorrentService::from_config(&cfg.torrent).await {
|
||||
Ok(svc) => self.torrent_service = svc,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to init torrent client on reload: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let mut metadata = services::MetadataService::new(&cfg.metadata.endpoint);
|
||||
if metadata.connect().await.is_ok() {
|
||||
self.metadata_service = metadata;
|
||||
} else {
|
||||
tracing::warn!("failed to connect to metadata service on reload");
|
||||
}
|
||||
|
||||
tracing::info!("config reloaded from {}", self.config_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppState = Arc<RwLock<AppServices>>;
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use music_agregator::{
|
||||
api, config,
|
||||
services::{DbService, IndexerService, MetadataService, TorrentService},
|
||||
AppServices, AppState,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "music-agregator")]
|
||||
#[command(about = "Music aggregation service with torrent and metadata integration")]
|
||||
struct Args {
|
||||
#[arg(short, long, default_value = "config.yaml")]
|
||||
config: String,
|
||||
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let config = match config::Config::load(&args.config) {
|
||||
Ok(cfg) => {
|
||||
tracing::info!("loaded config from {}", args.config);
|
||||
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 = match TorrentService::from_config(&config.torrent).await {
|
||||
Ok(svc) => {
|
||||
match &config.torrent {
|
||||
config::TorrentConfig::QBittorrent { url, .. } => {
|
||||
tracing::info!("connected to qBittorrent at {}", url);
|
||||
}
|
||||
config::TorrentConfig::Stub { log_path, .. } => {
|
||||
tracing::info!("using stub torrent client, logging to {}", log_path);
|
||||
}
|
||||
config::TorrentConfig::None => {
|
||||
tracing::info!("no torrent client configured");
|
||||
}
|
||||
}
|
||||
svc
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to init torrent client: {} (continuing without)", e);
|
||||
TorrentService::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut metadata_service = MetadataService::new(&config.metadata.endpoint);
|
||||
match metadata_service.connect().await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"connected to metadata service at {}",
|
||||
config.metadata.endpoint
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"failed to connect to metadata service: {} (continuing without metadata)",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let db_service = match DbService::new(&config.database.url).await {
|
||||
Ok(svc) => {
|
||||
tracing::info!("connected to database");
|
||||
Some(svc)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"failed to connect to database: {} (continuing without db)",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let state: AppState = Arc::new(RwLock::new(AppServices::new(
|
||||
indexer_service,
|
||||
torrent_service,
|
||||
metadata_service,
|
||||
db_service,
|
||||
args.config.clone(),
|
||||
)));
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api::routes(state))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let port = args.port.unwrap_or(config.app.port);
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use thiserror::Error;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use super::proto::{
|
||||
get_album_request, get_artist_request, metadata_service_client::MetadataServiceClient,
|
||||
sync_artist_request, Album, Artist, GetAlbumRequest, GetAlbumTracksRequest,
|
||||
GetAlbumTracksResponse, GetArtistAlbumsRequest, GetArtistAlbumsResponse, GetArtistRequest,
|
||||
Provider, SearchArtistsRequest, SearchArtistsResponse, SyncArtistRequest, SyncArtistResponse,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MetadataClientError {
|
||||
#[error("connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("request failed: {0}")]
|
||||
RequestFailed(#[from] tonic::Status),
|
||||
|
||||
#[error("transport error: {0}")]
|
||||
Transport(#[from] tonic::transport::Error),
|
||||
}
|
||||
|
||||
pub struct MetadataClient {
|
||||
client: MetadataServiceClient<Channel>,
|
||||
}
|
||||
|
||||
impl MetadataClient {
|
||||
pub async fn connect(endpoint: &str) -> Result<Self, MetadataClientError> {
|
||||
let client = MetadataServiceClient::connect(endpoint.to_string()).await?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
pub async fn search_artists(
|
||||
&mut self,
|
||||
query: &str,
|
||||
limit: Option<i32>,
|
||||
offset: Option<i32>,
|
||||
) -> Result<SearchArtistsResponse, MetadataClientError> {
|
||||
let request = SearchArtistsRequest {
|
||||
query: query.to_string(),
|
||||
limit: limit.unwrap_or(20),
|
||||
offset: offset.unwrap_or(0),
|
||||
provider: Provider::Unspecified as i32,
|
||||
};
|
||||
|
||||
let response = self.client.search_artists(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_artist(&mut self, id: &str) -> Result<Artist, MetadataClientError> {
|
||||
let request = GetArtistRequest {
|
||||
identifier: Some(get_artist_request::Identifier::Id(id.to_string())),
|
||||
provider: Provider::Unspecified as i32,
|
||||
};
|
||||
|
||||
let response = self.client.get_artist(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_artist_albums(
|
||||
&mut self,
|
||||
artist_id: &str,
|
||||
limit: Option<i32>,
|
||||
offset: Option<i32>,
|
||||
) -> Result<GetArtistAlbumsResponse, MetadataClientError> {
|
||||
let request = GetArtistAlbumsRequest {
|
||||
artist_id: artist_id.to_string(),
|
||||
limit: limit.unwrap_or(50),
|
||||
offset: offset.unwrap_or(0),
|
||||
provider: Provider::Unspecified as i32,
|
||||
};
|
||||
|
||||
let response = self.client.get_artist_albums(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_album(&mut self, id: &str) -> Result<Album, MetadataClientError> {
|
||||
let request = GetAlbumRequest {
|
||||
identifier: Some(get_album_request::Identifier::Id(id.to_string())),
|
||||
provider: Provider::Unspecified as i32,
|
||||
};
|
||||
|
||||
let response = self.client.get_album(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_album_tracks(
|
||||
&mut self,
|
||||
album_id: &str,
|
||||
) -> Result<GetAlbumTracksResponse, MetadataClientError> {
|
||||
let request = GetAlbumTracksRequest {
|
||||
album_id: album_id.to_string(),
|
||||
provider: Provider::Unspecified as i32,
|
||||
};
|
||||
|
||||
let response = self.client.get_album_tracks(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn sync_artist(
|
||||
&mut self,
|
||||
name: &str,
|
||||
) -> Result<SyncArtistResponse, MetadataClientError> {
|
||||
let request = SyncArtistRequest {
|
||||
target: Some(sync_artist_request::Target::Name(name.to_string())),
|
||||
provider: Provider::Musicbrainz as i32,
|
||||
};
|
||||
|
||||
let response = self.client.sync_artist(request).await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mod client;
|
||||
|
||||
pub use client::{MetadataClient, MetadataClientError};
|
||||
|
||||
pub mod proto {
|
||||
tonic::include_proto!("metadata.v1");
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Track {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration_secs: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTrack {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration_secs: u32,
|
||||
}
|
||||
|
||||
impl From<CreateTrack> for Track {
|
||||
fn from(input: CreateTrack) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
title: input.title,
|
||||
artist: input.artist,
|
||||
album: input.album,
|
||||
duration_secs: input.duration_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
use sqlx::{postgres::PgPoolOptions, FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::metadata::proto::{Album, Artist};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DbService {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl DbService {
|
||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn upsert_artist_metadata(&self, artist: &Artist) -> Result<Uuid, sqlx::Error> {
|
||||
let id = Uuid::parse_str(&artist.id).unwrap_or_else(|_| Uuid::new_v4());
|
||||
let genres: serde_json::Value = serde_json::json!(artist
|
||||
.genres
|
||||
.iter()
|
||||
.map(|g| serde_json::json!({"id": g.id, "name": g.name}))
|
||||
.collect::<Vec<_>>());
|
||||
let links: serde_json::Value = serde_json::json!(artist
|
||||
.external_ids
|
||||
.iter()
|
||||
.map(
|
||||
|e| serde_json::json!({"source": e.source, "source_id": e.source_id, "url": e.url})
|
||||
)
|
||||
.collect::<Vec<_>>());
|
||||
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO artist_metadata (
|
||||
id, foreign_artist_id, name, sort_name, disambiguation,
|
||||
artist_type, status, overview, genres, links, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
||||
ON CONFLICT (foreign_artist_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
sort_name = EXCLUDED.sort_name,
|
||||
artist_type = EXCLUDED.artist_type,
|
||||
overview = EXCLUDED.overview,
|
||||
genres = EXCLUDED.genres,
|
||||
links = EXCLUDED.links,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&artist.id)
|
||||
.bind(&artist.name)
|
||||
.bind(&artist.sort_name)
|
||||
.bind(&artist.description)
|
||||
.bind(&artist.artist_type)
|
||||
.bind("active")
|
||||
.bind(&artist.description)
|
||||
.bind(&genres)
|
||||
.bind(&links)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn upsert_album(
|
||||
&self,
|
||||
album: &Album,
|
||||
artist_metadata_id: Uuid,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let id = Uuid::parse_str(&album.id).unwrap_or_else(|_| Uuid::new_v4());
|
||||
let genres: serde_json::Value = serde_json::json!(album
|
||||
.genres
|
||||
.iter()
|
||||
.map(|g| serde_json::json!({"id": g.id, "name": g.name}))
|
||||
.collect::<Vec<_>>());
|
||||
let images: serde_json::Value = serde_json::json!([]);
|
||||
let release_date = chrono::NaiveDate::parse_from_str(&album.release_date, "%Y-%m-%d").ok();
|
||||
let clean_title = album
|
||||
.title
|
||||
.to_lowercase()
|
||||
.replace(|c: char| !c.is_alphanumeric(), "");
|
||||
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO albums (
|
||||
id, artist_metadata_id, foreign_album_id, title, clean_title,
|
||||
overview, album_type, release_date, images, genres
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (foreign_album_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
album_type = EXCLUDED.album_type,
|
||||
release_date = EXCLUDED.release_date,
|
||||
genres = EXCLUDED.genres
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(artist_metadata_id)
|
||||
.bind(&album.id)
|
||||
.bind(&album.title)
|
||||
.bind(&clean_title)
|
||||
.bind("")
|
||||
.bind(&album.album_type)
|
||||
.bind(release_date)
|
||||
.bind(&images)
|
||||
.bind(&genres)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn list_artists(&self) -> Result<Vec<ArtistMetadataRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
|
||||
FROM artist_metadata
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_albums_by_artist(
|
||||
&self,
|
||||
artist_metadata_id: Uuid,
|
||||
) -> Result<Vec<AlbumRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
|
||||
FROM albums
|
||||
WHERE artist_metadata_id = $1
|
||||
ORDER BY release_date DESC NULLS LAST
|
||||
"#,
|
||||
)
|
||||
.bind(artist_metadata_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_all_albums(&self) -> Result<Vec<AlbumWithArtistRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at,
|
||||
am.id as artist_id, am.name as artist_name
|
||||
FROM albums a
|
||||
JOIN artist_metadata am ON a.artist_metadata_id = am.id
|
||||
ORDER BY a.added_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_artists(&self) -> Result<i64, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM artist_metadata")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn count_albums(&self) -> Result<i64, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM albums")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, FromRow)]
|
||||
pub struct ArtistMetadataRow {
|
||||
pub id: Uuid,
|
||||
pub foreign_artist_id: Option<String>,
|
||||
pub name: String,
|
||||
pub sort_name: Option<String>,
|
||||
pub artist_type: Option<String>,
|
||||
pub genres: Option<serde_json::Value>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, FromRow)]
|
||||
pub struct AlbumRow {
|
||||
pub id: Uuid,
|
||||
pub artist_metadata_id: Uuid,
|
||||
pub foreign_album_id: Option<String>,
|
||||
pub title: String,
|
||||
pub album_type: Option<String>,
|
||||
pub release_date: Option<chrono::NaiveDate>,
|
||||
pub monitored: bool,
|
||||
pub added_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, FromRow)]
|
||||
pub struct AlbumWithArtistRow {
|
||||
pub id: Uuid,
|
||||
pub foreign_album_id: Option<String>,
|
||||
pub title: String,
|
||||
pub album_type: Option<String>,
|
||||
pub release_date: Option<chrono::NaiveDate>,
|
||||
pub monitored: bool,
|
||||
pub added_at: chrono::DateTime<chrono::Utc>,
|
||||
pub artist_id: Uuid,
|
||||
pub artist_name: String,
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::indexer::SearchResult;
|
||||
|
||||
use super::{DbService, IndexerService, MetadataService, TorrentService};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncOptions {
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub download: bool,
|
||||
pub store: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SyncResult {
|
||||
pub artist_id: String,
|
||||
pub artist_name: String,
|
||||
pub total_albums: usize,
|
||||
pub albums_stored: usize,
|
||||
pub albums_downloaded: usize,
|
||||
pub albums_no_results: usize,
|
||||
pub albums_failed: usize,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub results: Vec<AlbumSyncResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AlbumSyncResult {
|
||||
pub album_id: String,
|
||||
pub album_title: String,
|
||||
pub stored: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub download_status: Option<DownloadStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub torrent_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indexer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DownloadStatus {
|
||||
Added,
|
||||
NoResults,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
struct DownloadResult {
|
||||
status: DownloadStatus,
|
||||
torrent_hash: Option<String>,
|
||||
indexer: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DownloadService;
|
||||
|
||||
impl DownloadService {
|
||||
pub async fn sync(
|
||||
options: SyncOptions,
|
||||
metadata: &MetadataService,
|
||||
indexers: &IndexerService,
|
||||
torrent: &TorrentService,
|
||||
db: Option<&DbService>,
|
||||
) -> Result<SyncResult, String> {
|
||||
let search_result = metadata
|
||||
.search_artists(&options.artist, 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", options.artist))?;
|
||||
|
||||
let artist_metadata_id = if options.store {
|
||||
if let Some(db) = db {
|
||||
match db.upsert_artist_metadata(artist).await {
|
||||
Ok(id) => {
|
||||
tracing::info!("stored artist metadata: {} ({})", artist.name, id);
|
||||
Some(id)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to store artist metadata: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let albums_response = metadata
|
||||
.get_artist_albums(&artist.id, Some(500), None)
|
||||
.await
|
||||
.map_err(|e| format!("failed to get albums: {}", e))?;
|
||||
|
||||
let albums_to_process: Vec<_> = if let Some(ref album_filter) = options.album {
|
||||
let filter_lower = album_filter.to_lowercase();
|
||||
albums_response
|
||||
.albums
|
||||
.iter()
|
||||
.filter(|a| a.title.to_lowercase().contains(&filter_lower))
|
||||
.collect()
|
||||
} else {
|
||||
albums_response.albums.iter().collect()
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut albums_stored = 0;
|
||||
let mut albums_downloaded = 0;
|
||||
let mut albums_no_results = 0;
|
||||
let mut albums_failed = 0;
|
||||
|
||||
for album in albums_to_process.iter() {
|
||||
let stored = if options.store {
|
||||
if let (Some(db), Some(artist_id)) = (db, artist_metadata_id) {
|
||||
match db.upsert_album(album, artist_id).await {
|
||||
Ok(_) => {
|
||||
albums_stored += 1;
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to store album {}: {}", album.title, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let (download_status, torrent_hash, indexer, error) = if options.download {
|
||||
let year = album
|
||||
.release_date
|
||||
.split('-')
|
||||
.next()
|
||||
.and_then(|y| y.parse().ok());
|
||||
|
||||
let dl_result =
|
||||
Self::download_album(&artist.name, &album.title, year, indexers, torrent).await;
|
||||
|
||||
match dl_result.status {
|
||||
DownloadStatus::Added => albums_downloaded += 1,
|
||||
DownloadStatus::NoResults => albums_no_results += 1,
|
||||
DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1,
|
||||
}
|
||||
|
||||
(
|
||||
Some(dl_result.status),
|
||||
dl_result.torrent_hash,
|
||||
dl_result.indexer,
|
||||
dl_result.error,
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
results.push(AlbumSyncResult {
|
||||
album_id: album.id.clone(),
|
||||
album_title: album.title.clone(),
|
||||
stored,
|
||||
download_status,
|
||||
torrent_hash,
|
||||
indexer,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SyncResult {
|
||||
artist_id: artist.id.clone(),
|
||||
artist_name: artist.name.clone(),
|
||||
total_albums: albums_to_process.len(),
|
||||
albums_stored,
|
||||
albums_downloaded,
|
||||
albums_no_results,
|
||||
albums_failed,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_album(
|
||||
artist_name: &str,
|
||||
album_title: &str,
|
||||
year: Option<u32>,
|
||||
indexers: &IndexerService,
|
||||
torrent: &TorrentService,
|
||||
) -> DownloadResult {
|
||||
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 DownloadResult {
|
||||
status: DownloadStatus::Failed,
|
||||
torrent_hash: None,
|
||||
indexer: None,
|
||||
error: Some(format!("indexer search failed: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if search_results.is_empty() {
|
||||
return DownloadResult {
|
||||
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(()) => DownloadResult {
|
||||
status: DownloadStatus::Added,
|
||||
torrent_hash: best.infohash.clone(),
|
||||
indexer: Some(best.indexer.clone()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => DownloadResult {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::{IndexerConfig, IndexerType};
|
||||
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 torznab_url = Self::build_torznab_url(&config.url, config.indexer_type);
|
||||
let indexer = TorznabIndexer::new(&config.name, &torznab_url, &config.api_key)?;
|
||||
service.add_indexer(Arc::new(indexer));
|
||||
}
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
fn build_torznab_url(base_url: &str, indexer_type: IndexerType) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
match indexer_type {
|
||||
IndexerType::Jackett => format!("{}/api/v2.0/indexers/all/results/torznab/", base),
|
||||
IndexerType::Prowlarr => format!("{}/api/v1/indexer/all/torznab", base),
|
||||
IndexerType::Torznab => base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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,92 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::metadata::{MetadataClient, MetadataClientError};
|
||||
|
||||
pub struct MetadataService {
|
||||
client: Option<Arc<Mutex<MetadataClient>>>,
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
impl MetadataService {
|
||||
pub fn new(endpoint: &str) -> Self {
|
||||
Self {
|
||||
client: None,
|
||||
endpoint: endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self) -> Result<(), MetadataClientError> {
|
||||
let client = MetadataClient::connect(&self.endpoint).await?;
|
||||
self.client = Some(Arc::new(Mutex::new(client)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.client.is_some()
|
||||
}
|
||||
|
||||
fn client(&self) -> Result<Arc<Mutex<MetadataClient>>, MetadataClientError> {
|
||||
self.client
|
||||
.clone()
|
||||
.ok_or_else(|| MetadataClientError::ConnectionFailed("not connected".into()))
|
||||
}
|
||||
|
||||
pub async fn search_artists(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: Option<i32>,
|
||||
offset: Option<i32>,
|
||||
) -> Result<crate::metadata::proto::SearchArtistsResponse, MetadataClientError> {
|
||||
let client = self.client()?;
|
||||
let mut guard = client.lock().await;
|
||||
guard.search_artists(query, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn get_artist(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<crate::metadata::proto::Artist, MetadataClientError> {
|
||||
let client = self.client()?;
|
||||
let mut guard = client.lock().await;
|
||||
guard.get_artist(id).await
|
||||
}
|
||||
|
||||
pub async fn get_artist_albums(
|
||||
&self,
|
||||
artist_id: &str,
|
||||
limit: Option<i32>,
|
||||
offset: Option<i32>,
|
||||
) -> Result<crate::metadata::proto::GetArtistAlbumsResponse, MetadataClientError> {
|
||||
let client = self.client()?;
|
||||
let mut guard = client.lock().await;
|
||||
guard.get_artist_albums(artist_id, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn get_album(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<crate::metadata::proto::Album, MetadataClientError> {
|
||||
let client = self.client()?;
|
||||
let mut guard = client.lock().await;
|
||||
guard.get_album(id).await
|
||||
}
|
||||
|
||||
pub async fn get_album_tracks(
|
||||
&self,
|
||||
album_id: &str,
|
||||
) -> Result<crate::metadata::proto::GetAlbumTracksResponse, MetadataClientError> {
|
||||
let client = self.client()?;
|
||||
let mut guard = client.lock().await;
|
||||
guard.get_album_tracks(album_id).await
|
||||
}
|
||||
|
||||
pub async fn sync_artist(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<crate::metadata::proto::SyncArtistResponse, MetadataClientError> {
|
||||
let client = self.client()?;
|
||||
let mut guard = client.lock().await;
|
||||
guard.sync_artist(name).await
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
mod db_service;
|
||||
mod download_service;
|
||||
mod indexer_service;
|
||||
mod metadata_service;
|
||||
mod torrent_service;
|
||||
|
||||
pub use db_service::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow, DbService};
|
||||
pub use download_service::{DownloadService, DownloadStatus, SyncOptions, SyncResult};
|
||||
pub use indexer_service::{IndexerInfo, IndexerService};
|
||||
pub use metadata_service::MetadataService;
|
||||
pub use torrent_service::TorrentService;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::Track;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Aggregator {
|
||||
tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
impl Aggregator {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_track(&mut self, track: Track) -> Track {
|
||||
self.tracks.push(track.clone());
|
||||
track
|
||||
}
|
||||
|
||||
pub fn get_all(&self) -> &[Track] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
pub fn get_by_id(&self, id: Uuid) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.id == id)
|
||||
}
|
||||
|
||||
pub fn search_by_artist(&self, artist: &str) -> Vec<&Track> {
|
||||
let artist_lower = artist.to_lowercase();
|
||||
self.tracks
|
||||
.iter()
|
||||
.filter(|t| t.artist.to_lowercase().contains(&artist_lower))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn delete(&mut self, id: Uuid) -> bool {
|
||||
let len_before = self.tracks.len();
|
||||
self.tracks.retain(|t| t.id != id);
|
||||
self.tracks.len() != len_before
|
||||
}
|
||||
|
||||
pub fn total_duration(&self) -> u32 {
|
||||
self.tracks.iter().map(|t| t.duration_secs).sum()
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::TorrentConfig;
|
||||
use crate::torrent::{
|
||||
QBittorrentClient, StubTorrentClient, TorrentClient, TorrentClientError, TorrentInfo,
|
||||
};
|
||||
|
||||
pub struct TorrentService {
|
||||
client: Option<Arc<dyn TorrentClient>>,
|
||||
}
|
||||
|
||||
impl TorrentService {
|
||||
pub fn new() -> Self {
|
||||
Self { client: None }
|
||||
}
|
||||
|
||||
pub async fn from_config(config: &TorrentConfig) -> Result<Self, TorrentClientError> {
|
||||
match config {
|
||||
TorrentConfig::QBittorrent {
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
} => {
|
||||
let mut client = QBittorrentClient::new(url, username, password)?;
|
||||
client.connect().await?;
|
||||
Ok(Self {
|
||||
client: Some(Arc::new(client)),
|
||||
})
|
||||
}
|
||||
TorrentConfig::Stub {
|
||||
log_path,
|
||||
save_path,
|
||||
} => {
|
||||
let mut client = StubTorrentClient::new(log_path, save_path);
|
||||
client.connect().await?;
|
||||
Ok(Self {
|
||||
client: Some(Arc::new(client)),
|
||||
})
|
||||
}
|
||||
TorrentConfig::None => Ok(Self::new()),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TorrentClientError {
|
||||
#[error("authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
#[error("connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("torrent not found: {0}")]
|
||||
TorrentNotFound(String),
|
||||
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("unexpected error: {0}")]
|
||||
Unexpected(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TorrentState {
|
||||
Downloading,
|
||||
Seeding,
|
||||
Paused,
|
||||
Queued,
|
||||
Checking,
|
||||
Error,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TorrentInfo {
|
||||
pub hash: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub progress: f64,
|
||||
pub download_speed: u64,
|
||||
pub upload_speed: u64,
|
||||
pub state: TorrentState,
|
||||
pub save_path: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TorrentClient: Send + Sync {
|
||||
async fn connect(&mut self) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError>;
|
||||
|
||||
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_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 pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mod client;
|
||||
mod qbittorrent;
|
||||
mod stub;
|
||||
|
||||
pub use client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
|
||||
pub use qbittorrent::QBittorrentClient;
|
||||
pub use stub::StubTorrentClient;
|
||||
@@ -1,253 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{multipart, Client};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use url::Url;
|
||||
|
||||
use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
|
||||
|
||||
pub struct QBittorrentClient {
|
||||
base_url: Url,
|
||||
username: String,
|
||||
password: String,
|
||||
http: Client,
|
||||
connected: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QBTorrent {
|
||||
hash: String,
|
||||
name: String,
|
||||
size: i64,
|
||||
progress: f64,
|
||||
dlspeed: i64,
|
||||
upspeed: i64,
|
||||
state: String,
|
||||
save_path: String,
|
||||
}
|
||||
|
||||
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 http = Client::builder().cookie_store(true).build()?;
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
http,
|
||||
connected: Arc::new(RwLock::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
format!("{}api/v2{}", self.base_url, path)
|
||||
}
|
||||
|
||||
fn map_state(state: &str) -> TorrentState {
|
||||
match state {
|
||||
"downloading" | "forcedDL" | "metaDL" | "allocating" => TorrentState::Downloading,
|
||||
"uploading" | "forcedUP" | "stalledUP" => TorrentState::Seeding,
|
||||
"pausedDL" | "pausedUP" => TorrentState::Paused,
|
||||
"queuedDL" | "queuedUP" => TorrentState::Queued,
|
||||
"checkingDL" | "checkingUP" | "checkingResumeData" => TorrentState::Checking,
|
||||
"error" | "missingFiles" => TorrentState::Error,
|
||||
_ => TorrentState::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_torrent(t: QBTorrent) -> TorrentInfo {
|
||||
TorrentInfo {
|
||||
hash: t.hash,
|
||||
name: t.name,
|
||||
size: t.size.max(0) as u64,
|
||||
progress: t.progress,
|
||||
download_speed: t.dlspeed.max(0) as u64,
|
||||
upload_speed: t.upspeed.max(0) as u64,
|
||||
state: Self::map_state(&t.state),
|
||||
save_path: t.save_path,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_connected(&self) -> Result<(), TorrentClientError> {
|
||||
let connected = *self.connected.read().await;
|
||||
if !connected {
|
||||
return Err(TorrentClientError::ConnectionFailed("not connected".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TorrentClient for QBittorrentClient {
|
||||
async fn connect(&mut self) -> Result<(), TorrentClientError> {
|
||||
let params = [
|
||||
("username", self.username.as_str()),
|
||||
("password", self.password.as_str()),
|
||||
];
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/auth/login"))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let text = resp.text().await?;
|
||||
|
||||
if text == "Ok." {
|
||||
*self.connected.write().await = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::AuthenticationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
|
||||
self.http.post(self.api_url("/auth/logout")).send().await?;
|
||||
|
||||
*self.connected.write().await = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 torrents: Vec<QBTorrent> = resp.json().await?;
|
||||
Ok(torrents.into_iter().map(Self::map_torrent).collect())
|
||||
}
|
||||
|
||||
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.api_url("/torrents/info"))
|
||||
.query(&[("hashes", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let torrents: Vec<QBTorrent> = resp.json().await?;
|
||||
torrents
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(Self::map_torrent)
|
||||
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/torrents/add"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::InvalidRequest(
|
||||
resp.text().await.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
.file_name("torrent.torrent")
|
||||
.mime_str("application/x-bittorrent")
|
||||
.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());
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/torrents/add"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::InvalidRequest(
|
||||
resp.text().await.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_torrent(
|
||||
&self,
|
||||
hash: &str,
|
||||
delete_files: bool,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.api_url("/torrents/delete"))
|
||||
.form(&[
|
||||
("hashes", hash),
|
||||
("deleteFiles", if delete_files { "true" } else { "false" }),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
self.http
|
||||
.post(self.api_url("/torrents/pause"))
|
||||
.form(&[("hashes", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
self.http
|
||||
.post(self.api_url("/torrents/resume"))
|
||||
.form(&[("hashes", hash)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action")]
|
||||
pub enum StubRequest {
|
||||
AddUrl {
|
||||
url: String,
|
||||
save_path: Option<String>,
|
||||
timestamp: String,
|
||||
},
|
||||
AddFile {
|
||||
size: usize,
|
||||
save_path: Option<String>,
|
||||
timestamp: String,
|
||||
},
|
||||
Remove {
|
||||
hash: String,
|
||||
delete_files: bool,
|
||||
timestamp: String,
|
||||
},
|
||||
Pause {
|
||||
hash: String,
|
||||
timestamp: String,
|
||||
},
|
||||
Resume {
|
||||
hash: String,
|
||||
timestamp: String,
|
||||
},
|
||||
}
|
||||
|
||||
struct StubTorrent {
|
||||
info: TorrentInfo,
|
||||
}
|
||||
|
||||
pub struct StubTorrentClient {
|
||||
torrents: Arc<RwLock<HashMap<String, StubTorrent>>>,
|
||||
log_path: PathBuf,
|
||||
save_path: String,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
impl StubTorrentClient {
|
||||
pub fn new(log_path: impl Into<PathBuf>, save_path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
torrents: Arc::new(RwLock::new(HashMap::new())),
|
||||
log_path: log_path.into(),
|
||||
save_path: save_path.into(),
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_request(&self, request: &StubRequest) {
|
||||
use std::io::Write;
|
||||
|
||||
let json = serde_json::to_string_pretty(request).unwrap_or_default();
|
||||
let entry = format!("{}\n---\n", json);
|
||||
|
||||
let result = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.log_path)
|
||||
.and_then(|mut f| f.write_all(entry.as_bytes()));
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("failed to write stub log: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_hash(input: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
input.hash(&mut hasher);
|
||||
format!("{:016x}{:016x}{:08x}", hasher.finish(), hasher.finish(), 0)
|
||||
}
|
||||
|
||||
fn timestamp() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TorrentClient for StubTorrentClient {
|
||||
async fn connect(&mut self) -> Result<(), TorrentClientError> {
|
||||
self.connected = true;
|
||||
tracing::info!("stub torrent client connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
|
||||
self.connected = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
|
||||
let torrents = self.torrents.read().await;
|
||||
Ok(torrents.values().map(|t| t.info.clone()).collect())
|
||||
}
|
||||
|
||||
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
|
||||
let torrents = self.torrents.read().await;
|
||||
torrents
|
||||
.get(hash)
|
||||
.map(|t| t.info.clone())
|
||||
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
|
||||
async fn add_torrent_url(
|
||||
&self,
|
||||
url: &str,
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
let request = StubRequest::AddUrl {
|
||||
url: url.to_string(),
|
||||
save_path: save_path.map(String::from),
|
||||
timestamp: Self::timestamp(),
|
||||
};
|
||||
self.log_request(&request);
|
||||
|
||||
let hash = Self::generate_hash(url);
|
||||
let name = url.rsplit('/').next().unwrap_or("unknown").to_string();
|
||||
|
||||
let info = TorrentInfo {
|
||||
hash: hash.clone(),
|
||||
name,
|
||||
size: 0,
|
||||
progress: 0.0,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
state: TorrentState::Downloading,
|
||||
save_path: save_path.unwrap_or(&self.save_path).to_string(),
|
||||
};
|
||||
|
||||
let mut torrents = self.torrents.write().await;
|
||||
torrents.insert(hash, StubTorrent { info });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_torrent_file(
|
||||
&self,
|
||||
torrent_data: &[u8],
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
let request = StubRequest::AddFile {
|
||||
size: torrent_data.len(),
|
||||
save_path: save_path.map(String::from),
|
||||
timestamp: Self::timestamp(),
|
||||
};
|
||||
self.log_request(&request);
|
||||
|
||||
let hash = Self::generate_hash(&format!("file:{}", torrent_data.len()));
|
||||
|
||||
let info = TorrentInfo {
|
||||
hash: hash.clone(),
|
||||
name: format!("torrent-{}.torrent", &hash[..8]),
|
||||
size: torrent_data.len() as u64,
|
||||
progress: 0.0,
|
||||
download_speed: 0,
|
||||
upload_speed: 0,
|
||||
state: TorrentState::Downloading,
|
||||
save_path: save_path.unwrap_or(&self.save_path).to_string(),
|
||||
};
|
||||
|
||||
let mut torrents = self.torrents.write().await;
|
||||
torrents.insert(hash, StubTorrent { info });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_torrent(
|
||||
&self,
|
||||
hash: &str,
|
||||
delete_files: bool,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
let request = StubRequest::Remove {
|
||||
hash: hash.to_string(),
|
||||
delete_files,
|
||||
timestamp: Self::timestamp(),
|
||||
};
|
||||
self.log_request(&request);
|
||||
|
||||
let mut torrents = self.torrents.write().await;
|
||||
torrents
|
||||
.remove(hash)
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
|
||||
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
let request = StubRequest::Pause {
|
||||
hash: hash.to_string(),
|
||||
timestamp: Self::timestamp(),
|
||||
};
|
||||
self.log_request(&request);
|
||||
|
||||
let mut torrents = self.torrents.write().await;
|
||||
if let Some(t) = torrents.get_mut(hash) {
|
||||
t.info.state = TorrentState::Paused;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
let request = StubRequest::Resume {
|
||||
hash: hash.to_string(),
|
||||
timestamp: Self::timestamp(),
|
||||
};
|
||||
self.log_request(&request);
|
||||
|
||||
let mut torrents = self.torrents.write().await;
|
||||
if let Some(t) = torrents.get_mut(hash) {
|
||||
t.info.state = TorrentState::Downloading;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user