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
|
/target
|
||||||
/result
|
/result
|
||||||
.direnv/
|
.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 = [
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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::{
|
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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)
|
||||||
|
|||||||
@@ -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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::Track;
|
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 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>;
|
||||||
|
|
||||||
|
|||||||
+20
-17
@@ -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,
|
||||||
@@ -109,10 +107,7 @@ 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,7 +140,11 @@ 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());
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user