diff --git a/.gitignore b/.gitignore index b39fec2..d77122b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /result .direnv/ +config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 120000 index 0000000..57544a0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1 @@ +/nix/store/ykac3kn52hv5lqhffvg55zghgrvlgd0r-pre-commit-config.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 31b2633..1ee075e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a08f33b..bcd2661 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..d3f1211 --- /dev/null +++ b/config.example.yaml @@ -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" diff --git a/containers/docker-compose.yml b/containers/docker-compose.yml new file mode 100644 index 0000000..e5977c2 --- /dev/null +++ b/containers/docker-compose.yml @@ -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: diff --git a/docs/erd.puml b/docs/erd.puml new file mode 100644 index 0000000..eac8539 --- /dev/null +++ b/docs/erd.puml @@ -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 <> + -- + foreign_artist_id : TEXT <> + 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 <> + -- + metadata_id : UUID <> + quality_profile_id : UUID <> + metadata_profile_id : UUID <> + root_folder_id : UUID <> + -- + path : TEXT + monitored : BOOLEAN + monitor_new_items : TEXT + -- + last_info_sync : TIMESTAMPTZ + added_at : TIMESTAMPTZ + } + + entity "albums" { + * id : UUID <> + -- + artist_metadata_id : UUID <> + -- + foreign_album_id : TEXT <> + 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 <> + -- + album_id : UUID <> + -- + foreign_release_id : TEXT <> + 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 <> + -- + album_release_id : UUID <> + artist_metadata_id : UUID <> + track_file_id : UUID <> + -- + foreign_track_id : TEXT <> + title : TEXT + track_number : INT + disc_number : INT + duration_ms : INT + explicit : BOOLEAN + } + + entity "track_files" { + * id : UUID <> + -- + album_id : UUID <> + -- + 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 <> + -- + name : TEXT <> + cutoff : INT + items : JSONB + upgrade_allowed : BOOLEAN + } + + entity "metadata_profiles" { + * id : UUID <> + -- + name : TEXT <> + primary_album_types : JSONB + secondary_album_types : JSONB + release_statuses : JSONB + } + + entity "root_folders" { + * id : UUID <> + -- + name : TEXT + path : TEXT <> + default_quality_profile_id : UUID <> + default_metadata_profile_id : UUID <> + } + + entity "indexers" { + * id : UUID <> + -- + name : TEXT + implementation : TEXT + settings : JSONB + enable_rss : BOOLEAN + enable_search : BOOLEAN + priority : INT + } + + entity "download_clients" { + * id : UUID <> + -- + name : TEXT + implementation : TEXT + settings : JSONB + protocol : TEXT + priority : INT + enabled : BOOLEAN + } +} + +' ══════════════════════════════════════════════════════════════ +' DOWNLOAD TRACKING +' ══════════════════════════════════════════════════════════════ + +package "Download Tracking" #E8F5E9 { + entity "wanted_albums" { + * id : UUID <> + -- + album_id : UUID <> <> + -- + priority : INT + search_count : INT + last_searched_at : TIMESTAMPTZ + added_at : TIMESTAMPTZ + } + + entity "download_queue" { + * id : UUID <> + -- + artist_id : UUID <> + album_id : UUID <> + -- + 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 <> + -- + artist_id : UUID <> + album_id : UUID <> + -- + 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 diff --git a/docs/research/lidarr/erd.puml b/docs/research/lidarr/erd.puml new file mode 100644 index 0000000..e4fd54f --- /dev/null +++ b/docs/research/lidarr/erd.puml @@ -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 <> + -- + foreign_artist_id : TEXT <> + 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 <> + -- + metadata_id : UUID <> + quality_profile_id : UUID <> + metadata_profile_id : UUID <> + root_folder_id : UUID <> + -- + path : TEXT + monitored : BOOLEAN + monitor_new_items : TEXT + -- + last_info_sync : TIMESTAMPTZ + added_at : TIMESTAMPTZ + tags : INT[] + } + + entity "albums" { + * id : UUID <> + -- + artist_metadata_id : UUID <> + -- + foreign_album_id : TEXT <> + 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 <> + -- + album_id : UUID <> + -- + foreign_release_id : TEXT <> + 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 <> + -- + album_release_id : UUID <> + artist_metadata_id : UUID <> + track_file_id : UUID <> + -- + foreign_track_id : TEXT <> + foreign_recording_id : TEXT + title : TEXT + track_number : INT + disc_number : INT + duration_ms : INT + explicit : BOOLEAN + ratings : JSONB + } + + entity "track_files" { + * id : UUID <> + -- + album_id : UUID <> + -- + 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 <> + -- + name : TEXT <> + cutoff : INT + items : JSONB + upgrade_allowed : BOOLEAN + min_format_score : INT + cutoff_format_score : INT + } + + entity "metadata_profiles" { + * id : UUID <> + -- + name : TEXT <> + primary_album_types : JSONB + secondary_album_types : JSONB + release_statuses : JSONB + } + + entity "root_folders" { + * id : UUID <> + -- + name : TEXT + path : TEXT <> + default_quality_profile_id : UUID <> + default_metadata_profile_id : UUID <> + default_monitor_option : TEXT + default_tags : INT[] + } + + entity "tags" { + * id : SERIAL <> + -- + label : TEXT <> + } + + entity "indexers" { + * id : UUID <> + -- + name : TEXT + implementation : TEXT + settings : JSONB + enable_rss : BOOLEAN + enable_search : BOOLEAN + priority : INT + tags : INT[] + } + + entity "download_clients" { + * id : UUID <> + -- + 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 <> + -- + artist_id : UUID <> + album_id : UUID <> + -- + 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 <> + -- + artist_id : UUID <> + album_id : UUID <> + -- + title : TEXT + release : JSONB + parsed_album_info : JSONB + reason : TEXT + additional_info : JSONB + -- + added_at : TIMESTAMPTZ + } + + entity "download_history" { + * id : UUID <> + -- + artist_id : UUID <> + album_id : UUID <> + -- + 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 <> + -- + artist_id : UUID <> + 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 <> + -- + artist_id : UUID <> + album_id : UUID <> + track_id : UUID <> + -- + 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 <> + -- + 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 diff --git a/docs/research/lidarr/research_summary.md b/docs/research/lidarr/research_summary.md new file mode 100644 index 0000000..42a334f --- /dev/null +++ b/docs/research/lidarr/research_summary.md @@ -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` diff --git a/src/api/indexer_controller.rs b/src/api/indexer_controller.rs new file mode 100644 index 0000000..b3aa6b3 --- /dev/null +++ b/src/api/indexer_controller.rs @@ -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 { + Router::new() + .route("/", get(list_indexers)) + .route("/search", post(search)) + .route("/:name/test", get(test_indexer)) +} + +async fn list_indexers(State(state): State) -> Json> { + let state = state.read().await; + Json(state.indexer_service.list_indexers()) +} + +#[derive(Debug, Deserialize)] +pub struct SearchRequest { + pub artist: String, + pub album: Option, + pub year: Option, + pub limit: Option, + pub indexer: Option, +} + +#[derive(Debug, Serialize)] +pub struct SearchResponse { + pub results: Vec, + pub total: usize, +} + +async fn search( + State(state): State, + Json(req): Json, +) -> Result, (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, + Path(name): Path, +) -> Result, (StatusCode, Json)> { + 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(), + }), + )), + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 33db271..d3d354c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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) -> Json> { - 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, Json(input): Json, ) -> (StatusCode, Json) { - 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, Path(id): Path, ) -> Result, 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, - Path(id): Path, -) -> StatusCode { - let mut agg = state.write().await; - if agg.delete(id) { +async fn delete_track(State(state): State, Path(id): Path) -> 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, Query(query): Query, ) -> Json> { - 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) -> Json { - 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(), }) } diff --git a/src/api/torrent_controller.rs b/src/api/torrent_controller.rs new file mode 100644 index 0000000..a7ffca7 --- /dev/null +++ b/src/api/torrent_controller.rs @@ -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 { + 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, + pub total: usize, +} + +async fn list_torrents( + State(state): State, +) -> Result, (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, + Path(hash): Path, +) -> Result, (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, + Path(hash): Path, + Query(query): Query, +) -> Result { + 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, + Path(hash): Path, +) -> Result { + 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, + Path(hash): Path, +) -> Result { + 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, +} + +#[derive(Debug, Serialize)] +pub struct AddResponse { + pub success: bool, + pub message: String, +} + +async fn add_torrent_url( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + 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, +} + +async fn add_torrent_file( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + 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) -> Json { + let state = state.read().await; + Json(StatusResponse { + connected: state.torrent_service.is_connected().await, + }) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..11fe174 --- /dev/null +++ b/src/config/mod.rs @@ -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, + 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, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct QBittorrentConfig { + pub url: String, + pub username: String, + pub password: String, +} + +impl Config { + pub fn load>(path: P) -> Result { + let content = fs::read_to_string(path)?; + let config: Config = serde_yaml::from_str(&content)?; + Ok(config) + } +} diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs new file mode 100644 index 0000000..0b970c6 --- /dev/null +++ b/src/indexer/mod.rs @@ -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, IndexerError>; + + async fn test_connection(&self) -> Result<(), IndexerError>; +} diff --git a/src/indexer/search.rs b/src/indexer/search.rs new file mode 100644 index 0000000..c72c62e --- /dev/null +++ b/src/indexer/search.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +pub struct MusicSearchCriteria { + pub artist: String, + pub album: Option, + pub year: Option, + pub limit: u32, + pub offset: u32, +} + +impl MusicSearchCriteria { + pub fn new(artist: impl Into) -> Self { + Self { + artist: artist.into(), + album: None, + year: None, + limit: 100, + offset: 0, + } + } + + pub fn with_album(mut self, album: impl Into) -> 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 { + 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, + pub size: u64, + pub publish_date: Option, + + pub artist: Option, + pub album: Option, + pub year: Option, + pub label: Option, + + pub seeders: Option, + pub leechers: Option, + pub grabs: Option, + + pub infohash: Option, + pub magnet_url: Option, + + pub indexer: String, + pub categories: Vec, +} diff --git a/src/indexer/torznab.rs b/src/indexer/torznab.rs new file mode 100644 index 0000000..ec2d117 --- /dev/null +++ b/src/indexer/torznab.rs @@ -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, + http: Client, +} + +impl TorznabIndexer { + pub fn new( + name: impl Into, + base_url: &str, + api_key: impl Into, + ) -> Result { + 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) -> Self { + self.categories = categories; + self + } + + fn build_search_url(&self, criteria: &MusicSearchCriteria) -> Result { + 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::>() + .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, 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 { + let get_text = |tag: &str| -> Option { + 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 { + 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::() { + 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, 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(" Self { + Self { + aggregator: services::Aggregator::new(), + indexer_service, + torrent_service, + } + } +} + +pub type AppState = Arc>; diff --git a/src/main.rs b/src/main.rs index 6d84c77..623b938 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>; - #[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) diff --git a/src/services/indexer_service.rs b/src/services/indexer_service.rs new file mode 100644 index 0000000..6521c4a --- /dev/null +++ b/src/services/indexer_service.rs @@ -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>, +} + +impl IndexerService { + pub fn new() -> Self { + Self { + indexers: HashMap::new(), + } + } + + pub fn from_config(configs: &[IndexerConfig]) -> Result { + 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) { + self.indexers.insert(indexer.name().to_string(), indexer); + } + + pub fn get_indexer(&self, name: &str) -> Option> { + self.indexers.get(name).cloned() + } + + pub fn list_indexers(&self) -> Vec { + 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, 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, +} diff --git a/src/services/mod.rs b/src/services/mod.rs index d60b40d..a4bce9f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -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; diff --git a/src/services/torrent_service.rs b/src/services/torrent_service.rs new file mode 100644 index 0000000..2747fce --- /dev/null +++ b/src/services/torrent_service.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use crate::config::QBittorrentConfig; +use crate::torrent::{QBittorrentClient, TorrentClient, TorrentClientError, TorrentInfo}; + +pub struct TorrentService { + client: Option>, +} + +impl TorrentService { + pub fn new() -> Self { + Self { client: None } + } + + pub async fn from_qbittorrent_config( + config: &QBittorrentConfig, + ) -> Result { + 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, 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, TorrentClientError> { + self.client()?.list_torrents().await + } + + pub async fn get_torrent(&self, hash: &str) -> Result { + 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() + } +} diff --git a/src/torrent/client.rs b/src/torrent/client.rs index c69f893..2a8a8b7 100644 --- a/src/torrent/client.rs +++ b/src/torrent/client.rs @@ -57,11 +57,23 @@ pub trait TorrentClient: Send + Sync { async fn get_torrent(&self, hash: &str) -> Result; - 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>; diff --git a/src/torrent/qbittorrent.rs b/src/torrent/qbittorrent.rs index dc2d19b..8cd99bd 100644 --- a/src/torrent/qbittorrent.rs +++ b/src/torrent/qbittorrent.rs @@ -29,12 +29,10 @@ struct QBTorrent { impl QBittorrentClient { pub fn new(base_url: &str, username: &str, password: &str) -> Result { - 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, @@ -99,7 +97,7 @@ impl TorrentClient for QBittorrentClient { .await?; let text = resp.text().await?; - + if text == "Ok." { *self.connected.write().await = true; Ok(()) @@ -109,11 +107,8 @@ 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, 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 = resp.json().await?; Ok(torrents.into_iter().map(Self::map_torrent).collect()) @@ -149,11 +140,15 @@ impl TorrentClient for QBittorrentClient { .ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string())) } - 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()); - + if let Some(path) = save_path { form = form.text("savepath", path.to_string()); } @@ -174,7 +169,11 @@ impl TorrentClient for QBittorrentClient { } } - async fn add_torrent_file(&self, torrent_data: &[u8], save_path: Option<&str>) -> Result<(), TorrentClientError> { + async fn add_torrent_file( + &self, + torrent_data: &[u8], + save_path: Option<&str>, + ) -> Result<(), TorrentClientError> { self.ensure_connected().await?; let part = multipart::Part::bytes(torrent_data.to_vec()) @@ -183,7 +182,7 @@ impl TorrentClient for QBittorrentClient { .map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?; let mut form = multipart::Form::new().part("torrents", part); - + if let Some(path) = save_path { form = form.text("savepath", path.to_string()); } @@ -204,7 +203,11 @@ impl TorrentClient for QBittorrentClient { } } - async fn remove_torrent(&self, hash: &str, delete_files: bool) -> Result<(), TorrentClientError> { + async fn remove_torrent( + &self, + hash: &str, + delete_files: bool, + ) -> Result<(), TorrentClientError> { self.ensure_connected().await?; let resp = self