diff --git a/Cargo.lock b/Cargo.lock index 7a6d72a..8523ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -577,6 +577,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dateparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ef451feee09ae5ecd8a02e738bd9adee9266b8fa9b44e22d3ce968d8694238" +dependencies = [ + "anyhow", + "chrono", + "lazy_static", + "regex", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1382,6 +1394,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1513,7 +1534,7 @@ dependencies = [ "hex", "http", "intervaltree", - "itertools", + "itertools 0.14.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1592,7 +1613,7 @@ dependencies = [ "data-encoding", "directories", "hex", - "itertools", + "itertools 0.14.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1645,7 +1666,7 @@ dependencies = [ "bitvec", "byteorder", "bytes", - "itertools", + "itertools 0.14.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1829,6 +1850,9 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes", + "chrono", + "dateparser", + "itertools 0.15.0", "librqbit", "pbjson", "pbjson-types", @@ -2011,7 +2035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af22d08a625a2213a78dbb0ffa253318c5c79ce3133d32d296655a7bdfb02095" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "prost", "prost-types", ] @@ -2146,7 +2170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -2165,7 +2189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", diff --git a/Cargo.toml b/Cargo.toml index fd1ac21..363037e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,5 +37,8 @@ anyhow = "1" sqlx = { version = "0.9", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls", "json", "uuid"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } +dateparser = "0.2" +chrono = "0.4" +itertools = "0.15.0" [workspace] \ No newline at end of file diff --git a/backlog.md b/backlog.md new file mode 100644 index 0000000..aae16ec --- /dev/null +++ b/backlog.md @@ -0,0 +1,19 @@ +# Backlog + + + + + +- [ ] [P2] Every restart of devenv - recreates Jackett container *(priority: P2)* + + + + + +| Timestamp | Item ID | Action | Details | +|-----------|---------|--------|---------| +| 2026-07-19T12:57:24.051Z | n-ddy1wp | item_created | Every restart of devenv - recreates Jackett container | + + + + diff --git a/config.toml b/config.toml index 23a8b04..6d47097 100644 --- a/config.toml +++ b/config.toml @@ -2,7 +2,7 @@ address = "[::1]:50051" [indexer] address = "http://localhost:9117" -api = "pj47116b3clxpf4h1b2fu9vrj07pmfa6" +api = "feuxigkphd3z1q9yruxc3grp2fcs2reh" [torrent] address = "unix:///run/user/1000/devenv-ae542bd/torad.sock" [metadata] diff --git a/dev/Music Agregator/Jackett/Search.yml b/dev/Music Agregator/Jackett/Search.yml index d05b682..51bc02b 100644 --- a/dev/Music Agregator/Jackett/Search.yml +++ b/dev/Music Agregator/Jackett/Search.yml @@ -5,10 +5,10 @@ info: http: method: GET - url: http://localhost:9117/api/v2.0/indexers/all/results/torznab/api?apikey=pj47116b3clxpf4h1b2fu9vrj07pmfa6&t=search&q=ДДТ + url: http://localhost:9117/api/v2.0/indexers/all/results/torznab/api?apikey=feuxigkphd3z1q9yruxc3grp2fcs2reh&t=search&q=ДДТ params: - name: apikey - value: pj47116b3clxpf4h1b2fu9vrj07pmfa6 + value: feuxigkphd3z1q9yruxc3grp2fcs2reh type: query - name: t value: search diff --git a/dev/Music Agregator/Tora/List.yml b/dev/Music Agregator/Tora/List.yml new file mode 100644 index 0000000..59174d4 --- /dev/null +++ b/dev/Music Agregator/Tora/List.yml @@ -0,0 +1,11 @@ +info: + name: List + type: grpc + seq: 1 + +grpc: + url: localhost:50051 + method: /torrent.Torrents/List + methodType: unary + message: "{}" + auth: inherit diff --git a/dev/Music Agregator/Tora/Remove.yml b/dev/Music Agregator/Tora/Remove.yml new file mode 100644 index 0000000..965f3ea --- /dev/null +++ b/dev/Music Agregator/Tora/Remove.yml @@ -0,0 +1,15 @@ +info: + name: Remove + type: grpc + seq: 2 + +grpc: + url: localhost:50051 + method: /torrent.Torrents/Remove + methodType: unary + message: |- + { + "id": "7a76f972-5bce-4266-8e29-870a29d297e1", + "delete_files": true + } + auth: inherit diff --git a/dev/Music Agregator/Tora/folder.yml b/dev/Music Agregator/Tora/folder.yml new file mode 100644 index 0000000..3435570 --- /dev/null +++ b/dev/Music Agregator/Tora/folder.yml @@ -0,0 +1,7 @@ +info: + name: Tora + type: folder + seq: 6 + +request: + auth: inherit diff --git a/proto/search.proto b/proto/torrent_manager/v1/torrent_manager.proto similarity index 52% rename from proto/search.proto rename to proto/torrent_manager/v1/torrent_manager.proto index 3a710aa..33b6c21 100644 --- a/proto/search.proto +++ b/proto/torrent_manager/v1/torrent_manager.proto @@ -1,10 +1,11 @@ syntax = "proto3"; -package torrent_manager; +package torrent_manager.v1; service TorrentManager { rpc Search(SearchRequest) returns (SearchResponse); -} + rpc Add(AddRequest) returns (AddResponse); +} message SearchRequest { @@ -18,4 +19,15 @@ message SearchResponse { message SearchItem { string name = 1; string url = 2; + // -1 when the indexer did not report the value. + int32 seeders = 3; + int32 leechers = 4; +} + +message AddRequest { + string url = 1; +} + +message AddResponse { + } diff --git a/src/db.rs b/src/db.rs index 67de73e..8e5f3d7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,7 +3,7 @@ use sqlx::Row; use sqlx::postgres::{PgPool, PgPoolOptions}; use uuid::Uuid; -use crate::torrent_parser::{AudioFormat, ParsedTorrent, ReleaseSource, ReleaseType}; +use crate::torrent::{AudioFormat, ParsedTorrent, ReleaseSource, ReleaseType}; const SCHEMA_SQL: &str = include_str!("../db/schema.sql"); @@ -160,6 +160,7 @@ pub async fn get(pool: &PgPool, info_hash: &str) -> Result Ok(Some(ParsedTorrent { info_hash: row.try_get("info_hash")?, raw_title: row.try_get("raw_title")?, + url: String::new(), artist: row.try_get("artist")?, album: row.try_get("album")?, year: row.try_get::("year")? as u32, @@ -180,6 +181,8 @@ pub async fn get(pool: &PgPool, info_hash: &str) -> Result has_cover_art: row.try_get("has_cover_art")?, has_cue_sheet: row.try_get("has_cue_sheet")?, has_rip_log: row.try_get("has_rip_log")?, + seeders: -1, + leechers: -1, parsed_successfully: row.try_get("parsed_successfully")?, parse_errors: parse_errors.0, })) diff --git a/src/generated/torrent_manager/torrent_manager.rs b/src/generated/torrent_manager/torrent_manager.rs deleted file mode 100644 index ff2bb20..0000000 --- a/src/generated/torrent_manager/torrent_manager.rs +++ /dev/null @@ -1,100 +0,0 @@ -// @generated -// This file is @generated by prost-build. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SearchRequest { - #[prost(string, tag = "1")] - pub name: ::prost::alloc::string::String, -} -impl ::prost::Name for SearchRequest { - const NAME: &'static str = "SearchRequest"; - const PACKAGE: &'static str = "torrent_manager"; - fn full_name() -> ::prost::alloc::string::String { - "torrent_manager.SearchRequest".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/torrent_manager.SearchRequest".into() - } -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SearchResponse { - #[prost(message, repeated, tag = "1")] - pub item: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for SearchResponse { - const NAME: &'static str = "SearchResponse"; - const PACKAGE: &'static str = "torrent_manager"; - fn full_name() -> ::prost::alloc::string::String { - "torrent_manager.SearchResponse".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/torrent_manager.SearchResponse".into() - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SearchItem { - #[prost(string, tag = "1")] - pub name: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub url: ::prost::alloc::string::String, -} -impl ::prost::Name for SearchItem { - const NAME: &'static str = "SearchItem"; - const PACKAGE: &'static str = "torrent_manager"; - fn full_name() -> ::prost::alloc::string::String { - "torrent_manager.SearchItem".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/torrent_manager.SearchItem".into() - } -} -/// Encoded file descriptor set for the `torrent_manager` package -pub const FILE_DESCRIPTOR_SET: &[u8] = &[ - 0x0a, 0xc0, 0x05, 0x0a, 0x0c, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x12, 0x0f, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x72, 0x22, 0x23, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x41, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x04, 0x69, 0x74, 0x65, - 0x6d, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, - 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x49, 0x74, 0x65, 0x6d, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x32, 0x0a, 0x0a, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, - 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x32, 0x5b, - 0x0a, 0x0e, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, - 0x12, 0x49, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1e, 0x2e, 0x74, 0x6f, 0x72, - 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x74, 0x6f, 0x72, - 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4a, 0x9d, 0x03, 0x0a, 0x06, - 0x12, 0x04, 0x00, 0x00, 0x14, 0x01, 0x0a, 0x08, 0x0a, 0x01, 0x0c, 0x12, 0x03, 0x00, 0x00, 0x12, - 0x0a, 0x08, 0x0a, 0x01, 0x02, 0x12, 0x03, 0x02, 0x00, 0x18, 0x0a, 0x0a, 0x0a, 0x02, 0x06, 0x00, - 0x12, 0x04, 0x04, 0x00, 0x06, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x06, 0x00, 0x01, 0x12, 0x03, 0x04, - 0x08, 0x16, 0x0a, 0x0b, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x00, 0x12, 0x03, 0x05, 0x02, 0x35, 0x0a, - 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x05, 0x06, 0x0c, 0x0a, 0x0c, 0x0a, - 0x05, 0x06, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03, 0x05, 0x0d, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, - 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x05, 0x25, 0x33, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x00, 0x12, - 0x04, 0x09, 0x00, 0x0b, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x00, 0x01, 0x12, 0x03, 0x09, 0x08, - 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x05, 0x12, 0x03, 0x0a, 0x02, 0x08, 0x0a, - 0x0b, 0x0a, 0x04, 0x04, 0x00, 0x02, 0x00, 0x12, 0x03, 0x0a, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, - 0x04, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x0a, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, - 0x02, 0x00, 0x03, 0x12, 0x03, 0x0a, 0x10, 0x11, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x01, 0x12, 0x04, - 0x0d, 0x00, 0x0f, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x01, 0x01, 0x12, 0x03, 0x0d, 0x08, 0x16, - 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x04, 0x12, 0x03, 0x0e, 0x02, 0x0a, 0x0a, 0x0b, - 0x0a, 0x04, 0x04, 0x01, 0x02, 0x00, 0x12, 0x03, 0x0e, 0x02, 0x1f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, - 0x01, 0x02, 0x00, 0x06, 0x12, 0x03, 0x0e, 0x0b, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, - 0x00, 0x01, 0x12, 0x03, 0x0e, 0x16, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x03, - 0x12, 0x03, 0x0e, 0x1d, 0x1e, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x02, 0x12, 0x04, 0x11, 0x00, 0x14, - 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x02, 0x01, 0x12, 0x03, 0x11, 0x08, 0x12, 0x0a, 0x0c, 0x0a, - 0x05, 0x04, 0x02, 0x02, 0x00, 0x05, 0x12, 0x03, 0x12, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, - 0x02, 0x02, 0x00, 0x12, 0x03, 0x12, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, - 0x01, 0x12, 0x03, 0x12, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x03, 0x12, - 0x03, 0x12, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x01, 0x05, 0x12, 0x03, 0x13, - 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x01, 0x12, 0x03, 0x13, 0x02, 0x11, 0x0a, - 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x01, 0x01, 0x12, 0x03, 0x13, 0x09, 0x0c, 0x0a, 0x0c, 0x0a, - 0x05, 0x04, 0x02, 0x02, 0x01, 0x03, 0x12, 0x03, 0x13, 0x0f, 0x10, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, -]; -include!("torrent_manager.serde.rs"); -include!("torrent_manager.tonic.rs"); -// @@protoc_insertion_point(module) diff --git a/src/generated/torrent_manager/v1/torrent_manager.v1.rs b/src/generated/torrent_manager/v1/torrent_manager.v1.rs new file mode 100644 index 0000000..0510b38 --- /dev/null +++ b/src/generated/torrent_manager/v1/torrent_manager.v1.rs @@ -0,0 +1,165 @@ +// @generated +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SearchRequest { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, +} +impl ::prost::Name for SearchRequest { + const NAME: &'static str = "SearchRequest"; + const PACKAGE: &'static str = "torrent_manager.v1"; + fn full_name() -> ::prost::alloc::string::String { + "torrent_manager.v1.SearchRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent_manager.v1.SearchRequest".into() + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SearchResponse { + #[prost(message, repeated, tag = "1")] + pub item: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for SearchResponse { + const NAME: &'static str = "SearchResponse"; + const PACKAGE: &'static str = "torrent_manager.v1"; + fn full_name() -> ::prost::alloc::string::String { + "torrent_manager.v1.SearchResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent_manager.v1.SearchResponse".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SearchItem { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub url: ::prost::alloc::string::String, + /// -1 when the indexer did not report the value. + #[prost(int32, tag = "3")] + pub seeders: i32, + #[prost(int32, tag = "4")] + pub leechers: i32, +} +impl ::prost::Name for SearchItem { + const NAME: &'static str = "SearchItem"; + const PACKAGE: &'static str = "torrent_manager.v1"; + fn full_name() -> ::prost::alloc::string::String { + "torrent_manager.v1.SearchItem".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent_manager.v1.SearchItem".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddRequest { + #[prost(string, tag = "1")] + pub url: ::prost::alloc::string::String, +} +impl ::prost::Name for AddRequest { + const NAME: &'static str = "AddRequest"; + const PACKAGE: &'static str = "torrent_manager.v1"; + fn full_name() -> ::prost::alloc::string::String { + "torrent_manager.v1.AddRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent_manager.v1.AddRequest".into() + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddResponse {} +impl ::prost::Name for AddResponse { + const NAME: &'static str = "AddResponse"; + const PACKAGE: &'static str = "torrent_manager.v1"; + fn full_name() -> ::prost::alloc::string::String { + "torrent_manager.v1.AddResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent_manager.v1.AddResponse".into() + } +} +/// Encoded file descriptor set for the `torrent_manager.v1` package +pub const FILE_DESCRIPTOR_SET: &[u8] = &[ + 0x0a, 0xd3, 0x09, 0x0a, 0x28, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x12, 0x74, + 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x22, 0x23, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x44, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, + 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x68, 0x0a, 0x0a, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, + 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, + 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x65, 0x64, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x07, 0x73, 0x65, 0x65, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x65, + 0x65, 0x63, 0x68, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x6c, 0x65, + 0x65, 0x63, 0x68, 0x65, 0x72, 0x73, 0x22, 0x1e, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x0d, 0x0a, 0x0b, 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xa9, 0x01, 0x0a, 0x0e, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, + 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x12, 0x4f, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x12, 0x21, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x03, 0x41, 0x64, 0x64, + 0x12, 0x1e, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1f, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x4a, 0xda, 0x05, 0x0a, 0x06, 0x12, 0x04, 0x00, 0x00, 0x20, 0x01, 0x0a, 0x08, 0x0a, 0x01, + 0x0c, 0x12, 0x03, 0x00, 0x00, 0x12, 0x0a, 0x08, 0x0a, 0x01, 0x02, 0x12, 0x03, 0x02, 0x00, 0x1b, + 0x0a, 0x0a, 0x0a, 0x02, 0x06, 0x00, 0x12, 0x04, 0x04, 0x00, 0x07, 0x01, 0x0a, 0x0a, 0x0a, 0x03, + 0x06, 0x00, 0x01, 0x12, 0x03, 0x04, 0x08, 0x16, 0x0a, 0x0b, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x00, + 0x12, 0x03, 0x05, 0x02, 0x35, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, + 0x05, 0x06, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03, 0x05, 0x0d, + 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x05, 0x25, 0x33, 0x0a, + 0x0b, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x01, 0x12, 0x03, 0x06, 0x02, 0x2c, 0x0a, 0x0c, 0x0a, 0x05, + 0x06, 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, 0x06, 0x06, 0x09, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, + 0x02, 0x01, 0x02, 0x12, 0x03, 0x06, 0x0a, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, + 0x03, 0x12, 0x03, 0x06, 0x1f, 0x2a, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x00, 0x12, 0x04, 0x0a, 0x00, + 0x0c, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x00, 0x01, 0x12, 0x03, 0x0a, 0x08, 0x15, 0x0a, 0x0c, + 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x05, 0x12, 0x03, 0x0b, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, + 0x04, 0x00, 0x02, 0x00, 0x12, 0x03, 0x0b, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, + 0x00, 0x01, 0x12, 0x03, 0x0b, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x03, + 0x12, 0x03, 0x0b, 0x10, 0x11, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x01, 0x12, 0x04, 0x0e, 0x00, 0x10, + 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x01, 0x01, 0x12, 0x03, 0x0e, 0x08, 0x16, 0x0a, 0x0c, 0x0a, + 0x05, 0x04, 0x01, 0x02, 0x00, 0x04, 0x12, 0x03, 0x0f, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, + 0x01, 0x02, 0x00, 0x12, 0x03, 0x0f, 0x02, 0x1f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, + 0x06, 0x12, 0x03, 0x0f, 0x0b, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x01, 0x12, + 0x03, 0x0f, 0x16, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x03, 0x12, 0x03, 0x0f, + 0x1d, 0x1e, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x02, 0x12, 0x04, 0x12, 0x00, 0x18, 0x01, 0x0a, 0x0a, + 0x0a, 0x03, 0x04, 0x02, 0x01, 0x12, 0x03, 0x12, 0x08, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, + 0x02, 0x00, 0x05, 0x12, 0x03, 0x13, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x00, + 0x12, 0x03, 0x13, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x01, 0x12, 0x03, + 0x13, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x03, 0x12, 0x03, 0x13, 0x10, + 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x01, 0x05, 0x12, 0x03, 0x14, 0x02, 0x08, 0x0a, + 0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x01, 0x12, 0x03, 0x14, 0x02, 0x11, 0x0a, 0x0c, 0x0a, 0x05, + 0x04, 0x02, 0x02, 0x01, 0x01, 0x12, 0x03, 0x14, 0x09, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, + 0x02, 0x01, 0x03, 0x12, 0x03, 0x14, 0x0f, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x02, + 0x05, 0x12, 0x03, 0x16, 0x02, 0x07, 0x0a, 0x3c, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x02, 0x12, 0x03, + 0x16, 0x02, 0x14, 0x1a, 0x2f, 0x20, 0x2d, 0x31, 0x20, 0x77, 0x68, 0x65, 0x6e, 0x20, 0x74, 0x68, + 0x65, 0x20, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x72, 0x20, 0x64, 0x69, 0x64, 0x20, 0x6e, 0x6f, + 0x74, 0x20, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x74, 0x68, 0x65, 0x20, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x02, 0x01, 0x12, 0x03, 0x16, + 0x08, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x02, 0x03, 0x12, 0x03, 0x16, 0x12, 0x13, + 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x03, 0x05, 0x12, 0x03, 0x17, 0x02, 0x07, 0x0a, 0x0b, + 0x0a, 0x04, 0x04, 0x02, 0x02, 0x03, 0x12, 0x03, 0x17, 0x02, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, + 0x02, 0x02, 0x03, 0x01, 0x12, 0x03, 0x17, 0x08, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, + 0x03, 0x03, 0x12, 0x03, 0x17, 0x13, 0x14, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x03, 0x12, 0x04, 0x1a, + 0x00, 0x1c, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x03, 0x01, 0x12, 0x03, 0x1a, 0x08, 0x12, 0x0a, + 0x0c, 0x0a, 0x05, 0x04, 0x03, 0x02, 0x00, 0x05, 0x12, 0x03, 0x1b, 0x02, 0x08, 0x0a, 0x0b, 0x0a, + 0x04, 0x04, 0x03, 0x02, 0x00, 0x12, 0x03, 0x1b, 0x02, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x03, + 0x02, 0x00, 0x01, 0x12, 0x03, 0x1b, 0x09, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x03, 0x02, 0x00, + 0x03, 0x12, 0x03, 0x1b, 0x0f, 0x10, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x04, 0x12, 0x04, 0x1e, 0x00, + 0x20, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x04, 0x01, 0x12, 0x03, 0x1e, 0x08, 0x13, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +]; +include!("torrent_manager.v1.serde.rs"); +include!("torrent_manager.v1.tonic.rs"); +// @@protoc_insertion_point(module) diff --git a/src/generated/torrent_manager/torrent_manager.serde.rs b/src/generated/torrent_manager/v1/torrent_manager.v1.serde.rs similarity index 54% rename from src/generated/torrent_manager/torrent_manager.serde.rs rename to src/generated/torrent_manager/v1/torrent_manager.v1.serde.rs index a37e576..ae1726a 100644 --- a/src/generated/torrent_manager/torrent_manager.serde.rs +++ b/src/generated/torrent_manager/v1/torrent_manager.v1.serde.rs @@ -1,4 +1,167 @@ // @generated +impl serde::Serialize for AddRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.url.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("torrent_manager.v1.AddRequest", len)?; + if !self.url.is_empty() { + struct_ser.serialize_field("url", &self.url)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for AddRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &["url"]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Url, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "url" => Ok(GeneratedField::Url), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = AddRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent_manager.v1.AddRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut url__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Url => { + if url__.is_some() { + return Err(serde::de::Error::duplicate_field("url")); + } + url__ = Some(map_.next_value()?); + } + } + } + Ok(AddRequest { + url: url__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("torrent_manager.v1.AddRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for AddResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let len = 0; + let struct_ser = serializer.serialize_struct("torrent_manager.v1.AddResponse", len)?; + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for AddResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField {} + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + Err(serde::de::Error::unknown_field(value, FIELDS)) + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = AddResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent_manager.v1.AddResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + while map_.next_key::()?.is_some() { + let _ = map_.next_value::()?; + } + Ok(AddResponse {}) + } + } + deserializer.deserialize_struct("torrent_manager.v1.AddResponse", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for SearchItem { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -13,13 +176,25 @@ impl serde::Serialize for SearchItem { if !self.url.is_empty() { len += 1; } - let mut struct_ser = serializer.serialize_struct("torrent_manager.SearchItem", len)?; + if self.seeders != 0 { + len += 1; + } + if self.leechers != 0 { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("torrent_manager.v1.SearchItem", len)?; if !self.name.is_empty() { struct_ser.serialize_field("name", &self.name)?; } if !self.url.is_empty() { struct_ser.serialize_field("url", &self.url)?; } + if self.seeders != 0 { + struct_ser.serialize_field("seeders", &self.seeders)?; + } + if self.leechers != 0 { + struct_ser.serialize_field("leechers", &self.leechers)?; + } struct_ser.end() } } @@ -29,12 +204,14 @@ impl<'de> serde::Deserialize<'de> for SearchItem { where D: serde::Deserializer<'de>, { - const FIELDS: &[&str] = &["name", "url"]; + const FIELDS: &[&str] = &["name", "url", "seeders", "leechers"]; #[allow(clippy::enum_variant_names)] enum GeneratedField { Name, Url, + Seeders, + Leechers, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -61,6 +238,8 @@ impl<'de> serde::Deserialize<'de> for SearchItem { match value { "name" => Ok(GeneratedField::Name), "url" => Ok(GeneratedField::Url), + "seeders" => Ok(GeneratedField::Seeders), + "leechers" => Ok(GeneratedField::Leechers), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -73,7 +252,7 @@ impl<'de> serde::Deserialize<'de> for SearchItem { type Value = SearchItem; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct torrent_manager.SearchItem") + formatter.write_str("struct torrent_manager.v1.SearchItem") } fn visit_map(self, mut map_: V) -> std::result::Result @@ -82,6 +261,8 @@ impl<'de> serde::Deserialize<'de> for SearchItem { { let mut name__ = None; let mut url__ = None; + let mut seeders__ = None; + let mut leechers__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::Name => { @@ -96,15 +277,35 @@ impl<'de> serde::Deserialize<'de> for SearchItem { } url__ = Some(map_.next_value()?); } + GeneratedField::Seeders => { + if seeders__.is_some() { + return Err(serde::de::Error::duplicate_field("seeders")); + } + seeders__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } + GeneratedField::Leechers => { + if leechers__.is_some() { + return Err(serde::de::Error::duplicate_field("leechers")); + } + leechers__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } } } Ok(SearchItem { name: name__.unwrap_or_default(), url: url__.unwrap_or_default(), + seeders: seeders__.unwrap_or_default(), + leechers: leechers__.unwrap_or_default(), }) } } - deserializer.deserialize_struct("torrent_manager.SearchItem", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("torrent_manager.v1.SearchItem", FIELDS, GeneratedVisitor) } } impl serde::Serialize for SearchRequest { @@ -118,7 +319,8 @@ impl serde::Serialize for SearchRequest { if !self.name.is_empty() { len += 1; } - let mut struct_ser = serializer.serialize_struct("torrent_manager.SearchRequest", len)?; + let mut struct_ser = + serializer.serialize_struct("torrent_manager.v1.SearchRequest", len)?; if !self.name.is_empty() { struct_ser.serialize_field("name", &self.name)?; } @@ -173,7 +375,7 @@ impl<'de> serde::Deserialize<'de> for SearchRequest { type Value = SearchRequest; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct torrent_manager.SearchRequest") + formatter.write_str("struct torrent_manager.v1.SearchRequest") } fn visit_map(self, mut map_: V) -> std::result::Result @@ -196,7 +398,11 @@ impl<'de> serde::Deserialize<'de> for SearchRequest { }) } } - deserializer.deserialize_struct("torrent_manager.SearchRequest", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct( + "torrent_manager.v1.SearchRequest", + FIELDS, + GeneratedVisitor, + ) } } impl serde::Serialize for SearchResponse { @@ -210,7 +416,8 @@ impl serde::Serialize for SearchResponse { if !self.item.is_empty() { len += 1; } - let mut struct_ser = serializer.serialize_struct("torrent_manager.SearchResponse", len)?; + let mut struct_ser = + serializer.serialize_struct("torrent_manager.v1.SearchResponse", len)?; if !self.item.is_empty() { struct_ser.serialize_field("item", &self.item)?; } @@ -265,7 +472,7 @@ impl<'de> serde::Deserialize<'de> for SearchResponse { type Value = SearchResponse; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct torrent_manager.SearchResponse") + formatter.write_str("struct torrent_manager.v1.SearchResponse") } fn visit_map(self, mut map_: V) -> std::result::Result @@ -288,6 +495,10 @@ impl<'de> serde::Deserialize<'de> for SearchResponse { }) } } - deserializer.deserialize_struct("torrent_manager.SearchResponse", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct( + "torrent_manager.v1.SearchResponse", + FIELDS, + GeneratedVisitor, + ) } } diff --git a/src/generated/torrent_manager/torrent_manager.tonic.rs b/src/generated/torrent_manager/v1/torrent_manager.v1.tonic.rs similarity index 76% rename from src/generated/torrent_manager/torrent_manager.tonic.rs rename to src/generated/torrent_manager/v1/torrent_manager.v1.tonic.rs index 5b410fd..b07c75c 100644 --- a/src/generated/torrent_manager/torrent_manager.tonic.rs +++ b/src/generated/torrent_manager/v1/torrent_manager.v1.tonic.rs @@ -98,10 +98,27 @@ pub mod torrent_manager_client { })?; let codec = tonic_prost::ProstCodec::default(); let path = - http::uri::PathAndQuery::from_static("/torrent_manager.TorrentManager/Search"); + http::uri::PathAndQuery::from_static("/torrent_manager.v1.TorrentManager/Search"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new( + "torrent_manager.v1.TorrentManager", + "Search", + )); + self.inner.unary(req, path, codec).await + } + pub async fn add( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner.ready().await.map_err(|e| { + tonic::Status::unknown(format!("Service was not ready: {}", e.into())) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = + http::uri::PathAndQuery::from_static("/torrent_manager.v1.TorrentManager/Add"); let mut req = request.into_request(); req.extensions_mut() - .insert(GrpcMethod::new("torrent_manager.TorrentManager", "Search")); + .insert(GrpcMethod::new("torrent_manager.v1.TorrentManager", "Add")); self.inner.unary(req, path, codec).await } } @@ -123,6 +140,10 @@ pub mod torrent_manager_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + async fn add( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; } #[derive(Debug)] pub struct TorrentManagerServer { @@ -197,7 +218,7 @@ pub mod torrent_manager_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { - "/torrent_manager.TorrentManager/Search" => { + "/torrent_manager.v1.TorrentManager/Search" => { #[allow(non_camel_case_types)] struct SearchSvc(pub Arc); impl tonic::server::UnaryService for SearchSvc { @@ -235,6 +256,44 @@ pub mod torrent_manager_server { }; Box::pin(fut) } + "/torrent_manager.v1.TorrentManager/Add" => { + #[allow(non_camel_case_types)] + struct AddSvc(pub Arc); + impl tonic::server::UnaryService for AddSvc { + type Response = super::AddResponse; + type Future = BoxFuture, tonic::Status>; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = + async move { ::add(&inner, request).await }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = AddSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => Box::pin(async move { let mut response = http::Response::new(tonic::body::Body::default()); let headers = response.headers_mut(); @@ -264,7 +323,7 @@ pub mod torrent_manager_server { } } /// Generated gRPC service name - pub const SERVICE_NAME: &str = "torrent_manager.TorrentManager"; + pub const SERVICE_NAME: &str = "torrent_manager.v1.TorrentManager"; impl tonic::server::NamedService for TorrentManagerServer { const NAME: &'static str = SERVICE_NAME; } diff --git a/src/magnet_resolver.rs b/src/magnet_resolver.rs index dfc186e..253c240 100644 --- a/src/magnet_resolver.rs +++ b/src/magnet_resolver.rs @@ -3,7 +3,7 @@ //! Turns a `magnet:` URI into the same bencoded torrent bytes a `.torrent` //! download would yield, by resolving metadata over the BitTorrent DHT and //! the BEP-9 `ut_metadata` peer extension. The bytes are then handed to -//! [`crate::torrent_parser::TorrentParser`] so magnet and torrent sources are +//! [`crate::torrent::TorrentParser`] so magnet and torrent sources are //! parsed identically. use std::sync::Arc; diff --git a/src/main.rs b/src/main.rs index 18f9d55..b00b307 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,6 @@ mod magnet_resolver; mod metadata; mod music; mod torrent; -mod torrent_manager; -mod torrent_parser; mod generated { pub mod hello { @@ -20,7 +18,7 @@ mod generated { } pub mod torrent_manager { - include!("generated/torrent_manager/torrent_manager.rs"); + include!("generated/torrent_manager/v1/torrent_manager.v1.rs"); } pub mod metadata { @@ -50,8 +48,7 @@ use indexer::Jackett; use metadata::MetadataService; use music::MusicService; use tonic_health::pb::health_server::HealthServer; -use torrent::TorrentsService; -use torrent_manager::TorrentMananagerService; +use torrent::{TorrentMananagerService, TorrentsService}; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/src/music.rs b/src/music.rs index cebd273..4233c3d 100644 --- a/src/music.rs +++ b/src/music.rs @@ -1,3 +1,5 @@ +use chrono::Datelike; +use itertools::Itertools; use tonic::{Request, Response, Status}; use crate::{ @@ -6,14 +8,14 @@ use crate::{ Album, SearchAlbumsRequest, metadata_service_server::MetadataService as MetadataSvc, }, music::{ - MonitorAlbumRequest, MonitorAlbumResponse, music_service_server::MusicService as Music, + MonitorAlbumRequest, MonitorAlbumResponse, Quality, + music_service_server::MusicService as Music, }, - torrent_manager::{SearchRequest, torrent_manager_server::TorrentManager}, + torrent_manager::{AddRequest, SearchRequest, torrent_manager_server::TorrentManager}, }, magnet_resolver::MagnetResolver, metadata::MetadataService, - torrent_manager::TorrentMananagerService, - torrent_parser::{ParsedTorrent, TorrentParser}, + torrent::{AudioFormat, ParsedTorrent, TorrentMananagerService, TorrentParser}, }; pub struct MusicService { @@ -58,6 +60,9 @@ impl Music for MusicService { dbg!(&search_response); let album: &Album = &search_response.get_ref().albums[0]; + let release_year = dateparser::parse(&album.release_date) + .map(|dt| dt.year() as u32) + .map_err(|e| Status::invalid_argument(format!("invalid release_date: {e}")))?; let name = format!( "{} {}", album.artists[0].artist.clone().unwrap().name, @@ -90,6 +95,32 @@ impl Music for MusicService { .await; dbg!(&parsed_torrents); - Ok(Response::new(MonitorAlbumResponse::default())) + // I don't expect it to fail, the request should fail immediately at parse time rather then here + let quality = Quality::try_from(request.get_ref().quality).unwrap(); + let matching: Option = parsed_torrents + .into_iter() + .filter(|pt| pt.year == release_year && quality_matches(quality, pt.format)) + .sorted() + .next(); + + return match matching { + Some(matched) => { + let request: AddRequest = AddRequest { url: matched.url }; + self.torrent_manager + .add(Request::new(request)) + .await + .map(|_| Response::new(MonitorAlbumResponse::default())) + } + None => Err(Status::not_found( + "no release matched the requested quality or has enough seeders", + )), + }; + } +} + +fn quality_matches(quality: Quality, format: AudioFormat) -> bool { + match quality { + Quality::Mp3 => format == AudioFormat::MP3, + Quality::Flac => format == AudioFormat::FLAC, } } diff --git a/src/torrent.rs b/src/torrent.rs index 407d185..03b6a4f 100644 --- a/src/torrent.rs +++ b/src/torrent.rs @@ -1,3 +1,24 @@ +//! Torrent subsystem: gRPC [`TorrentsService`] proxy plus the release parser +//! ([`TorrentParser`]) that turns Jackett/torrent metainfo into structured +//! [`ParsedTorrent`] records. + +mod audio_format; +mod bencode; +mod fetch; +mod helpers; +mod manager; +mod parsed; +mod parser; +mod release_source; +mod release_type; + +pub use audio_format::AudioFormat; +pub use manager::TorrentMananagerService; +pub use parsed::ParsedTorrent; +pub use parser::TorrentParser; +pub use release_source::ReleaseSource; +pub use release_type::ReleaseType; + use crate::generated::torrent::{torrents_client::TorrentsClient, torrents_server::Torrents, *}; use tonic::transport::{Channel, Endpoint}; diff --git a/src/torrent/audio_format.rs b/src/torrent/audio_format.rs new file mode 100644 index 0000000..e27f9bb --- /dev/null +++ b/src/torrent/audio_format.rs @@ -0,0 +1,113 @@ +/// Audio container/codec format detected from file extensions or title tokens. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum AudioFormat { + #[default] + Unknown, + FLAC, + MP3, + AAC, + APE, + WavPack, + ALAC, + OGG, + WAV, +} + +impl AudioFormat { + /// Lossless (uncompressed or losslessly compressed) formats. + pub fn is_lossless(self) -> bool { + matches!( + self, + Self::FLAC | Self::APE | Self::WavPack | Self::ALAC | Self::WAV + ) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::FLAC => "FLAC", + Self::MP3 => "MP3", + Self::AAC => "AAC", + Self::APE => "APE", + Self::WavPack => "WavPack", + Self::ALAC => "ALAC", + Self::OGG => "OGG", + Self::WAV => "WAV", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "FLAC" => Self::FLAC, + "MP3" => Self::MP3, + "AAC" => Self::AAC, + "APE" => Self::APE, + "WavPack" => Self::WavPack, + "ALAC" => Self::ALAC, + "OGG" => Self::OGG, + "WAV" => Self::WAV, + _ => Self::Unknown, + } + } + + /// Relative quality rank — higher is better (lossless outranks lossy, + /// FLAC at the top, MP3 near the bottom). Non-obvious ordering used by + /// `ParsedTorrent`'s `Ord`. + pub fn quality_rank(self) -> u32 { + match self { + Self::Unknown => 0, + Self::MP3 => 1, + Self::OGG => 2, + Self::AAC => 3, + Self::WavPack => 4, + Self::APE => 5, + Self::ALAC => 6, + Self::WAV => 7, + Self::FLAC => 8, + } + } +} + +pub(super) fn format_for_extension(ext: &str) -> Option { + match ext { + ".flac" => Some(AudioFormat::FLAC), + ".mp3" => Some(AudioFormat::MP3), + ".aac" => Some(AudioFormat::AAC), + ".m4a" => Some(AudioFormat::AAC), + ".ape" => Some(AudioFormat::APE), + ".wv" => Some(AudioFormat::WavPack), + ".alac" => Some(AudioFormat::ALAC), + ".ogg" => Some(AudioFormat::OGG), + ".wav" => Some(AudioFormat::WAV), + _ => None, + } +} + +/// Conservative title-token format detection: only matches codec names that +/// are unlikely to appear as substrings of other words. +pub(super) fn detect_format_from_title(title: &str) -> AudioFormat { + let lower = title.to_ascii_lowercase(); + if lower.contains("wavpack") { + return AudioFormat::WavPack; + } + if lower.contains("flac") { + return AudioFormat::FLAC; + } + if lower.contains("alac") { + return AudioFormat::ALAC; + } + if lower.contains("ape") { + return AudioFormat::APE; + } + if lower.contains("aac") || lower.contains("m4a") { + return AudioFormat::AAC; + } + if lower.contains("ogg") { + return AudioFormat::OGG; + } + // A bare "mp3" token or an explicit kbps bitrate strongly implies MP3. + if lower.contains("mp3") || lower.contains("kbps") { + return AudioFormat::MP3; + } + AudioFormat::Unknown +} diff --git a/src/torrent/bencode.rs b/src/torrent/bencode.rs new file mode 100644 index 0000000..a864842 --- /dev/null +++ b/src/torrent/bencode.rs @@ -0,0 +1,165 @@ +use std::collections::BTreeMap; + +/// A parsed bencode value. Byte offsets are retained so the raw span of any +/// value (notably the `info` dict) can be sliced out for SHA-1 hashing. +#[derive(Debug, Clone)] +pub(super) enum BencodeValue { + Int(i64), + Bytes(Vec), + List(Vec), + Dict(BTreeMap, BencodeNode>), +} + +#[derive(Debug, Clone)] +pub(super) struct BencodeNode { + pub value: BencodeValue, + pub start: usize, + pub end: usize, +} + +impl BencodeNode { + pub fn as_int(&self) -> Option { + match self.value { + BencodeValue::Int(i) => Some(i), + _ => None, + } + } + + pub fn as_bytes(&self) -> Option<&[u8]> { + match &self.value { + BencodeValue::Bytes(b) => Some(b), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + self.as_bytes().and_then(|b| std::str::from_utf8(b).ok()) + } + + pub fn as_list(&self) -> Option<&[BencodeNode]> { + match &self.value { + BencodeValue::List(v) => Some(v), + _ => None, + } + } + + pub fn as_dict(&self) -> Option<&BTreeMap, BencodeNode>> { + match &self.value { + BencodeValue::Dict(d) => Some(d), + _ => None, + } + } + + pub fn dict_get(&self, key: &str) -> Option<&BencodeNode> { + self.as_dict().and_then(|d| d.get(key.as_bytes())) + } +} + +#[derive(Debug)] +pub(super) struct BencodeError { + pub msg: String, +} + +impl std::fmt::Display for BencodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.msg) + } +} + +impl std::error::Error for BencodeError {} + +pub(super) fn parse(data: &[u8]) -> Result { + let mut pos = 0usize; + parse_value(data, &mut pos) +} + +fn parse_value(data: &[u8], pos: &mut usize) -> Result { + let start = *pos; + let byte = *peek(data, pos)?; + let value = match byte { + b'i' => { + *pos += 1; + BencodeValue::Int(parse_int_until(data, pos, b'e')?) + } + b'l' => { + *pos += 1; + let mut items = Vec::new(); + loop { + if *peek(data, pos)? == b'e' { + *pos += 1; + break; + } + items.push(parse_value(data, pos)?); + } + BencodeValue::List(items) + } + b'd' => { + *pos += 1; + let mut dict = BTreeMap::new(); + loop { + if *peek(data, pos)? == b'e' { + *pos += 1; + break; + } + let key_node = parse_value(data, pos)?; + let BencodeValue::Bytes(key) = key_node.value else { + return Err(BencodeError { + msg: "bencode dict key is not a string".to_string(), + }); + }; + let val = parse_value(data, pos)?; + dict.insert(key, val); + } + BencodeValue::Dict(dict) + } + b'0'..=b'9' => BencodeValue::Bytes(parse_string(data, pos)?), + other => { + return Err(BencodeError { + msg: format!("unexpected bencode byte {other:#x}"), + }); + } + }; + Ok(BencodeNode { + value, + start, + end: *pos, + }) +} + +fn peek<'a>(data: &'a [u8], pos: &usize) -> Result<&'a u8, BencodeError> { + data.get(*pos).ok_or_else(|| BencodeError { + msg: "unexpected end of bencoded data".to_string(), + }) +} + +fn parse_string(data: &[u8], pos: &mut usize) -> Result, BencodeError> { + let len = parse_int_until(data, pos, b':')? as usize; + let bytes = data + .get(*pos..*pos + len) + .ok_or_else(|| BencodeError { + msg: "bencoded string length exceeds buffer".to_string(), + })? + .to_vec(); + *pos += len; + Ok(bytes) +} + +fn parse_int_until(data: &[u8], pos: &mut usize, terminator: u8) -> Result { + let start = *pos; + while let Some(&b) = data.get(*pos) { + if b == terminator { + let text = std::str::from_utf8(&data[start..*pos]).map_err(|e| BencodeError { + msg: format!("invalid bencode integer: {e}"), + })?; + let n: i64 = text.parse().map_err(|e| BencodeError { + msg: format!("invalid bencode integer '{text}': {e}"), + })?; + *pos += 1; + return Ok(n); + } + *pos += 1; + } + Err(BencodeError { + msg: "bencoded integer missing terminator".to_string(), + }) +} diff --git a/src/torrent/fetch.rs b/src/torrent/fetch.rs new file mode 100644 index 0000000..61a4da7 --- /dev/null +++ b/src/torrent/fetch.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use tracing::warn; + +use crate::magnet_resolver::MagnetResolver; + +pub(super) enum FetchOutcome { + Bytes { bytes: Vec }, + MagnetSalvaged { info_hash: String }, + Failed { error: String }, +} + +impl FetchOutcome { + fn failed(msg: impl Into) -> Self { + Self::Failed { error: msg.into() } + } +} + +pub(super) async fn fetch_item( + client: &reqwest::Client, + resolver: &Option>, + url: &str, +) -> FetchOutcome { + if url.starts_with("magnet:") { + return resolve_magnet(resolver, url).await; + } + let resp = match client.get(url).send().await { + Ok(r) => r, + Err(e) => return FetchOutcome::failed(format!("http fetch failed: {e}")), + }; + if resp.status().is_redirection() { + let loc = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if loc.starts_with("magnet:") { + return resolve_magnet(resolver, loc).await; + } + return FetchOutcome::failed(format!("unsupported redirect target: {loc}")); + } + if let Err(e) = resp.error_for_status_ref() { + return FetchOutcome::failed(format!("http status error: {e}")); + } + match resp.bytes().await { + Ok(b) if !b.is_empty() => FetchOutcome::Bytes { bytes: b.to_vec() }, + Ok(_) => FetchOutcome::failed("empty response body"), + Err(e) => FetchOutcome::failed(format!("error reading body: {e}")), + } +} + +async fn resolve_magnet(resolver: &Option>, magnet: &str) -> FetchOutcome { + if let Some(r) = resolver { + match r.resolve(magnet).await { + Ok(bytes) => return FetchOutcome::Bytes { bytes }, + Err(e) => { + warn!(magnet = %magnet, error = %e, "magnet resolution failed; salvaging info-hash") + } + } + } + match infohash_from_magnet(magnet) { + Some(h) => FetchOutcome::MagnetSalvaged { info_hash: h }, + None => FetchOutcome::failed(salvage_failure_reason(resolver)), + } +} + +fn salvage_failure_reason(resolver: &Option>) -> &'static str { + match resolver { + Some(_) => "magnet resolution failed and no parseable info-hash", + None => "magnet link with no resolver configured and no parseable info-hash", + } +} + +// Extracts the 40-hex BTv1 info-hash from a magnet's `xt=urn:btih:` param. +// Base32-encoded hashes are intentionally unsupported. +fn infohash_from_magnet(magnet: &str) -> Option { + let key = "xt=urn:btih:"; + let rest = magnet.split(key).nth(1)?; + let hash = rest.split('&').next()?; + if hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { + Some(hash.to_ascii_lowercase()) + } else { + None + } +} diff --git a/src/torrent/helpers.rs b/src/torrent/helpers.rs new file mode 100644 index 0000000..dc0376d --- /dev/null +++ b/src/torrent/helpers.rs @@ -0,0 +1,49 @@ +pub(super) fn extension_lower(path: &str) -> &str { + match path.rfind('.') { + Some(idx) => &path[idx..], + None => "", + } +} + +pub(super) fn file_stem(path: &str) -> &str { + let base = path.rsplit('/').next().unwrap_or(path); + match base.rfind('.') { + Some(idx) => &base[..idx], + None => base, + } +} + +pub(super) fn clean_separators(s: &str) -> String { + let cleaned: String = s + .chars() + .map(|c| if c == '.' || c == '_' { ' ' } else { c }) + .collect(); + collapse_whitespace(&cleaned) +} + +fn collapse_whitespace(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_space = false; + for ch in s.trim().chars() { + if ch.is_whitespace() { + if !prev_space { + out.push(' '); + prev_space = true; + } + } else { + out.push(ch); + prev_space = false; + } + } + out +} + +pub(super) fn hex_sha1(bytes: &[u8]) -> String { + use sha1::{Digest, Sha1}; + let digest = Sha1::digest(bytes); + let mut out = String::with_capacity(40); + for b in digest.iter() { + out.push_str(&format!("{b:02x}")); + } + out +} diff --git a/src/torrent_manager.rs b/src/torrent/manager.rs similarity index 51% rename from src/torrent_manager.rs rename to src/torrent/manager.rs index c0d257d..53f3ae3 100644 --- a/src/torrent_manager.rs +++ b/src/torrent/manager.rs @@ -2,15 +2,20 @@ use std::sync::Arc; use crate::{ generated::{ - torrent::torrents_client, + torrent::{ + AddRequest as DaemonAddRequest, torrents_client, + torrents_server::Torrents as TorrentsDaemon, + }, torrent_manager::{ - SearchItem, SearchRequest, SearchResponse, torrent_manager_server::TorrentManager, + AddRequest, AddResponse, SearchItem, SearchRequest, SearchResponse, + torrent_manager_server::TorrentManager, }, }, indexer::{Indexer, SearchResult}, - torrent::TorrentsService, }; +use super::TorrentsService; + #[derive(Clone)] pub struct TorrentMananagerService { torrents_service: TorrentsService, @@ -40,9 +45,19 @@ impl TorrentManager for TorrentMananagerService { .unwrap_or(Vec::new()) .iter() .map(|item| { + let seeders = match item.seeders { + Some(s) => s as i32, + None => -1, + }; + let leechers = match (item.seeders, item.peers) { + (Some(s), Some(p)) => p.saturating_sub(s) as i32, + _ => -1, + }; return SearchItem { name: item.title.clone(), url: item.link.clone(), + seeders, + leechers, }; }) .collect(); @@ -51,4 +66,18 @@ impl TorrentManager for TorrentMananagerService { return tonic::Result::Ok(tonic::Response::new(response)); } + + async fn add( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let req = request.into_inner(); + let daemon_req = DaemonAddRequest { + magnet: req.url, + output_dir: String::new(), + }; + return TorrentsDaemon::add(&self.torrents_service, tonic::Request::new(daemon_req)) + .await + .map(|_| tonic::Response::new(AddResponse {})); + } } diff --git a/src/torrent/parsed.rs b/src/torrent/parsed.rs new file mode 100644 index 0000000..ce563a3 --- /dev/null +++ b/src/torrent/parsed.rs @@ -0,0 +1,77 @@ +use super::{AudioFormat, ReleaseSource, ReleaseType}; + +/// Structured release information extracted from a torrent title and/or its +/// metainfo bytes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ParsedTorrent { + pub raw_title: String, + pub url: String, + /// Lowercase-hex SHA-1 info-hash; populated only from torrent metainfo. + pub info_hash: String, + + pub artist: String, + pub album: String, + pub year: u32, + + pub release_type: ReleaseType, + pub genres: Vec, + pub label: String, + pub source: ReleaseSource, + pub rip_type: String, + + pub format: AudioFormat, + /// e.g. "320 kbps" (lossy only). + pub bitrate: String, + /// e.g. 16 or 24 (lossless only). + pub bit_depth: u32, + /// In Hz (e.g. 44100), not kHz. + pub sample_rate: u32, + + pub track_names: Vec, + pub track_count: u32, + pub release_count: u32, + pub audio_file_count: u32, + pub total_audio_size: u64, + + pub has_cover_art: bool, + pub has_cue_sheet: bool, + pub has_rip_log: bool, + + /// Drives the primary sort key of [`Ord`]. `-1` means the indexer did + /// not report the value. + pub seeders: i32, + /// Torznab `peers - seeders`. `-1` means the indexer did not report it. + pub leechers: i32, + + pub parsed_successfully: bool, + pub parse_errors: Vec, +} + +impl Ord for ParsedTorrent { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.seeders + .cmp(&other.seeders) + .then(self.format.quality_rank().cmp(&other.format.quality_rank())) + } +} + +impl PartialOrd for ParsedTorrent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl ParsedTorrent { + pub(super) fn record_error(&mut self, msg: impl Into) { + self.parse_errors.push(msg.into()); + } + + pub(super) fn set_quality_if_missing(&mut self, bit_depth: u32, sample_rate: u32) { + if self.bit_depth == 0 { + self.bit_depth = bit_depth; + } + if self.sample_rate == 0 { + self.sample_rate = sample_rate; + } + } +} diff --git a/src/torrent_parser.rs b/src/torrent/parser.rs similarity index 58% rename from src/torrent_parser.rs rename to src/torrent/parser.rs index 4f6df17..29d96e4 100644 --- a/src/torrent_parser.rs +++ b/src/torrent/parser.rs @@ -1,240 +1,23 @@ -//! Torrent release parser. -//! -//! Ports the behaviour of the Go `tracker.GenericParser`: given a torrent -//! title (or full torrent metainfo bytes) it extracts structured release -//! metadata — artist, album, year, audio format, bitrate, bit-depth / -//! sample-rate, source, rip type and (when torrent data is available) the -//! per-file track listing. -//! -//! Primary entry point for search results is -//! [`TorrentParser::parse_search_response`]; for raw torrent bytes use -//! [`TorrentParser::parse_torrent_data`]. - -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::sync::Arc; use regex::Regex; use tokio::sync::Semaphore; -use tracing::{trace, warn}; +use tracing::trace; use crate::generated::metadata::Album; use crate::generated::torrent_manager::{SearchItem, SearchResponse}; use crate::magnet_resolver::MagnetResolver; +use super::audio_format::{AudioFormat, detect_format_from_title, format_for_extension}; +use super::bencode; +use super::fetch::{FetchOutcome, fetch_item}; +use super::helpers::{clean_separators, extension_lower, file_stem, hex_sha1}; +use super::parsed::ParsedTorrent; +use super::{ReleaseSource, ReleaseType}; + const MAX_CONCURRENT_RESOLVES: usize = 8; -/// Audio container/codec format detected from file extensions or title tokens. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum AudioFormat { - #[default] - Unknown, - FLAC, - MP3, - AAC, - APE, - WavPack, - ALAC, - OGG, - WAV, -} - -impl AudioFormat { - /// Lossless (uncompressed or losslessly compressed) formats. - pub fn is_lossless(self) -> bool { - matches!( - self, - Self::FLAC | Self::APE | Self::WavPack | Self::ALAC | Self::WAV - ) - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Unknown => "unknown", - Self::FLAC => "FLAC", - Self::MP3 => "MP3", - Self::AAC => "AAC", - Self::APE => "APE", - Self::WavPack => "WavPack", - Self::ALAC => "ALAC", - Self::OGG => "OGG", - Self::WAV => "WAV", - } - } - - pub fn from_str(s: &str) -> Self { - match s { - "FLAC" => Self::FLAC, - "MP3" => Self::MP3, - "AAC" => Self::AAC, - "APE" => Self::APE, - "WavPack" => Self::WavPack, - "ALAC" => Self::ALAC, - "OGG" => Self::OGG, - "WAV" => Self::WAV, - _ => Self::Unknown, - } - } -} - -/// Release classification (album, EP, single, ...). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ReleaseType { - #[default] - Unknown, - Album, - EP, - Single, - Compilation, - Soundtrack, - Live, -} - -impl ReleaseType { - pub fn as_str(self) -> &'static str { - match self { - Self::Unknown => "unknown", - Self::Album => "album", - Self::EP => "ep", - Self::Single => "single", - Self::Compilation => "compilation", - Self::Soundtrack => "soundtrack", - Self::Live => "live", - } - } - - pub fn from_str(s: &str) -> Self { - match s { - "album" => Self::Album, - "ep" => Self::EP, - "single" => Self::Single, - "compilation" => Self::Compilation, - "soundtrack" => Self::Soundtrack, - "live" => Self::Live, - _ => Self::Unknown, - } - } -} - -/// Physical/digital source medium of the rip. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ReleaseSource { - #[default] - Unknown, - CD, - WEB, - Vinyl, - Cassette, - DVD, - BluRay, - SACD, - DAT, -} - -impl ReleaseSource { - pub fn as_str(self) -> &'static str { - match self { - Self::Unknown => "unknown", - Self::CD => "CD", - Self::WEB => "WEB", - Self::Vinyl => "Vinyl", - Self::Cassette => "Cassette", - Self::DVD => "DVD", - Self::BluRay => "Blu-Ray", - Self::SACD => "SACD", - Self::DAT => "DAT", - } - } - - pub fn from_str(s: &str) -> Self { - match s { - "CD" => Self::CD, - "WEB" => Self::WEB, - "Vinyl" => Self::Vinyl, - "Cassette" => Self::Cassette, - "DVD" => Self::DVD, - "Blu-Ray" => Self::BluRay, - "SACD" => Self::SACD, - "DAT" => Self::DAT, - _ => Self::Unknown, - } - } -} - -/// Maps a file extension (with leading dot, lowercase) to an audio format. -fn format_for_extension(ext: &str) -> Option { - match ext { - ".flac" => Some(AudioFormat::FLAC), - ".mp3" => Some(AudioFormat::MP3), - ".aac" => Some(AudioFormat::AAC), - ".m4a" => Some(AudioFormat::AAC), - ".ape" => Some(AudioFormat::APE), - ".wv" => Some(AudioFormat::WavPack), - ".alac" => Some(AudioFormat::ALAC), - ".ogg" => Some(AudioFormat::OGG), - ".wav" => Some(AudioFormat::WAV), - _ => None, - } -} - -// --------------------------------------------------------------------------- -// Parsed output -// --------------------------------------------------------------------------- - -/// Structured release information extracted from a torrent title and/or its -/// metainfo bytes. -#[derive(Debug, Clone, Default)] -pub struct ParsedTorrent { - pub raw_title: String, - /// Lowercase-hex SHA-1 info-hash; populated only from torrent metainfo. - pub info_hash: String, - - pub artist: String, - pub album: String, - pub year: u32, - - pub release_type: ReleaseType, - pub genres: Vec, - pub label: String, - pub source: ReleaseSource, - pub rip_type: String, - - pub format: AudioFormat, - /// e.g. "320 kbps" (lossy only). - pub bitrate: String, - /// e.g. 16 or 24 (lossless only). - pub bit_depth: u32, - /// In Hz (e.g. 44100), not kHz. - pub sample_rate: u32, - - pub track_names: Vec, - pub track_count: u32, - pub release_count: u32, - pub audio_file_count: u32, - pub total_audio_size: u64, - - pub has_cover_art: bool, - pub has_cue_sheet: bool, - pub has_rip_log: bool, - - pub parsed_successfully: bool, - pub parse_errors: Vec, -} - -impl ParsedTorrent { - fn record_error(&mut self, msg: impl Into) { - self.parse_errors.push(msg.into()); - } - - fn set_quality_if_missing(&mut self, bit_depth: u32, sample_rate: u32) { - if self.bit_depth == 0 { - self.bit_depth = bit_depth; - } - if self.sample_rate == 0 { - self.sample_rate = sample_rate; - } - } -} - /// File-list facts derived from a torrent's `info` dictionary. struct TorrentFileList { format: AudioFormat, @@ -246,176 +29,6 @@ struct TorrentFileList { has_rip_log: bool, } -/// A parsed bencode value. Byte offsets are retained so the raw span of any -/// value (notably the `info` dict) can be sliced out for SHA-1 hashing. -#[derive(Debug, Clone)] -enum BencodeValue { - Int(i64), - Bytes(Vec), - List(Vec), - Dict(BTreeMap, BencodeNode>), -} - -#[derive(Debug, Clone)] -struct BencodeNode { - value: BencodeValue, - start: usize, - end: usize, -} - -impl BencodeNode { - fn as_int(&self) -> Option { - match self.value { - BencodeValue::Int(i) => Some(i), - _ => None, - } - } - - fn as_bytes(&self) -> Option<&[u8]> { - match &self.value { - BencodeValue::Bytes(b) => Some(b), - _ => None, - } - } - - fn as_str(&self) -> Option<&str> { - self.as_bytes().and_then(|b| std::str::from_utf8(b).ok()) - } - - fn as_list(&self) -> Option<&[BencodeNode]> { - match &self.value { - BencodeValue::List(v) => Some(v), - _ => None, - } - } - - fn as_dict(&self) -> Option<&BTreeMap, BencodeNode>> { - match &self.value { - BencodeValue::Dict(d) => Some(d), - _ => None, - } - } - - fn dict_get(&self, key: &str) -> Option<&BencodeNode> { - self.as_dict().and_then(|d| d.get(key.as_bytes())) - } -} - -#[derive(Debug)] -struct BencodeError { - msg: String, -} - -impl std::fmt::Display for BencodeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.msg) - } -} - -impl std::error::Error for BencodeError {} - -mod bencode { - use std::collections::BTreeMap; - - use super::{BencodeError, BencodeNode, BencodeValue}; - - pub(super) fn parse(data: &[u8]) -> Result { - let mut pos = 0usize; - parse_value(data, &mut pos) - } - - fn parse_value(data: &[u8], pos: &mut usize) -> Result { - let start = *pos; - let byte = *peek(data, pos)?; - let value = match byte { - b'i' => { - *pos += 1; - BencodeValue::Int(parse_int_until(data, pos, b'e')?) - } - b'l' => { - *pos += 1; - let mut items = Vec::new(); - loop { - if *peek(data, pos)? == b'e' { - *pos += 1; - break; - } - items.push(parse_value(data, pos)?); - } - BencodeValue::List(items) - } - b'd' => { - *pos += 1; - let mut dict = BTreeMap::new(); - loop { - if *peek(data, pos)? == b'e' { - *pos += 1; - break; - } - let key_node = parse_value(data, pos)?; - let BencodeValue::Bytes(key) = key_node.value else { - return Err(BencodeError { - msg: "bencode dict key is not a string".to_string(), - }); - }; - let val = parse_value(data, pos)?; - dict.insert(key, val); - } - BencodeValue::Dict(dict) - } - b'0'..=b'9' => BencodeValue::Bytes(parse_string(data, pos)?), - other => { - return Err(BencodeError { - msg: format!("unexpected bencode byte {other:#x}"), - }); - } - }; - Ok(BencodeNode { - value, - start, - end: *pos, - }) - } - - fn peek<'a>(data: &'a [u8], pos: &usize) -> Result<&'a u8, BencodeError> { - data.get(*pos).ok_or_else(|| BencodeError { - msg: "unexpected end of bencoded data".to_string(), - }) - } - - fn parse_string(data: &[u8], pos: &mut usize) -> Result, BencodeError> { - let len = parse_int_until(data, pos, b':')? as usize; - let bytes = data - .get(*pos..*pos + len) - .ok_or_else(|| BencodeError { - msg: "bencoded string length exceeds buffer".to_string(), - })? - .to_vec(); - *pos += len; - Ok(bytes) - } - - fn parse_int_until(data: &[u8], pos: &mut usize, terminator: u8) -> Result { - let start = *pos; - while let Some(&b) = data.get(*pos) { - if b == terminator { - let text = std::str::from_utf8(&data[start..*pos]).map_err(|e| BencodeError { - msg: format!("invalid bencode integer: {e}"), - })?; - let n: i64 = text.parse().map_err(|e| BencodeError { - msg: format!("invalid bencode integer '{text}': {e}"), - })?; - *pos += 1; - return Ok(n); - } - *pos += 1; - } - Err(BencodeError { - msg: "bencoded integer missing terminator".to_string(), - }) - } -} - /// Stateless-after-construction torrent release parser. Compile the regexes /// once and reuse across many titles / metainfo blobs. pub struct TorrentParser { @@ -563,7 +176,11 @@ impl TorrentParser { } pub fn parse_item(&self, item: &SearchItem) -> ParsedTorrent { - self.parse_title(&item.name) + let mut r = self.parse_title(&item.name); + r.url = item.url.clone(); + r.seeders = item.seeders; + r.leechers = item.leechers; + r } pub fn parse_title(&self, title: &str) -> ParsedTorrent { @@ -603,89 +220,7 @@ impl TorrentParser { r.parse_errors.push("missing artist or album".to_string()); } } -} -enum FetchOutcome { - Bytes { bytes: Vec }, - MagnetSalvaged { info_hash: String }, - Failed { error: String }, -} - -impl FetchOutcome { - fn failed(msg: impl Into) -> Self { - Self::Failed { error: msg.into() } - } -} - -async fn fetch_item( - client: &reqwest::Client, - resolver: &Option>, - url: &str, -) -> FetchOutcome { - if url.starts_with("magnet:") { - return resolve_magnet(resolver, url).await; - } - let resp = match client.get(url).send().await { - Ok(r) => r, - Err(e) => return FetchOutcome::failed(format!("http fetch failed: {e}")), - }; - if resp.status().is_redirection() { - let loc = resp - .headers() - .get(reqwest::header::LOCATION) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if loc.starts_with("magnet:") { - return resolve_magnet(resolver, loc).await; - } - return FetchOutcome::failed(format!("unsupported redirect target: {loc}")); - } - if let Err(e) = resp.error_for_status_ref() { - return FetchOutcome::failed(format!("http status error: {e}")); - } - match resp.bytes().await { - Ok(b) if !b.is_empty() => FetchOutcome::Bytes { bytes: b.to_vec() }, - Ok(_) => FetchOutcome::failed("empty response body"), - Err(e) => FetchOutcome::failed(format!("error reading body: {e}")), - } -} - -async fn resolve_magnet(resolver: &Option>, magnet: &str) -> FetchOutcome { - if let Some(r) = resolver { - match r.resolve(magnet).await { - Ok(bytes) => return FetchOutcome::Bytes { bytes }, - Err(e) => { - warn!(magnet = %magnet, error = %e, "magnet resolution failed; salvaging info-hash") - } - } - } - match infohash_from_magnet(magnet) { - Some(h) => FetchOutcome::MagnetSalvaged { info_hash: h }, - None => FetchOutcome::failed(salvage_failure_reason(resolver)), - } -} - -fn salvage_failure_reason(resolver: &Option>) -> &'static str { - match resolver { - Some(_) => "magnet resolution failed and no parseable info-hash", - None => "magnet link with no resolver configured and no parseable info-hash", - } -} - -// Extracts the 40-hex BTv1 info-hash from a magnet's `xt=urn:btih:` param. -// Base32-encoded hashes are intentionally unsupported. -fn infohash_from_magnet(magnet: &str) -> Option { - let key = "xt=urn:btih:"; - let rest = magnet.split(key).nth(1)?; - let hash = rest.split('&').next()?; - if hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { - Some(hash.to_ascii_lowercase()) - } else { - None - } -} - -impl TorrentParser { fn fill_from_metadata(&self, r: &mut ParsedTorrent, album: &Album) { if !album.title.is_empty() { r.album = album.title.clone(); @@ -801,7 +336,7 @@ impl TorrentParser { /// Returns `None` when the torrent contains no audio files. fn extract_file_list( &self, - info: &BencodeNode, + info: &bencode::BencodeNode, single_file_name: &str, ) -> Option { let mut format_counts: HashMap = HashMap::new(); @@ -922,9 +457,7 @@ impl TorrentParser { "torrent parser: overlaid torrent file list" ); } -} -impl TorrentParser { fn fill_from_title(&self, r: &mut ParsedTorrent, title: &str) { if title.is_empty() { return; @@ -1116,82 +649,3 @@ impl TorrentParser { } } } - -fn extension_lower(path: &str) -> &str { - match path.rfind('.') { - Some(idx) => &path[idx..], - None => "", - } -} - -fn file_stem(path: &str) -> &str { - let base = path.rsplit('/').next().unwrap_or(path); - match base.rfind('.') { - Some(idx) => &base[..idx], - None => base, - } -} - -fn clean_separators(s: &str) -> String { - let cleaned: String = s - .chars() - .map(|c| if c == '.' || c == '_' { ' ' } else { c }) - .collect(); - collapse_whitespace(&cleaned) -} - -fn collapse_whitespace(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut prev_space = false; - for ch in s.trim().chars() { - if ch.is_whitespace() { - if !prev_space { - out.push(' '); - prev_space = true; - } - } else { - out.push(ch); - prev_space = false; - } - } - out -} - -/// Conservative title-token format detection: only matches codec names that -/// are unlikely to appear as substrings of other words. -fn detect_format_from_title(title: &str) -> AudioFormat { - let lower = title.to_ascii_lowercase(); - if lower.contains("wavpack") { - return AudioFormat::WavPack; - } - if lower.contains("flac") { - return AudioFormat::FLAC; - } - if lower.contains("alac") { - return AudioFormat::ALAC; - } - if lower.contains("ape") { - return AudioFormat::APE; - } - if lower.contains("aac") || lower.contains("m4a") { - return AudioFormat::AAC; - } - if lower.contains("ogg") { - return AudioFormat::OGG; - } - // A bare "mp3" token or an explicit kbps bitrate strongly implies MP3. - if lower.contains("mp3") || lower.contains("kbps") { - return AudioFormat::MP3; - } - AudioFormat::Unknown -} - -fn hex_sha1(bytes: &[u8]) -> String { - use sha1::{Digest, Sha1}; - let digest = Sha1::digest(bytes); - let mut out = String::with_capacity(40); - for b in digest.iter() { - out.push_str(&format!("{b:02x}")); - } - out -} diff --git a/src/torrent/release_source.rs b/src/torrent/release_source.rs new file mode 100644 index 0000000..0582b82 --- /dev/null +++ b/src/torrent/release_source.rs @@ -0,0 +1,44 @@ +/// Physical/digital source medium of the rip. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReleaseSource { + #[default] + Unknown, + CD, + WEB, + Vinyl, + Cassette, + DVD, + BluRay, + SACD, + DAT, +} + +impl ReleaseSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::CD => "CD", + Self::WEB => "WEB", + Self::Vinyl => "Vinyl", + Self::Cassette => "Cassette", + Self::DVD => "DVD", + Self::BluRay => "Blu-Ray", + Self::SACD => "SACD", + Self::DAT => "DAT", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "CD" => Self::CD, + "WEB" => Self::WEB, + "Vinyl" => Self::Vinyl, + "Cassette" => Self::Cassette, + "DVD" => Self::DVD, + "Blu-Ray" => Self::BluRay, + "SACD" => Self::SACD, + "DAT" => Self::DAT, + _ => Self::Unknown, + } + } +} diff --git a/src/torrent/release_type.rs b/src/torrent/release_type.rs new file mode 100644 index 0000000..1477984 --- /dev/null +++ b/src/torrent/release_type.rs @@ -0,0 +1,38 @@ +/// Release classification (album, EP, single, ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReleaseType { + #[default] + Unknown, + Album, + EP, + Single, + Compilation, + Soundtrack, + Live, +} + +impl ReleaseType { + pub fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Album => "album", + Self::EP => "ep", + Self::Single => "single", + Self::Compilation => "compilation", + Self::Soundtrack => "soundtrack", + Self::Live => "live", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "album" => Self::Album, + "ep" => Self::EP, + "single" => Self::Single, + "compilation" => Self::Compilation, + "soundtrack" => Self::Soundtrack, + "live" => Self::Live, + _ => Self::Unknown, + } + } +}