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:
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
/result
|
||||
.direnv/
|
||||
config.yaml
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/nix/store/ykac3kn52hv5lqhffvg55zghgrvlgd0r-pre-commit-config.json
|
||||
Generated
+28
@@ -667,9 +667,12 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
@@ -949,6 +952,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -1074,6 +1083,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1408,6 +1430,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -12,6 +12,7 @@ axum = "0.8"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
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"
|
||||
thiserror = "2"
|
||||
url = "2"
|
||||
roxmltree = "0.20"
|
||||
base64 = "0.22"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -0,0 +1,92 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::indexer::{MusicSearchCriteria, SearchResult};
|
||||
use crate::services::IndexerInfo;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_indexers))
|
||||
.route("/search", post(search))
|
||||
.route("/:name/test", get(test_indexer))
|
||||
}
|
||||
|
||||
async fn list_indexers(State(state): State<AppState>) -> Json<Vec<IndexerInfo>> {
|
||||
let state = state.read().await;
|
||||
Json(state.indexer_service.list_indexers())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub limit: Option<u32>,
|
||||
pub indexer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResponse {
|
||||
pub results: Vec<SearchResult>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
async fn search(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SearchRequest>,
|
||||
) -> Result<Json<SearchResponse>, (StatusCode, String)> {
|
||||
let mut criteria = MusicSearchCriteria::new(&req.artist);
|
||||
|
||||
if let Some(album) = &req.album {
|
||||
criteria = criteria.with_album(album);
|
||||
}
|
||||
if let Some(year) = req.year {
|
||||
criteria = criteria.with_year(year);
|
||||
}
|
||||
if let Some(limit) = req.limit {
|
||||
criteria = criteria.with_limit(limit);
|
||||
}
|
||||
|
||||
let state = state.read().await;
|
||||
let results = state
|
||||
.indexer_service
|
||||
.search(&criteria, req.indexer.as_deref())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
let total = results.len();
|
||||
Ok(Json(SearchResponse { results, total }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TestResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
async fn test_indexer(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<TestResponse>, (StatusCode, Json<TestResponse>)> {
|
||||
let state = state.read().await;
|
||||
|
||||
match state.indexer_service.test_indexer(&name).await {
|
||||
Ok(()) => Ok(Json(TestResponse {
|
||||
success: true,
|
||||
message: "Connection successful".to_string(),
|
||||
})),
|
||||
Err(e) => Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(TestResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
+28
-17
@@ -1,3 +1,6 @@
|
||||
mod indexer_controller;
|
||||
mod torrent_controller;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
@@ -18,20 +21,22 @@ pub fn routes(state: AppState) -> Router {
|
||||
.route("/tracks/:id", delete(delete_track))
|
||||
.route("/tracks/search", get(search_tracks))
|
||||
.route("/stats", get(get_stats))
|
||||
.nest("/indexers", indexer_controller::routes())
|
||||
.nest("/torrents", torrent_controller::routes())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn list_tracks(State(state): State<AppState>) -> Json<Vec<Track>> {
|
||||
let agg = state.read().await;
|
||||
Json(agg.get_all().to_vec())
|
||||
let state = state.read().await;
|
||||
Json(state.aggregator.get_all().to_vec())
|
||||
}
|
||||
|
||||
async fn create_track(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateTrack>,
|
||||
) -> (StatusCode, Json<Track>) {
|
||||
let mut agg = state.write().await;
|
||||
let track = agg.add_track(input.into());
|
||||
let mut state = state.write().await;
|
||||
let track = state.aggregator.add_track(input.into());
|
||||
(StatusCode::CREATED, Json(track))
|
||||
}
|
||||
|
||||
@@ -39,19 +44,18 @@ async fn get_track(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Track>, StatusCode> {
|
||||
let agg = state.read().await;
|
||||
agg.get_by_id(id)
|
||||
let state = state.read().await;
|
||||
state
|
||||
.aggregator
|
||||
.get_by_id(id)
|
||||
.cloned()
|
||||
.map(Json)
|
||||
.ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
async fn delete_track(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> StatusCode {
|
||||
let mut agg = state.write().await;
|
||||
if agg.delete(id) {
|
||||
async fn delete_track(State(state): State<AppState>, Path(id): Path<Uuid>) -> StatusCode {
|
||||
let mut state = state.write().await;
|
||||
if state.aggregator.delete(id) {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::NOT_FOUND
|
||||
@@ -67,8 +71,15 @@ async fn search_tracks(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Json<Vec<Track>> {
|
||||
let agg = state.read().await;
|
||||
Json(agg.search_by_artist(&query.artist).into_iter().cloned().collect())
|
||||
let state = state.read().await;
|
||||
Json(
|
||||
state
|
||||
.aggregator
|
||||
.search_by_artist(&query.artist)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -78,9 +89,9 @@ struct Stats {
|
||||
}
|
||||
|
||||
async fn get_stats(State(state): State<AppState>) -> Json<Stats> {
|
||||
let agg = state.read().await;
|
||||
let state = state.read().await;
|
||||
Json(Stats {
|
||||
track_count: agg.get_all().len(),
|
||||
total_duration_secs: agg.total_duration(),
|
||||
track_count: state.aggregator.get_all().len(),
|
||||
total_duration_secs: state.aggregator.total_duration(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::torrent::TorrentInfo;
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_torrents))
|
||||
.route("/:hash", get(get_torrent))
|
||||
.route("/:hash", delete(remove_torrent))
|
||||
.route("/:hash/pause", post(pause_torrent))
|
||||
.route("/:hash/resume", post(resume_torrent))
|
||||
.route("/add/url", post(add_torrent_url))
|
||||
.route("/add/file", post(add_torrent_file))
|
||||
.route("/status", get(connection_status))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TorrentListResponse {
|
||||
pub torrents: Vec<TorrentInfo>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
async fn list_torrents(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<TorrentListResponse>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
let torrents = state
|
||||
.torrent_service
|
||||
.list_torrents()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
let total = torrents.len();
|
||||
Ok(Json(TorrentListResponse { torrents, total }))
|
||||
}
|
||||
|
||||
async fn get_torrent(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<Json<TorrentInfo>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
state
|
||||
.torrent_service
|
||||
.get_torrent(&hash)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
let status = if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
(status, e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RemoveQuery {
|
||||
#[serde(default)]
|
||||
pub delete_files: bool,
|
||||
}
|
||||
|
||||
async fn remove_torrent(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
Query(query): Query<RemoveQuery>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
state
|
||||
.torrent_service
|
||||
.remove_torrent(&hash, query.delete_files)
|
||||
.await
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
|
||||
}
|
||||
|
||||
async fn pause_torrent(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
state
|
||||
.torrent_service
|
||||
.pause_torrent(&hash)
|
||||
.await
|
||||
.map(|_| StatusCode::OK)
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
|
||||
}
|
||||
|
||||
async fn resume_torrent(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
state
|
||||
.torrent_service
|
||||
.resume_torrent(&hash)
|
||||
.await
|
||||
.map(|_| StatusCode::OK)
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddUrlRequest {
|
||||
pub url: String,
|
||||
pub save_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AddResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
async fn add_torrent_url(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AddUrlRequest>,
|
||||
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
|
||||
let state = state.read().await;
|
||||
state
|
||||
.torrent_service
|
||||
.add_torrent_url(&req.url, req.save_path.as_deref())
|
||||
.await
|
||||
.map(|_| {
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(AddResponse {
|
||||
success: true,
|
||||
message: "Torrent added successfully".to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(AddResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddFileRequest {
|
||||
pub torrent_base64: String,
|
||||
pub save_path: Option<String>,
|
||||
}
|
||||
|
||||
async fn add_torrent_file(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AddFileRequest>,
|
||||
) -> Result<(StatusCode, Json<AddResponse>), (StatusCode, Json<AddResponse>)> {
|
||||
use base64::Engine;
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD
|
||||
.decode(&req.torrent_base64)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(AddResponse {
|
||||
success: false,
|
||||
message: format!("Invalid base64: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let state = state.read().await;
|
||||
state
|
||||
.torrent_service
|
||||
.add_torrent_file(&data, req.save_path.as_deref())
|
||||
.await
|
||||
.map(|_| {
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(AddResponse {
|
||||
success: true,
|
||||
message: "Torrent added successfully".to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(AddResponse {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusResponse {
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
async fn connection_status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
let state = state.read().await;
|
||||
Json(StatusResponse {
|
||||
connected: state.torrent_service.is_connected().await,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("failed to read config file: {0}")]
|
||||
ReadError(#[from] std::io::Error),
|
||||
|
||||
#[error("failed to parse config: {0}")]
|
||||
ParseError(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
pub database: DatabaseConfig,
|
||||
pub indexers: Vec<IndexerConfig>,
|
||||
pub torrent: TorrentConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IndexerConfig {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TorrentConfig {
|
||||
pub qbittorrent: Option<QBittorrentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct QBittorrentConfig {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: Config = serde_yaml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
mod search;
|
||||
mod torznab;
|
||||
|
||||
pub use search::{MusicSearchCriteria, SearchResult};
|
||||
pub use torznab::TorznabIndexer;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IndexerError {
|
||||
#[error("authentication failed: invalid API key")]
|
||||
AuthenticationFailed,
|
||||
|
||||
#[error("rate limited: retry after {0} seconds")]
|
||||
RateLimited(u64),
|
||||
|
||||
#[error("indexer unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("search failed: {0}")]
|
||||
SearchFailed(String),
|
||||
|
||||
#[error("parse error: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Indexer: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
|
||||
fn supports_music_search(&self) -> bool;
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
criteria: &MusicSearchCriteria,
|
||||
) -> Result<Vec<SearchResult>, IndexerError>;
|
||||
|
||||
async fn test_connection(&self) -> Result<(), IndexerError>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicSearchCriteria {
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
impl MusicSearchCriteria {
|
||||
pub fn new(artist: impl Into<String>) -> Self {
|
||||
Self {
|
||||
artist: artist.into(),
|
||||
album: None,
|
||||
year: None,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_album(mut self, album: impl Into<String>) -> Self {
|
||||
self.album = Some(album.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_year(mut self, year: u32) -> Self {
|
||||
self.year = Some(year);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_limit(mut self, limit: u32) -> Self {
|
||||
self.limit = limit;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_offset(mut self, offset: u32) -> Self {
|
||||
self.offset = offset;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn clean_artist(&self) -> String {
|
||||
normalize_query(&self.artist)
|
||||
}
|
||||
|
||||
pub fn clean_album(&self) -> Option<String> {
|
||||
self.album.as_ref().map(|a| normalize_query(a))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_query(s: &str) -> String {
|
||||
s.trim().replace("\"", "").replace("'", "").to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub guid: String,
|
||||
pub title: String,
|
||||
pub download_url: String,
|
||||
pub info_url: Option<String>,
|
||||
pub size: u64,
|
||||
pub publish_date: Option<String>,
|
||||
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub label: Option<String>,
|
||||
|
||||
pub seeders: Option<u32>,
|
||||
pub leechers: Option<u32>,
|
||||
pub grabs: Option<u32>,
|
||||
|
||||
pub infohash: Option<String>,
|
||||
pub magnet_url: Option<String>,
|
||||
|
||||
pub indexer: String,
|
||||
pub categories: Vec<u32>,
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use url::Url;
|
||||
|
||||
use super::search::{MusicSearchCriteria, SearchResult};
|
||||
use super::{Indexer, IndexerError};
|
||||
|
||||
pub struct TorznabIndexer {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
api_key: String,
|
||||
categories: Vec<u32>,
|
||||
http: Client,
|
||||
}
|
||||
|
||||
impl TorznabIndexer {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
base_url: &str,
|
||||
api_key: impl Into<String>,
|
||||
) -> Result<Self, IndexerError> {
|
||||
let base_url = Url::parse(base_url)
|
||||
.map_err(|e| IndexerError::SearchFailed(format!("invalid URL: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
name: name.into(),
|
||||
base_url,
|
||||
api_key: api_key.into(),
|
||||
categories: vec![3000, 3010, 3040],
|
||||
http: Client::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_categories(mut self, categories: Vec<u32>) -> Self {
|
||||
self.categories = categories;
|
||||
self
|
||||
}
|
||||
|
||||
fn build_search_url(&self, criteria: &MusicSearchCriteria) -> Result<Url, IndexerError> {
|
||||
let mut url = self.base_url.clone();
|
||||
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("t", "music");
|
||||
query.append_pair("apikey", &self.api_key);
|
||||
query.append_pair("extended", "1");
|
||||
|
||||
let cats = self
|
||||
.categories
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
query.append_pair("cat", &cats);
|
||||
|
||||
query.append_pair("artist", &criteria.clean_artist());
|
||||
|
||||
if let Some(album) = criteria.clean_album() {
|
||||
query.append_pair("album", &album);
|
||||
}
|
||||
|
||||
if let Some(year) = criteria.year {
|
||||
query.append_pair("year", &year.to_string());
|
||||
}
|
||||
|
||||
query.append_pair("limit", &criteria.limit.to_string());
|
||||
query.append_pair("offset", &criteria.offset.to_string());
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn parse_response(&self, xml: &str) -> Result<Vec<SearchResult>, IndexerError> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let doc = roxmltree::Document::parse(xml)
|
||||
.map_err(|e| IndexerError::ParseError(format!("XML parse error: {}", e)))?;
|
||||
|
||||
if let Some(error) = doc.descendants().find(|n| n.has_tag_name("error")) {
|
||||
let code = error.attribute("code").unwrap_or("0");
|
||||
let desc = error.attribute("description").unwrap_or("Unknown error");
|
||||
|
||||
if code.starts_with("1") {
|
||||
return Err(IndexerError::AuthenticationFailed);
|
||||
}
|
||||
return Err(IndexerError::SearchFailed(desc.to_string()));
|
||||
}
|
||||
|
||||
for item in doc.descendants().filter(|n| n.has_tag_name("item")) {
|
||||
let result = self.parse_item(&item)?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn parse_item(&self, item: &roxmltree::Node) -> Result<SearchResult, IndexerError> {
|
||||
let get_text = |tag: &str| -> Option<String> {
|
||||
item.children()
|
||||
.find(|n| n.has_tag_name(tag))
|
||||
.and_then(|n| n.text())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let get_attr = |name: &str| -> Option<String> {
|
||||
item.children()
|
||||
.filter(|n| n.has_tag_name("attr"))
|
||||
.find(|n| n.attribute("name") == Some(name))
|
||||
.and_then(|n| n.attribute("value"))
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let guid = get_text("guid").unwrap_or_default();
|
||||
let title = get_text("title").unwrap_or_default();
|
||||
let download_url = get_text("link").unwrap_or_default();
|
||||
|
||||
let size = get_attr("size")
|
||||
.or_else(|| {
|
||||
item.children()
|
||||
.find(|n| n.has_tag_name("enclosure"))
|
||||
.and_then(|n| n.attribute("length"))
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut categories = Vec::new();
|
||||
for attr in item.children().filter(|n| n.has_tag_name("attr")) {
|
||||
if attr.attribute("name") == Some("category") {
|
||||
if let Some(val) = attr.attribute("value") {
|
||||
if let Ok(cat) = val.parse::<u32>() {
|
||||
categories.push(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchResult {
|
||||
guid,
|
||||
title,
|
||||
download_url,
|
||||
info_url: get_text("comments"),
|
||||
size,
|
||||
publish_date: get_text("pubDate"),
|
||||
artist: get_attr("artist"),
|
||||
album: get_attr("album"),
|
||||
year: get_attr("year").and_then(|s| s.parse().ok()),
|
||||
label: get_attr("label"),
|
||||
seeders: get_attr("seeders").and_then(|s| s.parse().ok()),
|
||||
leechers: get_attr("leechers").and_then(|s| s.parse().ok()),
|
||||
grabs: get_attr("grabs").and_then(|s| s.parse().ok()),
|
||||
infohash: get_attr("infohash"),
|
||||
magnet_url: get_attr("magneturl"),
|
||||
indexer: self.name.clone(),
|
||||
categories,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Indexer for TorznabIndexer {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn supports_music_search(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
criteria: &MusicSearchCriteria,
|
||||
) -> Result<Vec<SearchResult>, IndexerError> {
|
||||
let url = self.build_search_url(criteria)?;
|
||||
|
||||
let response = self.http.get(url).send().await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60);
|
||||
return Err(IndexerError::RateLimited(retry_after));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(IndexerError::Unavailable(format!(
|
||||
"HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let xml = response.text().await?;
|
||||
self.parse_response(&xml)
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<(), IndexerError> {
|
||||
let mut url = self.base_url.clone();
|
||||
url.query_pairs_mut()
|
||||
.append_pair("t", "caps")
|
||||
.append_pair("apikey", &self.api_key);
|
||||
|
||||
let response = self.http.get(url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(IndexerError::Unavailable(format!(
|
||||
"HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let xml = response.text().await?;
|
||||
|
||||
if xml.contains("<error") && xml.contains("code=\"1") {
|
||||
return Err(IndexerError::AuthenticationFailed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod indexer;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod torrent;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct AppServices {
|
||||
pub aggregator: services::Aggregator,
|
||||
pub indexer_service: services::IndexerService,
|
||||
pub torrent_service: services::TorrentService,
|
||||
}
|
||||
|
||||
impl AppServices {
|
||||
pub fn new(
|
||||
indexer_service: services::IndexerService,
|
||||
torrent_service: services::TorrentService,
|
||||
) -> Self {
|
||||
Self {
|
||||
aggregator: services::Aggregator::new(),
|
||||
indexer_service,
|
||||
torrent_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppState = Arc<RwLock<AppServices>>;
|
||||
+51
-10
@@ -1,20 +1,16 @@
|
||||
mod api;
|
||||
mod models;
|
||||
mod services;
|
||||
mod torrent;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use axum::Router;
|
||||
use music_agregator::{
|
||||
api, config,
|
||||
services::{IndexerService, TorrentService},
|
||||
AppServices, AppState,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::services::Aggregator;
|
||||
|
||||
pub type AppState = Arc<RwLock<Aggregator>>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
@@ -22,7 +18,52 @@ async fn main() {
|
||||
.with(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let state: AppState = Arc::new(RwLock::new(Aggregator::new()));
|
||||
let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "config.yaml".to_string());
|
||||
let config = match config::Config::load(&config_path) {
|
||||
Ok(cfg) => {
|
||||
tracing::info!("loaded config from {}", config_path);
|
||||
cfg
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to load config: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let indexer_service = match IndexerService::from_config(&config.indexers) {
|
||||
Ok(svc) => {
|
||||
tracing::info!("initialized {} indexer(s)", config.indexers.len());
|
||||
svc
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to initialize indexer service: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let torrent_service = if let Some(qbit_config) = &config.torrent.qbittorrent {
|
||||
match TorrentService::from_qbittorrent_config(qbit_config).await {
|
||||
Ok(svc) => {
|
||||
tracing::info!("connected to qBittorrent at {}", qbit_config.url);
|
||||
svc
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"failed to connect to qBittorrent: {} (continuing without torrent client)",
|
||||
e
|
||||
);
|
||||
TorrentService::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("no torrent client configured");
|
||||
TorrentService::new()
|
||||
};
|
||||
|
||||
let state: AppState = Arc::new(RwLock::new(AppServices::new(
|
||||
indexer_service,
|
||||
torrent_service,
|
||||
)));
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::IndexerConfig;
|
||||
use crate::indexer::{Indexer, IndexerError, MusicSearchCriteria, SearchResult, TorznabIndexer};
|
||||
|
||||
pub struct IndexerService {
|
||||
indexers: HashMap<String, Arc<dyn Indexer>>,
|
||||
}
|
||||
|
||||
impl IndexerService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
indexers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_config(configs: &[IndexerConfig]) -> Result<Self, IndexerError> {
|
||||
let mut service = Self::new();
|
||||
|
||||
for config in configs {
|
||||
let indexer = TorznabIndexer::new(&config.name, &config.url, &config.api_key)?;
|
||||
service.add_indexer(Arc::new(indexer));
|
||||
}
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
pub fn add_indexer(&mut self, indexer: Arc<dyn Indexer>) {
|
||||
self.indexers.insert(indexer.name().to_string(), indexer);
|
||||
}
|
||||
|
||||
pub fn get_indexer(&self, name: &str) -> Option<Arc<dyn Indexer>> {
|
||||
self.indexers.get(name).cloned()
|
||||
}
|
||||
|
||||
pub fn list_indexers(&self) -> Vec<IndexerInfo> {
|
||||
self.indexers
|
||||
.values()
|
||||
.map(|i| IndexerInfo {
|
||||
name: i.name().to_string(),
|
||||
supports_music: i.supports_music_search(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
&self,
|
||||
criteria: &MusicSearchCriteria,
|
||||
indexer_name: Option<&str>,
|
||||
) -> Result<Vec<SearchResult>, IndexerError> {
|
||||
match indexer_name {
|
||||
Some(name) => {
|
||||
let indexer = self.indexers.get(name).ok_or_else(|| {
|
||||
IndexerError::Unavailable(format!("indexer not found: {}", name))
|
||||
})?;
|
||||
indexer.search(criteria).await
|
||||
}
|
||||
None => {
|
||||
let mut all_results = Vec::new();
|
||||
for indexer in self.indexers.values() {
|
||||
if indexer.supports_music_search() {
|
||||
match indexer.search(criteria).await {
|
||||
Ok(results) => all_results.extend(results),
|
||||
Err(e) => {
|
||||
tracing::warn!("indexer {} failed: {}", indexer.name(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_indexer(&self, name: &str) -> Result<(), IndexerError> {
|
||||
let indexer = self
|
||||
.indexers
|
||||
.get(name)
|
||||
.ok_or_else(|| IndexerError::Unavailable(format!("indexer not found: {}", name)))?;
|
||||
indexer.test_connection().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IndexerService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct IndexerInfo {
|
||||
pub name: String,
|
||||
pub supports_music: bool,
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
mod indexer_service;
|
||||
mod torrent_service;
|
||||
|
||||
pub use indexer_service::{IndexerInfo, IndexerService};
|
||||
pub use torrent_service::TorrentService;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::Track;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::QBittorrentConfig;
|
||||
use crate::torrent::{QBittorrentClient, TorrentClient, TorrentClientError, TorrentInfo};
|
||||
|
||||
pub struct TorrentService {
|
||||
client: Option<Arc<dyn TorrentClient>>,
|
||||
}
|
||||
|
||||
impl TorrentService {
|
||||
pub fn new() -> Self {
|
||||
Self { client: None }
|
||||
}
|
||||
|
||||
pub async fn from_qbittorrent_config(
|
||||
config: &QBittorrentConfig,
|
||||
) -> Result<Self, TorrentClientError> {
|
||||
let mut client = QBittorrentClient::new(&config.url, &config.username, &config.password)?;
|
||||
client.connect().await?;
|
||||
|
||||
Ok(Self {
|
||||
client: Some(Arc::new(client)),
|
||||
})
|
||||
}
|
||||
|
||||
fn client(&self) -> Result<&Arc<dyn TorrentClient>, TorrentClientError> {
|
||||
self.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| TorrentClientError::ConnectionFailed("no client configured".into()))
|
||||
}
|
||||
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.client.is_some()
|
||||
}
|
||||
|
||||
pub async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
|
||||
self.client()?.list_torrents().await
|
||||
}
|
||||
|
||||
pub async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError> {
|
||||
self.client()?.get_torrent(hash).await
|
||||
}
|
||||
|
||||
pub async fn add_torrent_url(
|
||||
&self,
|
||||
url: &str,
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.client()?.add_torrent_url(url, save_path).await
|
||||
}
|
||||
|
||||
pub async fn add_torrent_file(
|
||||
&self,
|
||||
data: &[u8],
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.client()?.add_torrent_file(data, save_path).await
|
||||
}
|
||||
|
||||
pub async fn remove_torrent(
|
||||
&self,
|
||||
hash: &str,
|
||||
delete_files: bool,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.client()?.remove_torrent(hash, delete_files).await
|
||||
}
|
||||
|
||||
pub async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
self.client()?.pause_torrent(hash).await
|
||||
}
|
||||
|
||||
pub async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> {
|
||||
self.client()?.resume_torrent(hash).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TorrentService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -57,11 +57,23 @@ pub trait TorrentClient: Send + Sync {
|
||||
|
||||
async fn get_torrent(&self, hash: &str) -> Result<TorrentInfo, TorrentClientError>;
|
||||
|
||||
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError>;
|
||||
async fn add_torrent_url(
|
||||
&self,
|
||||
url: &str,
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError>;
|
||||
async fn add_torrent_file(
|
||||
&self,
|
||||
torrent_data: &[u8],
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError>;
|
||||
async fn remove_torrent(
|
||||
&self,
|
||||
hash: &str,
|
||||
delete_files: bool,
|
||||
) -> Result<(), TorrentClientError>;
|
||||
|
||||
async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>;
|
||||
|
||||
|
||||
+20
-17
@@ -29,12 +29,10 @@ struct QBTorrent {
|
||||
|
||||
impl QBittorrentClient {
|
||||
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, TorrentClientError> {
|
||||
let base_url = Url::parse(base_url)
|
||||
.map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
|
||||
let base_url =
|
||||
Url::parse(base_url).map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?;
|
||||
|
||||
let http = Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()?;
|
||||
let http = Client::builder().cookie_store(true).build()?;
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
@@ -109,10 +107,7 @@ impl TorrentClient for QBittorrentClient {
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), TorrentClientError> {
|
||||
self.http
|
||||
.post(self.api_url("/auth/logout"))
|
||||
.send()
|
||||
.await?;
|
||||
self.http.post(self.api_url("/auth/logout")).send().await?;
|
||||
|
||||
*self.connected.write().await = false;
|
||||
Ok(())
|
||||
@@ -121,11 +116,7 @@ impl TorrentClient for QBittorrentClient {
|
||||
async fn list_torrents(&self) -> Result<Vec<TorrentInfo>, TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.api_url("/torrents/info"))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = self.http.get(self.api_url("/torrents/info")).send().await?;
|
||||
|
||||
let torrents: Vec<QBTorrent> = resp.json().await?;
|
||||
Ok(torrents.into_iter().map(Self::map_torrent).collect())
|
||||
@@ -149,7 +140,11 @@ impl TorrentClient for QBittorrentClient {
|
||||
.ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string()))
|
||||
}
|
||||
|
||||
async fn add_torrent_url(&self, url: &str, save_path: Option<&str>) -> Result<(), TorrentClientError> {
|
||||
async fn add_torrent_url(
|
||||
&self,
|
||||
url: &str,
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let mut form = multipart::Form::new().text("urls", url.to_string());
|
||||
@@ -174,7 +169,11 @@ impl TorrentClient for QBittorrentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError> {
|
||||
async fn add_torrent_file(
|
||||
&self,
|
||||
torrent_data: &[u8],
|
||||
save_path: Option<&str>,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let part = multipart::Part::bytes(torrent_data.to_vec())
|
||||
@@ -204,7 +203,11 @@ impl TorrentClient for QBittorrentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError> {
|
||||
async fn remove_torrent(
|
||||
&self,
|
||||
hash: &str,
|
||||
delete_files: bool,
|
||||
) -> Result<(), TorrentClientError> {
|
||||
self.ensure_connected().await?;
|
||||
|
||||
let resp = self
|
||||
|
||||
Reference in New Issue
Block a user