feat: add indexer and torrent REST controllers with service layer

- Add config module for yaml config (database, indexers, torrent)
- Add indexer module with Torznab protocol support
- Add IndexerService and TorrentService for business logic
- Add REST controllers for indexer search and torrent management
- Add Docker Compose for PostgreSQL and Jackett
- Add ERD documentation for database schema
This commit is contained in:
Alexander
2026-04-28 18:53:50 +02:00
parent f77806ba46
commit 1aaaab4640
23 changed files with 1841 additions and 51 deletions
+1
View File
@@ -1,3 +1,4 @@
/target /target
/result /result
.direnv/ .direnv/
config.yaml
+1
View File
@@ -0,0 +1 @@
/nix/store/ykac3kn52hv5lqhffvg55zghgrvlgd0r-pre-commit-config.json
Generated
+28
View File
@@ -667,9 +667,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"base64",
"reqwest", "reqwest",
"roxmltree",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"thiserror", "thiserror",
"tokio", "tokio",
"tower-http", "tower-http",
@@ -949,6 +952,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -1074,6 +1083,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1408,6 +1430,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+3
View File
@@ -12,6 +12,7 @@ axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9"
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -20,6 +21,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "coo
async-trait = "0.1" async-trait = "0.1"
thiserror = "2" thiserror = "2"
url = "2" url = "2"
roxmltree = "0.20"
base64 = "0.22"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
+13
View File
@@ -0,0 +1,13 @@
database:
url: "postgresql://music:music@localhost:5433/music_aggregator"
indexers:
- name: "Jackett"
url: "http://localhost:9117"
api_key: "your-jackett-api-key"
torrent:
qbittorrent:
url: "http://localhost:8080"
username: "admin"
password: "changeme"
+38
View File
@@ -0,0 +1,38 @@
services:
postgres:
image: postgres:16-alpine
container_name: music-aggregator-db
restart: unless-stopped
environment:
POSTGRES_USER: music
POSTGRES_PASSWORD: music
POSTGRES_DB: music_aggregator
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U music -d music_aggregator"]
interval: 10s
timeout: 5s
retries: 5
jackett:
image: lscr.io/linuxserver/jackett:latest
container_name: music-aggregator-jackett
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Warsaw
- AUTO_UPDATE=true
volumes:
- jackett_config:/config
- jackett_downloads:/downloads
ports:
- "9117:9117"
volumes:
postgres_data:
jackett_config:
jackett_downloads:
+274
View File
@@ -0,0 +1,274 @@
@startuml Music Aggregator ERD
skinparam linetype ortho
skinparam ranksep 60
skinparam nodesep 40
skinparam entity {
BackgroundColor White
BorderColor #333333
}
skinparam package {
BackgroundColor #FAFAFA
BorderColor #DDDDDD
}
title Music Aggregator - Database Structure
' ══════════════════════════════════════════════════════════════
' CORE MUSIC ENTITIES
' ══════════════════════════════════════════════════════════════
package "Core Music Entities" #E3F2FD {
entity "artist_metadata" {
* id : UUID <<PK>>
--
foreign_artist_id : TEXT <<UNIQUE>>
name : TEXT
sort_name : TEXT
disambiguation : TEXT
artist_type : TEXT
status : TEXT
overview : TEXT
images : JSONB
links : JSONB
genres : JSONB
--
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
entity "artists" {
* id : UUID <<PK>>
--
metadata_id : UUID <<FK>>
quality_profile_id : UUID <<FK>>
metadata_profile_id : UUID <<FK>>
root_folder_id : UUID <<FK>>
--
path : TEXT
monitored : BOOLEAN
monitor_new_items : TEXT
--
last_info_sync : TIMESTAMPTZ
added_at : TIMESTAMPTZ
}
entity "albums" {
* id : UUID <<PK>>
--
artist_metadata_id : UUID <<FK>>
--
foreign_album_id : TEXT <<UNIQUE>>
title : TEXT
clean_title : TEXT
disambiguation : TEXT
overview : TEXT
album_type : TEXT
release_date : DATE
images : JSONB
genres : JSONB
--
monitored : BOOLEAN
added_at : TIMESTAMPTZ
}
entity "album_releases" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
foreign_release_id : TEXT <<UNIQUE>>
title : TEXT
status : TEXT
duration_ms : INT
release_date : DATE
country : TEXT[]
label : TEXT[]
format : TEXT
track_count : INT
--
monitored : BOOLEAN
}
entity "tracks" {
* id : UUID <<PK>>
--
album_release_id : UUID <<FK>>
artist_metadata_id : UUID <<FK>>
track_file_id : UUID <<FK NULL>>
--
foreign_track_id : TEXT <<UNIQUE>>
title : TEXT
track_number : INT
disc_number : INT
duration_ms : INT
explicit : BOOLEAN
}
entity "track_files" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
path : TEXT
relative_path : TEXT
size : BIGINT
--
file_hash : TEXT
audio_hash : TEXT
--
quality : JSONB
media_info : JSONB
--
scene_name : TEXT
release_group : TEXT
--
date_added : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' CONFIGURATION
' ══════════════════════════════════════════════════════════════
package "Configuration" #FFF3E0 {
entity "quality_profiles" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
cutoff : INT
items : JSONB
upgrade_allowed : BOOLEAN
}
entity "metadata_profiles" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
primary_album_types : JSONB
secondary_album_types : JSONB
release_statuses : JSONB
}
entity "root_folders" {
* id : UUID <<PK>>
--
name : TEXT
path : TEXT <<UNIQUE>>
default_quality_profile_id : UUID <<FK>>
default_metadata_profile_id : UUID <<FK>>
}
entity "indexers" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
enable_rss : BOOLEAN
enable_search : BOOLEAN
priority : INT
}
entity "download_clients" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
protocol : TEXT
priority : INT
enabled : BOOLEAN
}
}
' ══════════════════════════════════════════════════════════════
' DOWNLOAD TRACKING
' ══════════════════════════════════════════════════════════════
package "Download Tracking" #E8F5E9 {
entity "wanted_albums" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>> <<UNIQUE>>
--
priority : INT
search_count : INT
last_searched_at : TIMESTAMPTZ
added_at : TIMESTAMPTZ
}
entity "download_queue" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
download_id : TEXT
title : TEXT
size : BIGINT
size_left : BIGINT
--
status : TEXT
progress : REAL
error_message : TEXT
--
protocol : TEXT
indexer : TEXT
download_client : TEXT
torrent_hash : TEXT
output_path : TEXT
--
added_at : TIMESTAMPTZ
completed_at : TIMESTAMPTZ
}
entity "blocklist" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
source_title : TEXT
quality : JSONB
size : BIGINT
protocol : TEXT
indexer : TEXT
message : TEXT
torrent_hash : TEXT
--
date : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' RELATIONSHIPS
' ══════════════════════════════════════════════════════════════
' Core music relationships
artist_metadata ||--|| artists : "has config"
artist_metadata ||--o{ albums : "released"
albums ||--o{ album_releases : "has releases"
album_releases ||--o{ tracks : "contains"
tracks }o--o| track_files : "stored in"
track_files }o--|| albums : "belongs to"
' Artist config relationships
artists }o--|| quality_profiles : "uses"
artists }o--o| metadata_profiles : "uses"
artists }o--o| root_folders : "stored in"
' Root folder defaults
root_folders }o--o| quality_profiles : "default"
root_folders }o--o| metadata_profiles : "default"
' Download tracking relationships
wanted_albums ||--|| albums : "targets"
download_queue }o--o| artists : "for"
download_queue }o--o| albums : "for"
blocklist }o--|| artists : "for"
blocklist }o--o| albums : "for"
@enduml
+362
View File
@@ -0,0 +1,362 @@
@startuml Lidarr-Style Music Aggregator ERD
skinparam linetype ortho
skinparam ranksep 60
skinparam nodesep 40
skinparam entity {
BackgroundColor White
BorderColor #333333
}
skinparam package {
BackgroundColor #FAFAFA
BorderColor #DDDDDD
}
title Music Aggregator - Lidarr-Style Database Structure
' ══════════════════════════════════════════════════════════════
' CORE MUSIC ENTITIES
' ══════════════════════════════════════════════════════════════
package "Core Music Entities" #E3F2FD {
entity "artist_metadata" {
* id : UUID <<PK>>
--
foreign_artist_id : TEXT <<UNIQUE>>
name : TEXT
sort_name : TEXT
disambiguation : TEXT
artist_type : TEXT
status : TEXT
overview : TEXT
images : JSONB
links : JSONB
genres : JSONB
ratings : JSONB
members : JSONB
--
created_at : TIMESTAMPTZ
updated_at : TIMESTAMPTZ
}
entity "artists" {
* id : UUID <<PK>>
--
metadata_id : UUID <<FK>>
quality_profile_id : UUID <<FK>>
metadata_profile_id : UUID <<FK>>
root_folder_id : UUID <<FK>>
--
path : TEXT
monitored : BOOLEAN
monitor_new_items : TEXT
--
last_info_sync : TIMESTAMPTZ
added_at : TIMESTAMPTZ
tags : INT[]
}
entity "albums" {
* id : UUID <<PK>>
--
artist_metadata_id : UUID <<FK>>
--
foreign_album_id : TEXT <<UNIQUE>>
title : TEXT
clean_title : TEXT
disambiguation : TEXT
overview : TEXT
album_type : TEXT
secondary_types : JSONB
release_date : DATE
images : JSONB
links : JSONB
genres : JSONB
ratings : JSONB
--
monitored : BOOLEAN
any_release_ok : BOOLEAN
last_search_time : TIMESTAMPTZ
added_at : TIMESTAMPTZ
}
entity "album_releases" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
foreign_release_id : TEXT <<UNIQUE>>
title : TEXT
disambiguation : TEXT
status : TEXT
duration_ms : INT
release_date : DATE
country : TEXT[]
label : TEXT[]
media : JSONB
track_count : INT
--
monitored : BOOLEAN
}
entity "tracks" {
* id : UUID <<PK>>
--
album_release_id : UUID <<FK>>
artist_metadata_id : UUID <<FK>>
track_file_id : UUID <<FK NULL>>
--
foreign_track_id : TEXT <<UNIQUE>>
foreign_recording_id : TEXT
title : TEXT
track_number : INT
disc_number : INT
duration_ms : INT
explicit : BOOLEAN
ratings : JSONB
}
entity "track_files" {
* id : UUID <<PK>>
--
album_id : UUID <<FK>>
--
path : TEXT
relative_path : TEXT
size : BIGINT
quality : JSONB
media_info : JSONB
audio_tags : JSONB
--
scene_name : TEXT
release_group : TEXT
--
date_added : TIMESTAMPTZ
modified_at : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' CONFIGURATION
' ══════════════════════════════════════════════════════════════
package "Configuration" #FFF3E0 {
entity "quality_profiles" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
cutoff : INT
items : JSONB
upgrade_allowed : BOOLEAN
min_format_score : INT
cutoff_format_score : INT
}
entity "metadata_profiles" {
* id : UUID <<PK>>
--
name : TEXT <<UNIQUE>>
primary_album_types : JSONB
secondary_album_types : JSONB
release_statuses : JSONB
}
entity "root_folders" {
* id : UUID <<PK>>
--
name : TEXT
path : TEXT <<UNIQUE>>
default_quality_profile_id : UUID <<FK>>
default_metadata_profile_id : UUID <<FK>>
default_monitor_option : TEXT
default_tags : INT[]
}
entity "tags" {
* id : SERIAL <<PK>>
--
label : TEXT <<UNIQUE>>
}
entity "indexers" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
enable_rss : BOOLEAN
enable_search : BOOLEAN
priority : INT
tags : INT[]
}
entity "download_clients" {
* id : UUID <<PK>>
--
name : TEXT
implementation : TEXT
settings : JSONB
protocol : TEXT
priority : INT
remove_completed : BOOLEAN
remove_failed : BOOLEAN
tags : INT[]
enabled : BOOLEAN
}
}
' ══════════════════════════════════════════════════════════════
' DOWNLOAD TRACKING
' ══════════════════════════════════════════════════════════════
package "Download Tracking" #E8F5E9 {
entity "download_queue" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
download_id : TEXT
title : TEXT
size : BIGINT
size_left : BIGINT
time_left : INTERVAL
estimated_completion : TIMESTAMPTZ
--
status : TEXT
state : TEXT
status_messages : JSONB
--
protocol : TEXT
indexer : TEXT
download_client : TEXT
output_path : TEXT
download_forced : BOOLEAN
--
added_at : TIMESTAMPTZ
}
entity "pending_releases" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
title : TEXT
release : JSONB
parsed_album_info : JSONB
reason : TEXT
additional_info : JSONB
--
added_at : TIMESTAMPTZ
}
entity "download_history" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
--
event_type : TEXT
download_id : TEXT
source_title : TEXT
protocol : TEXT
indexer_id : UUID
download_client_id : UUID
release : JSONB
data : JSONB
--
date : TIMESTAMPTZ
}
entity "blocklist" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_ids : UUID[]
--
source_title : TEXT
quality : JSONB
size : BIGINT
protocol : TEXT
indexer : TEXT
message : TEXT
torrent_hash : TEXT
--
published_date : TIMESTAMPTZ
date : TIMESTAMPTZ
}
}
' ══════════════════════════════════════════════════════════════
' HISTORY & TRACKING
' ══════════════════════════════════════════════════════════════
package "History & Tracking" #FCE4EC {
entity "history" {
* id : UUID <<PK>>
--
artist_id : UUID <<FK>>
album_id : UUID <<FK>>
track_id : UUID <<FK NULL>>
--
event_type : TEXT
source_title : TEXT
quality : JSONB
download_id : TEXT
data : JSONB
--
date : TIMESTAMPTZ
}
entity "wanted_albums" {
' View/materialized view for missing albums
* album_id : UUID <<FK>>
--
artist_id : UUID
title : TEXT
release_date : DATE
monitored : BOOLEAN
track_count : INT
track_file_count : INT
}
}
' ══════════════════════════════════════════════════════════════
' RELATIONSHIPS
' ══════════════════════════════════════════════════════════════
' Core music relationships
artist_metadata ||--|| artists : "has config"
artist_metadata ||--o{ albums : "released"
albums ||--o{ album_releases : "has releases"
album_releases ||--o{ tracks : "contains"
tracks }o--o| track_files : "stored in"
track_files }o--|| albums : "belongs to"
' Artist relationships
artists }o--|| quality_profiles : "uses"
artists }o--o| metadata_profiles : "uses"
artists }o--o| root_folders : "stored in"
' Configuration relationships
root_folders }o--o| quality_profiles : "default"
root_folders }o--o| metadata_profiles : "default"
' Download tracking relationships
download_queue }o--o| artists : "for"
download_queue }o--o| albums : "for"
pending_releases }o--|| artists : "for"
pending_releases }o--o| albums : "for"
download_history }o--|| artists : "for"
download_history }o--o| albums : "for"
blocklist }o--|| artists : "for"
' History relationships
history }o--|| artists : "for"
history }o--o| albums : "for"
history }o--o| tracks : "for"
@enduml
+93
View File
@@ -0,0 +1,93 @@
# Lidarr Database Research Summary
## Overview
Lidarr is a music collection manager that uses a **Release-based** system. Key design principles:
1. **Metadata Separation** - Artist metadata separated from configuration
2. **Release-Centric** - Works with releases, not loose tracks
3. **Monitoring Hierarchy** - Artist → Album → Release (only one release monitored per album)
4. **Quality Profiles** - Separate profiles for quality and metadata preferences
5. **Download Lifecycle** - Pending → Queue → Imported → History/Blocklist
## Core Entity Hierarchy
```
ArtistMetadata (1) ←→ (1) Artist (1) ←→ (N) Albums
(N) AlbumReleases (only 1 monitored)
(N) Tracks (N) ←→ (1) TrackFile
```
## Key Entities
### Music Entities
- **Artists** - Configuration (path, monitoring, profiles)
- **ArtistMetadata** - Metadata (name, images, genres, members)
- **Albums** - Album info with monitoring and search tracking
- **AlbumReleases** - Physical releases (CD, Vinyl, Digital) - only ONE monitored per album
- **Tracks** - Individual tracks linked to releases
- **TrackFiles** - Actual files on disk with quality info
### Configuration
- **QualityProfiles** - Acceptable formats and upgrade cutoff
- **MetadataProfiles** - Which album types to include (Studio, EP, Live, etc.)
- **RootFolders** - Storage locations with defaults
### Download Tracking
- **PendingReleases** - Delayed downloads (waiting for better quality)
- **DownloadHistory** - Download lifecycle events
- **Blocklist** - Failed/rejected releases (prevent re-download)
- **History** - Complete audit trail of all events
### System
- **Indexers** - Search sources (Newznab/Torznab)
- **DownloadClients** - Torrent/Usenet clients
- **ImportLists** - Auto-import from Spotify, Last.fm, etc.
- **Tags** - Categorization
## Monitoring States
### Artist Level
- `monitored` - Is artist being tracked
- `monitor_new_items` - Policy for new releases (All/Future/Missing/Existing/None)
### Album Level
- `monitored` - Should we look for this album
- `any_release_ok` - Auto-switch releases during import
### Release Level
- `monitored` - Is this the release we want (exactly ONE per album)
## Download States
```
TrackedDownloadState:
Downloading → ImportPending → Importing → Imported
→ Ignored
→ DownloadFailed → DownloadFailedPending
```
## History Event Types
- Grabbed - Download started
- TrackFileImported - File imported to library
- DownloadFailed - Download failed
- TrackFileDeleted - File removed
- TrackFileRenamed - File renamed
- TrackFileRetagged - Metadata updated
- AlbumImportIncomplete - Partial import
- DownloadIgnored - Download skipped
## Sources
- GitHub: Lidarr/Lidarr
- Key files:
- `src/NzbDrone.Core/Music/Model/Artist.cs`
- `src/NzbDrone.Core/Music/Model/Album.cs`
- `src/NzbDrone.Core/Music/Model/Release.cs` (AlbumRelease)
- `src/NzbDrone.Core/Music/Model/Track.cs`
- `src/NzbDrone.Core/MediaFiles/TrackFile.cs`
- `src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs`
- `src/NzbDrone.Core/Datastore/Migration/023_add_release_groups_etc.cs`
+92
View File
@@ -0,0 +1,92 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::indexer::{MusicSearchCriteria, SearchResult};
use crate::services::IndexerInfo;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list_indexers))
.route("/search", post(search))
.route("/:name/test", get(test_indexer))
}
async fn list_indexers(State(state): State<AppState>) -> Json<Vec<IndexerInfo>> {
let state = state.read().await;
Json(state.indexer_service.list_indexers())
}
#[derive(Debug, Deserialize)]
pub struct SearchRequest {
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub limit: Option<u32>,
pub indexer: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub total: usize,
}
async fn search(
State(state): State<AppState>,
Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, (StatusCode, String)> {
let mut criteria = MusicSearchCriteria::new(&req.artist);
if let Some(album) = &req.album {
criteria = criteria.with_album(album);
}
if let Some(year) = req.year {
criteria = criteria.with_year(year);
}
if let Some(limit) = req.limit {
criteria = criteria.with_limit(limit);
}
let state = state.read().await;
let results = state
.indexer_service
.search(&criteria, req.indexer.as_deref())
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
let total = results.len();
Ok(Json(SearchResponse { results, total }))
}
#[derive(Debug, Serialize)]
pub struct TestResponse {
pub success: bool,
pub message: String,
}
async fn test_indexer(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<TestResponse>, (StatusCode, Json<TestResponse>)> {
let state = state.read().await;
match state.indexer_service.test_indexer(&name).await {
Ok(()) => Ok(Json(TestResponse {
success: true,
message: "Connection successful".to_string(),
})),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
Json(TestResponse {
success: false,
message: e.to_string(),
}),
)),
}
}
+28 -17
View File
@@ -1,3 +1,6 @@
mod indexer_controller;
mod torrent_controller;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
@@ -18,20 +21,22 @@ pub fn routes(state: AppState) -> Router {
.route("/tracks/:id", delete(delete_track)) .route("/tracks/:id", delete(delete_track))
.route("/tracks/search", get(search_tracks)) .route("/tracks/search", get(search_tracks))
.route("/stats", get(get_stats)) .route("/stats", get(get_stats))
.nest("/indexers", indexer_controller::routes())
.nest("/torrents", torrent_controller::routes())
.with_state(state) .with_state(state)
} }
async fn list_tracks(State(state): State<AppState>) -> Json<Vec<Track>> { async fn list_tracks(State(state): State<AppState>) -> Json<Vec<Track>> {
let agg = state.read().await; let state = state.read().await;
Json(agg.get_all().to_vec()) Json(state.aggregator.get_all().to_vec())
} }
async fn create_track( async fn create_track(
State(state): State<AppState>, State(state): State<AppState>,
Json(input): Json<CreateTrack>, Json(input): Json<CreateTrack>,
) -> (StatusCode, Json<Track>) { ) -> (StatusCode, Json<Track>) {
let mut agg = state.write().await; let mut state = state.write().await;
let track = agg.add_track(input.into()); let track = state.aggregator.add_track(input.into());
(StatusCode::CREATED, Json(track)) (StatusCode::CREATED, Json(track))
} }
@@ -39,19 +44,18 @@ async fn get_track(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<Track>, StatusCode> { ) -> Result<Json<Track>, StatusCode> {
let agg = state.read().await; let state = state.read().await;
agg.get_by_id(id) state
.aggregator
.get_by_id(id)
.cloned() .cloned()
.map(Json) .map(Json)
.ok_or(StatusCode::NOT_FOUND) .ok_or(StatusCode::NOT_FOUND)
} }
async fn delete_track( async fn delete_track(State(state): State<AppState>, Path(id): Path<Uuid>) -> StatusCode {
State(state): State<AppState>, let mut state = state.write().await;
Path(id): Path<Uuid>, if state.aggregator.delete(id) {
) -> StatusCode {
let mut agg = state.write().await;
if agg.delete(id) {
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} else { } else {
StatusCode::NOT_FOUND StatusCode::NOT_FOUND
@@ -67,8 +71,15 @@ async fn search_tracks(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<SearchQuery>, Query(query): Query<SearchQuery>,
) -> Json<Vec<Track>> { ) -> Json<Vec<Track>> {
let agg = state.read().await; let state = state.read().await;
Json(agg.search_by_artist(&query.artist).into_iter().cloned().collect()) Json(
state
.aggregator
.search_by_artist(&query.artist)
.into_iter()
.cloned()
.collect(),
)
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -78,9 +89,9 @@ struct Stats {
} }
async fn get_stats(State(state): State<AppState>) -> Json<Stats> { async fn get_stats(State(state): State<AppState>) -> Json<Stats> {
let agg = state.read().await; let state = state.read().await;
Json(Stats { Json(Stats {
track_count: agg.get_all().len(), track_count: state.aggregator.get_all().len(),
total_duration_secs: agg.total_duration(), total_duration_secs: state.aggregator.total_duration(),
}) })
} }
+210
View File
@@ -0,0 +1,210 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::torrent::TorrentInfo;
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(list_torrents))
.route("/:hash", get(get_torrent))
.route("/:hash", delete(remove_torrent))
.route("/:hash/pause", post(pause_torrent))
.route("/:hash/resume", post(resume_torrent))
.route("/add/url", post(add_torrent_url))
.route("/add/file", post(add_torrent_file))
.route("/status", get(connection_status))
}
#[derive(Debug, Serialize)]
pub struct TorrentListResponse {
pub torrents: Vec<TorrentInfo>,
pub total: usize,
}
async fn list_torrents(
State(state): State<AppState>,
) -> Result<Json<TorrentListResponse>, (StatusCode, String)> {
let state = state.read().await;
let torrents = state
.torrent_service
.list_torrents()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
let total = torrents.len();
Ok(Json(TorrentListResponse { torrents, total }))
}
async fn get_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Json<TorrentInfo>, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.get_torrent(&hash)
.await
.map(Json)
.map_err(|e| {
let status = if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_GATEWAY
};
(status, e.to_string())
})
}
#[derive(Debug, Deserialize)]
pub struct RemoveQuery {
#[serde(default)]
pub delete_files: bool,
}
async fn remove_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
Query(query): Query<RemoveQuery>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.remove_torrent(&hash, query.delete_files)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
async fn pause_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.pause_torrent(&hash)
.await
.map(|_| StatusCode::OK)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
async fn resume_torrent(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let state = state.read().await;
state
.torrent_service
.resume_torrent(&hash)
.await
.map(|_| StatusCode::OK)
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
}
#[derive(Debug, Deserialize)]
pub struct AddUrlRequest {
pub url: String,
pub save_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AddResponse {
pub success: bool,
pub message: String,
}
async fn add_torrent_url(
State(state): State<AppState>,
Json(req): Json<AddUrlRequest>,
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
let state = state.read().await;
state
.torrent_service
.add_torrent_url(&req.url, req.save_path.as_deref())
.await
.map(|_| {
(
StatusCode::CREATED,
Json(AddResponse {
success: true,
message: "Torrent added successfully".to_string(),
}),
)
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: e.to_string(),
}),
)
})
}
#[derive(Debug, Deserialize)]
pub struct AddFileRequest {
pub torrent_base64: String,
pub save_path: Option<String>,
}
async fn add_torrent_file(
State(state): State<AppState>,
Json(req): Json<AddFileRequest>,
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD
.decode(&req.torrent_base64)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: format!("Invalid base64: {}", e),
}),
)
})?;
let state = state.read().await;
state
.torrent_service
.add_torrent_file(&data, req.save_path.as_deref())
.await
.map(|_| {
(
StatusCode::CREATED,
Json(AddResponse {
success: true,
message: "Torrent added successfully".to_string(),
}),
)
})
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(AddResponse {
success: false,
message: e.to_string(),
}),
)
})
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub connected: bool,
}
async fn connection_status(State(state): State<AppState>) -> Json<StatusResponse> {
let state = state.read().await;
Json(StatusResponse {
connected: state.torrent_service.is_connected().await,
})
}
+52
View File
@@ -0,0 +1,52 @@
use serde::Deserialize;
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
ReadError(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
ParseError(#[from] serde_yaml::Error),
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub database: DatabaseConfig,
pub indexers: Vec<IndexerConfig>,
pub torrent: TorrentConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IndexerConfig {
pub name: String,
pub url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TorrentConfig {
pub qbittorrent: Option<QBittorrentConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct QBittorrentConfig {
pub url: String,
pub username: String,
pub password: String,
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&content)?;
Ok(config)
}
}
+43
View File
@@ -0,0 +1,43 @@
mod search;
mod torznab;
pub use search::{MusicSearchCriteria, SearchResult};
pub use torznab::TorznabIndexer;
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IndexerError {
#[error("authentication failed: invalid API key")]
AuthenticationFailed,
#[error("rate limited: retry after {0} seconds")]
RateLimited(u64),
#[error("indexer unavailable: {0}")]
Unavailable(String),
#[error("search failed: {0}")]
SearchFailed(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
}
#[async_trait]
pub trait Indexer: Send + Sync {
fn name(&self) -> &str;
fn supports_music_search(&self) -> bool;
async fn search(
&self,
criteria: &MusicSearchCriteria,
) -> Result<Vec<SearchResult>, IndexerError>;
async fn test_connection(&self) -> Result<(), IndexerError>;
}
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct MusicSearchCriteria {
pub artist: String,
pub album: Option<String>,
pub year: Option<u32>,
pub limit: u32,
pub offset: u32,
}
impl MusicSearchCriteria {
pub fn new(artist: impl Into<String>) -> Self {
Self {
artist: artist.into(),
album: None,
year: None,
limit: 100,
offset: 0,
}
}
pub fn with_album(mut self, album: impl Into<String>) -> Self {
self.album = Some(album.into());
self
}
pub fn with_year(mut self, year: u32) -> Self {
self.year = Some(year);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = limit;
self
}
pub fn with_offset(mut self, offset: u32) -> Self {
self.offset = offset;
self
}
pub fn clean_artist(&self) -> String {
normalize_query(&self.artist)
}
pub fn clean_album(&self) -> Option<String> {
self.album.as_ref().map(|a| normalize_query(a))
}
}
fn normalize_query(s: &str) -> String {
s.trim().replace("\"", "").replace("'", "").to_lowercase()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub guid: String,
pub title: String,
pub download_url: String,
pub info_url: Option<String>,
pub size: u64,
pub publish_date: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<u32>,
pub label: Option<String>,
pub seeders: Option<u32>,
pub leechers: Option<u32>,
pub grabs: Option<u32>,
pub infohash: Option<String>,
pub magnet_url: Option<String>,
pub indexer: String,
pub categories: Vec<u32>,
}
+222
View File
@@ -0,0 +1,222 @@
use async_trait::async_trait;
use reqwest::Client;
use url::Url;
use super::search::{MusicSearchCriteria, SearchResult};
use super::{Indexer, IndexerError};
pub struct TorznabIndexer {
name: String,
base_url: Url,
api_key: String,
categories: Vec<u32>,
http: Client,
}
impl TorznabIndexer {
pub fn new(
name: impl Into<String>,
base_url: &str,
api_key: impl Into<String>,
) -> Result<Self, IndexerError> {
let base_url = Url::parse(base_url)
.map_err(|e| IndexerError::SearchFailed(format!("invalid URL: {}", e)))?;
Ok(Self {
name: name.into(),
base_url,
api_key: api_key.into(),
categories: vec![3000, 3010, 3040],
http: Client::new(),
})
}
pub fn with_categories(mut self, categories: Vec<u32>) -> Self {
self.categories = categories;
self
}
fn build_search_url(&self, criteria: &MusicSearchCriteria) -> Result<Url, IndexerError> {
let mut url = self.base_url.clone();
{
let mut query = url.query_pairs_mut();
query.append_pair("t", "music");
query.append_pair("apikey", &self.api_key);
query.append_pair("extended", "1");
let cats = self
.categories
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(",");
query.append_pair("cat", &cats);
query.append_pair("artist", &criteria.clean_artist());
if let Some(album) = criteria.clean_album() {
query.append_pair("album", &album);
}
if let Some(year) = criteria.year {
query.append_pair("year", &year.to_string());
}
query.append_pair("limit", &criteria.limit.to_string());
query.append_pair("offset", &criteria.offset.to_string());
}
Ok(url)
}
fn parse_response(&self, xml: &str) -> Result<Vec<SearchResult>, IndexerError> {
let mut results = Vec::new();
let doc = roxmltree::Document::parse(xml)
.map_err(|e| IndexerError::ParseError(format!("XML parse error: {}", e)))?;
if let Some(error) = doc.descendants().find(|n| n.has_tag_name("error")) {
let code = error.attribute("code").unwrap_or("0");
let desc = error.attribute("description").unwrap_or("Unknown error");
if code.starts_with("1") {
return Err(IndexerError::AuthenticationFailed);
}
return Err(IndexerError::SearchFailed(desc.to_string()));
}
for item in doc.descendants().filter(|n| n.has_tag_name("item")) {
let result = self.parse_item(&item)?;
results.push(result);
}
Ok(results)
}
fn parse_item(&self, item: &roxmltree::Node) -> Result<SearchResult, IndexerError> {
let get_text = |tag: &str| -> Option<String> {
item.children()
.find(|n| n.has_tag_name(tag))
.and_then(|n| n.text())
.map(|s| s.to_string())
};
let get_attr = |name: &str| -> Option<String> {
item.children()
.filter(|n| n.has_tag_name("attr"))
.find(|n| n.attribute("name") == Some(name))
.and_then(|n| n.attribute("value"))
.map(|s| s.to_string())
};
let guid = get_text("guid").unwrap_or_default();
let title = get_text("title").unwrap_or_default();
let download_url = get_text("link").unwrap_or_default();
let size = get_attr("size")
.or_else(|| {
item.children()
.find(|n| n.has_tag_name("enclosure"))
.and_then(|n| n.attribute("length"))
.map(|s| s.to_string())
})
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mut categories = Vec::new();
for attr in item.children().filter(|n| n.has_tag_name("attr")) {
if attr.attribute("name") == Some("category") {
if let Some(val) = attr.attribute("value") {
if let Ok(cat) = val.parse::<u32>() {
categories.push(cat);
}
}
}
}
Ok(SearchResult {
guid,
title,
download_url,
info_url: get_text("comments"),
size,
publish_date: get_text("pubDate"),
artist: get_attr("artist"),
album: get_attr("album"),
year: get_attr("year").and_then(|s| s.parse().ok()),
label: get_attr("label"),
seeders: get_attr("seeders").and_then(|s| s.parse().ok()),
leechers: get_attr("leechers").and_then(|s| s.parse().ok()),
grabs: get_attr("grabs").and_then(|s| s.parse().ok()),
infohash: get_attr("infohash"),
magnet_url: get_attr("magneturl"),
indexer: self.name.clone(),
categories,
})
}
}
#[async_trait]
impl Indexer for TorznabIndexer {
fn name(&self) -> &str {
&self.name
}
fn supports_music_search(&self) -> bool {
true
}
async fn search(
&self,
criteria: &MusicSearchCriteria,
) -> Result<Vec<SearchResult>, IndexerError> {
let url = self.build_search_url(criteria)?;
let response = self.http.get(url).send().await?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
return Err(IndexerError::RateLimited(retry_after));
}
if !response.status().is_success() {
return Err(IndexerError::Unavailable(format!(
"HTTP {}",
response.status()
)));
}
let xml = response.text().await?;
self.parse_response(&xml)
}
async fn test_connection(&self) -> Result<(), IndexerError> {
let mut url = self.base_url.clone();
url.query_pairs_mut()
.append_pair("t", "caps")
.append_pair("apikey", &self.api_key);
let response = self.http.get(url).send().await?;
if !response.status().is_success() {
return Err(IndexerError::Unavailable(format!(
"HTTP {}",
response.status()
)));
}
let xml = response.text().await?;
if xml.contains("<error") && xml.contains("code=\"1") {
return Err(IndexerError::AuthenticationFailed);
}
Ok(())
}
}
+30
View File
@@ -0,0 +1,30 @@
pub mod api;
pub mod config;
pub mod indexer;
pub mod models;
pub mod services;
pub mod torrent;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct AppServices {
pub aggregator: services::Aggregator,
pub indexer_service: services::IndexerService,
pub torrent_service: services::TorrentService,
}
impl AppServices {
pub fn new(
indexer_service: services::IndexerService,
torrent_service: services::TorrentService,
) -> Self {
Self {
aggregator: services::Aggregator::new(),
indexer_service,
torrent_service,
}
}
}
pub type AppState = Arc<RwLock<AppServices>>;
+51 -10
View File
@@ -1,20 +1,16 @@
mod api;
mod models;
mod services;
mod torrent;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use axum::Router; use axum::Router;
use music_agregator::{
api, config,
services::{IndexerService, TorrentService},
AppServices, AppState,
};
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::services::Aggregator;
pub type AppState = Arc<RwLock<Aggregator>>;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::registry() tracing_subscriber::registry()
@@ -22,7 +18,52 @@ async fn main() {
.with(tracing_subscriber::EnvFilter::from_default_env()) .with(tracing_subscriber::EnvFilter::from_default_env())
.init(); .init();
let state: AppState = Arc::new(RwLock::new(Aggregator::new())); let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "config.yaml".to_string());
let config = match config::Config::load(&config_path) {
Ok(cfg) => {
tracing::info!("loaded config from {}", config_path);
cfg
}
Err(e) => {
tracing::error!("failed to load config: {}", e);
std::process::exit(1);
}
};
let indexer_service = match IndexerService::from_config(&config.indexers) {
Ok(svc) => {
tracing::info!("initialized {} indexer(s)", config.indexers.len());
svc
}
Err(e) => {
tracing::error!("failed to initialize indexer service: {}", e);
std::process::exit(1);
}
};
let torrent_service = if let Some(qbit_config) = &config.torrent.qbittorrent {
match TorrentService::from_qbittorrent_config(qbit_config).await {
Ok(svc) => {
tracing::info!("connected to qBittorrent at {}", qbit_config.url);
svc
}
Err(e) => {
tracing::warn!(
"failed to connect to qBittorrent: {} (continuing without torrent client)",
e
);
TorrentService::new()
}
}
} else {
tracing::info!("no torrent client configured");
TorrentService::new()
};
let state: AppState = Arc::new(RwLock::new(AppServices::new(
indexer_service,
torrent_service,
)));
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
+95
View File
@@ -0,0 +1,95 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::config::IndexerConfig;
use crate::indexer::{Indexer, IndexerError, MusicSearchCriteria, SearchResult, TorznabIndexer};
pub struct IndexerService {
indexers: HashMap<String, Arc<dyn Indexer>>,
}
impl IndexerService {
pub fn new() -> Self {
Self {
indexers: HashMap::new(),
}
}
pub fn from_config(configs: &[IndexerConfig]) -> Result<Self, IndexerError> {
let mut service = Self::new();
for config in configs {
let indexer = TorznabIndexer::new(&config.name, &config.url, &config.api_key)?;
service.add_indexer(Arc::new(indexer));
}
Ok(service)
}
pub fn add_indexer(&mut self, indexer: Arc<dyn Indexer>) {
self.indexers.insert(indexer.name().to_string(), indexer);
}
pub fn get_indexer(&self, name: &str) -> Option<Arc<dyn Indexer>> {
self.indexers.get(name).cloned()
}
pub fn list_indexers(&self) -> Vec<IndexerInfo> {
self.indexers
.values()
.map(|i| IndexerInfo {
name: i.name().to_string(),
supports_music: i.supports_music_search(),
})
.collect()
}
pub async fn search(
&self,
criteria: &MusicSearchCriteria,
indexer_name: Option<&str>,
) -> Result<Vec<SearchResult>, IndexerError> {
match indexer_name {
Some(name) => {
let indexer = self.indexers.get(name).ok_or_else(|| {
IndexerError::Unavailable(format!("indexer not found: {}", name))
})?;
indexer.search(criteria).await
}
None => {
let mut all_results = Vec::new();
for indexer in self.indexers.values() {
if indexer.supports_music_search() {
match indexer.search(criteria).await {
Ok(results) => all_results.extend(results),
Err(e) => {
tracing::warn!("indexer {} failed: {}", indexer.name(), e);
}
}
}
}
Ok(all_results)
}
}
}
pub async fn test_indexer(&self, name: &str) -> Result<(), IndexerError> {
let indexer = self
.indexers
.get(name)
.ok_or_else(|| IndexerError::Unavailable(format!("indexer not found: {}", name)))?;
indexer.test_connection().await
}
}
impl Default for IndexerService {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct IndexerInfo {
pub name: String,
pub supports_music: bool,
}
+6
View File
@@ -1,3 +1,9 @@
mod indexer_service;
mod torrent_service;
pub use indexer_service::{IndexerInfo, IndexerService};
pub use torrent_service::TorrentService;
use uuid::Uuid; use uuid::Uuid;
use crate::models::Track; use crate::models::Track;
+81
View File
@@ -0,0 +1,81 @@
use std::sync::Arc;
use crate::config::QBittorrentConfig;
use crate::torrent::{QBittorrentClient, TorrentClient, TorrentClientError, TorrentInfo};
pub struct TorrentService {
client: Option<Arc<dyn TorrentClient>>,
}
impl TorrentService {
pub fn new() -> Self {
Self { client: None }
}
pub async fn from_qbittorrent_config(
config: &QBittorrentConfig,
) -> Result<Self, TorrentClientError> {
let mut client = QBittorrentClient::new(&config.url, &config.username, &config.password)?;
client.connect().await?;
Ok(Self {
client: Some(Arc::new(client)),
})
}
fn client(&self) -> Result<&Arc<dyn TorrentClient>, TorrentClientError> {
self.client
.as_ref()
.ok_or_else(|| TorrentClientError::ConnectionFailed("no client configured".into()))
}
pub async fn is_connected(&self) -> bool {
self.client.is_some()
}
pub async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.client()?.list_torrents().await
}
pub async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
self.client()?.get_torrent(hash).await
}
pub async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.client()?.add_torrent_url(url, save_path).await
}
pub async fn add_torrent_file(
&self,
data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.client()?.add_torrent_file(data, save_path).await
}
pub async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
self.client()?.remove_torrent(hash, delete_files).await
}
pub async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.client()?.pause_torrent(hash).await
}
pub async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
self.client()?.resume_torrent(hash).await
}
}
impl Default for TorrentService {
fn default() -> Self {
Self::new()
}
}
+15 -3
View File
@@ -57,11 +57,23 @@ pub trait TorrentClient: Send + Sync {
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>; async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>;
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError>; async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError>;
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError>; async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError>;
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError>; async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError>;
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>; async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
+24 -21
View File
@@ -29,12 +29,10 @@ struct QBTorrent {
impl QBittorrentClient { impl QBittorrentClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> { pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> {
let base_url = Url::parse(base_url) let base_url =
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?; Url::parse(base_url).map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let http = Client::builder() let http = Client::builder().cookie_store(true).build()?;
.cookie_store(true)
.build()?;
Ok(Self { Ok(Self {
base_url, base_url,
@@ -99,7 +97,7 @@ impl TorrentClient for QBittorrentClient {
.await?; .await?;
let text = resp.text().await?; let text = resp.text().await?;
if text == "Ok." { if text == "Ok." {
*self.connected.write().await = true; *self.connected.write().await = true;
Ok(()) Ok(())
@@ -109,11 +107,8 @@ impl TorrentClient for QBittorrentClient {
} }
async fn disconnect(&mut self) -> Result<(), TorrentClientError> { async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
self.http self.http.post(self.api_url("/auth/logout")).send().await?;
.post(self.api_url("/auth/logout"))
.send()
.await?;
*self.connected.write().await = false; *self.connected.write().await = false;
Ok(()) Ok(())
} }
@@ -121,11 +116,7 @@ impl TorrentClient for QBittorrentClient {
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> { async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
self.ensure_connected().await?; self.ensure_connected().await?;
let resp = self let resp = self.http.get(self.api_url("/torrents/info")).send().await?;
.http
.get(self.api_url("/torrents/info"))
.send()
.await?;
let torrents: Vec<QBTorrent> = resp.json().await?; let torrents: Vec<QBTorrent> = resp.json().await?;
Ok(torrents.into_iter().map(Self::map_torrent).collect()) Ok(torrents.into_iter().map(Self::map_torrent).collect())
@@ -149,11 +140,15 @@ impl TorrentClient for QBittorrentClient {
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string())) .ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
} }
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError> { async fn add_torrent_url(
&self,
url: &str,
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?; self.ensure_connected().await?;
let mut form = multipart::Form::new().text("urls", url.to_string()); let mut form = multipart::Form::new().text("urls", url.to_string());
if let Some(path) = save_path { if let Some(path) = save_path {
form = form.text("savepath", path.to_string()); form = form.text("savepath", path.to_string());
} }
@@ -174,7 +169,11 @@ impl TorrentClient for QBittorrentClient {
} }
} }
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError> { async fn add_torrent_file(
&self,
torrent_data: &[u8],
save_path: Option<&str>,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?; self.ensure_connected().await?;
let part = multipart::Part::bytes(torrent_data.to_vec()) let part = multipart::Part::bytes(torrent_data.to_vec())
@@ -183,7 +182,7 @@ impl TorrentClient for QBittorrentClient {
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?; .map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
let mut form = multipart::Form::new().part("torrents", part); let mut form = multipart::Form::new().part("torrents", part);
if let Some(path) = save_path { if let Some(path) = save_path {
form = form.text("savepath", path.to_string()); form = form.text("savepath", path.to_string());
} }
@@ -204,7 +203,11 @@ impl TorrentClient for QBittorrentClient {
} }
} }
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError> { async fn remove_torrent(
&self,
hash: &str,
delete_files: bool,
) -> Result<(), TorrentClientError> {
self.ensure_connected().await?; self.ensure_connected().await?;
let resp = self let resp = self