From 0036b19612bdf5fc90e98dfa3db8d3118197375c Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 3 Jul 2026 19:10:26 +0200 Subject: [PATCH] Add pause/resume, enchance tora-cli --- .envrc | 2 - .gitignore | 3 + Cargo.lock | 25 + proto/buf.gen.yaml => buf.gen.yaml | 6 +- proto/buf.yaml => buf.yaml | 2 +- crates/proto/src/generated/torrent/torrent.rs | 471 +++++--- .../src/generated/torrent/torrent.serde.rs | 653 +++++++++++ .../src/generated/torrent/torrent.tonic.rs | 165 +++ crates/proto/src/lib.rs | 3 +- crates/tora/Cargo.toml | 3 + crates/tora/src/cli.rs | 75 +- crates/tora/src/main.rs | 244 +++- crates/tora/src/output.rs | 1028 +++++++++++++++++ crates/torad/Cargo.toml | 1 + crates/torad/src/db.rs | 30 +- crates/torad/src/magnet.rs | 20 +- crates/torad/src/torrents.rs | 271 ++++- justfile | 2 + proto/torrent.proto | 33 +- 19 files changed, 2796 insertions(+), 241 deletions(-) rename proto/buf.gen.yaml => buf.gen.yaml (59%) rename proto/buf.yaml => buf.yaml (82%) create mode 100644 crates/tora/src/output.rs create mode 100644 justfile diff --git a/.envrc b/.envrc index 3036deb..dabfa38 100644 --- a/.envrc +++ b/.envrc @@ -1,6 +1,4 @@ #!/usr/bin/env bash -export GIT_CONFIG_GLOBAL=/dev/null - eval "$(devenv direnvrc)" # You can pass flags to the devenv command diff --git a/.gitignore b/.gitignore index ae49879..270a4f6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ devenv.local.yaml # pre-commit .pre-commit-config.yaml + + +target/ diff --git a/Cargo.lock b/Cargo.lock index 2a2ffff..a271092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,6 +472,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1827,6 +1836,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -3169,7 +3190,10 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "colored", "hyper-util", + "nix", + "serde_json", "tokio", "tonic", "tonic-health", @@ -3195,6 +3219,7 @@ dependencies = [ "anyhow", "clap", "librqbit", + "librqbit-core", "sqlx", "tokio", "tokio-stream", diff --git a/proto/buf.gen.yaml b/buf.gen.yaml similarity index 59% rename from proto/buf.gen.yaml rename to buf.gen.yaml index 5ddc95e..6ac9b8f 100644 --- a/proto/buf.gen.yaml +++ b/buf.gen.yaml @@ -1,11 +1,11 @@ version: v2 plugins: - local: protoc-gen-prost - out: ../crates/proto/src/generated + out: ./crates/proto/src/generated opt: - file_descriptor_set - enable_type_names - local: protoc-gen-prost-serde - out: ../crates/proto/src/generated + out: ./crates/proto/src/generated - local: protoc-gen-tonic - out: ../crates/proto/src/generated + out: ./crates/proto/src/generated diff --git a/proto/buf.yaml b/buf.yaml similarity index 82% rename from proto/buf.yaml rename to buf.yaml index f74da98..c7e30e3 100644 --- a/proto/buf.yaml +++ b/buf.yaml @@ -1,6 +1,6 @@ version: v2 modules: - - path: . + - path: proto lint: use: - STANDARD diff --git a/crates/proto/src/generated/torrent/torrent.rs b/crates/proto/src/generated/torrent/torrent.rs index 2dbce2c..a8dbfb9 100644 --- a/crates/proto/src/generated/torrent/torrent.rs +++ b/crates/proto/src/generated/torrent/torrent.rs @@ -76,6 +76,89 @@ impl ::prost::Name for ListResponse { } } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoveRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(bool, tag = "2")] + pub delete_files: bool, +} +impl ::prost::Name for RemoveRequest { + const NAME: &'static str = "RemoveRequest"; + const PACKAGE: &'static str = "torrent"; + fn full_name() -> ::prost::alloc::string::String { + "torrent.RemoveRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent.RemoveRequest".into() + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoveResponse {} +impl ::prost::Name for RemoveResponse { + const NAME: &'static str = "RemoveResponse"; + const PACKAGE: &'static str = "torrent"; + fn full_name() -> ::prost::alloc::string::String { + "torrent.RemoveResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent.RemoveResponse".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PauseRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, +} +impl ::prost::Name for PauseRequest { + const NAME: &'static str = "PauseRequest"; + const PACKAGE: &'static str = "torrent"; + fn full_name() -> ::prost::alloc::string::String { + "torrent.PauseRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent.PauseRequest".into() + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PauseResponse {} +impl ::prost::Name for PauseResponse { + const NAME: &'static str = "PauseResponse"; + const PACKAGE: &'static str = "torrent"; + fn full_name() -> ::prost::alloc::string::String { + "torrent.PauseResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent.PauseResponse".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ResumeRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, +} +impl ::prost::Name for ResumeRequest { + const NAME: &'static str = "ResumeRequest"; + const PACKAGE: &'static str = "torrent"; + fn full_name() -> ::prost::alloc::string::String { + "torrent.ResumeRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent.ResumeRequest".into() + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ResumeResponse {} +impl ::prost::Name for ResumeResponse { + const NAME: &'static str = "ResumeResponse"; + const PACKAGE: &'static str = "torrent"; + fn full_name() -> ::prost::alloc::string::String { + "torrent.ResumeResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/torrent.ResumeResponse".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct TorrentStatus { #[prost(string, tag = "1")] pub id: ::prost::alloc::string::String, @@ -95,6 +178,18 @@ pub struct TorrentStatus { pub state: i32, #[prost(string, tag = "9")] pub error_message: ::prost::alloc::string::String, + #[prost(uint64, tag = "10")] + pub download_speed_bps: u64, + #[prost(uint64, tag = "11")] + pub eta_seconds: u64, + #[prost(uint64, tag = "12")] + pub uploaded_bytes: u64, + #[prost(uint64, tag = "13")] + pub upload_speed_bps: u64, + #[prost(uint32, tag = "14")] + pub peers: u32, + #[prost(uint32, tag = "15")] + pub seeds: u32, } impl ::prost::Name for TorrentStatus { const NAME: &'static str = "TorrentStatus"; @@ -146,7 +241,7 @@ impl State { } /// Encoded file descriptor set for the `torrent` package pub const FILE_DESCRIPTOR_SET: &[u8] = &[ - 0x0a, 0x94, 0x13, 0x0a, 0x0d, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, + 0x0a, 0x90, 0x1f, 0x0a, 0x0d, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x22, 0x43, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x61, 0x67, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x61, 0x67, 0x6e, 0x65, @@ -161,145 +256,241 @@ pub const FILE_DESCRIPTOR_SET: &[u8] = &[ 0x32, 0x0a, 0x08, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x08, 0x74, 0x6f, 0x72, 0x72, 0x65, - 0x6e, 0x74, 0x73, 0x22, 0xa0, 0x02, 0x0a, 0x0d, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x68, 0x61, - 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x69, 0x6e, 0x66, 0x6f, 0x48, 0x61, - 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1f, - 0x0a, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, - 0x1f, 0x0a, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x42, 0x79, 0x74, 0x65, 0x73, - 0x12, 0x29, 0x0a, 0x10, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x5f, 0x62, - 0x79, 0x74, 0x65, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x64, 0x6f, 0x77, 0x6e, - 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x24, 0x0a, 0x05, 0x73, - 0x74, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x74, 0x6f, 0x72, - 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, - 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2a, 0x61, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, - 0x15, 0x0a, 0x11, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, - 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, - 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x44, 0x4f, 0x57, 0x4e, 0x4c, 0x4f, 0x41, 0x44, 0x49, - 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x41, 0x55, 0x53, 0x45, 0x44, 0x10, 0x03, - 0x12, 0x0c, 0x0a, 0x08, 0x46, 0x49, 0x4e, 0x49, 0x53, 0x48, 0x45, 0x44, 0x10, 0x04, 0x12, 0x09, - 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xab, 0x01, 0x0a, 0x08, 0x54, 0x6f, - 0x72, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x30, 0x0a, 0x03, 0x41, 0x64, 0x64, 0x12, 0x13, 0x2e, - 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x64, 0x64, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x12, 0x16, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x74, 0x6f, 0x72, - 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x12, 0x33, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x14, 0x2e, 0x74, 0x6f, 0x72, - 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x15, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4a, 0xe5, 0x0c, 0x0a, 0x06, 0x12, 0x04, 0x00, 0x00, - 0x34, 0x01, 0x0a, 0x08, 0x0a, 0x01, 0x0c, 0x12, 0x03, 0x00, 0x00, 0x12, 0x0a, 0x08, 0x0a, 0x01, - 0x02, 0x12, 0x03, 0x02, 0x00, 0x10, 0x0a, 0x0a, 0x0a, 0x02, 0x06, 0x00, 0x12, 0x04, 0x04, 0x00, - 0x0b, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x06, 0x00, 0x01, 0x12, 0x03, 0x04, 0x08, 0x10, 0x0a, 0x49, - 0x0a, 0x04, 0x06, 0x00, 0x02, 0x00, 0x12, 0x03, 0x06, 0x02, 0x2c, 0x1a, 0x3c, 0x20, 0x41, 0x64, - 0x64, 0x20, 0x61, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x20, 0x66, 0x72, 0x6f, 0x6d, - 0x20, 0x61, 0x20, 0x6d, 0x61, 0x67, 0x6e, 0x65, 0x74, 0x20, 0x6c, 0x69, 0x6e, 0x6b, 0x20, 0x61, - 0x6e, 0x64, 0x20, 0x73, 0x74, 0x61, 0x72, 0x74, 0x20, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, - 0x64, 0x69, 0x6e, 0x67, 0x20, 0x69, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, - 0x00, 0x01, 0x12, 0x03, 0x06, 0x06, 0x09, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x02, - 0x12, 0x03, 0x06, 0x0a, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, - 0x06, 0x1f, 0x2a, 0x0a, 0x3a, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x01, 0x12, 0x03, 0x08, 0x02, 0x34, - 0x1a, 0x2d, 0x20, 0x47, 0x65, 0x74, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x75, 0x72, 0x72, 0x65, - 0x6e, 0x74, 0x20, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x20, 0x6f, 0x66, 0x20, 0x61, 0x20, 0x73, - 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x0a, 0x0a, - 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, 0x08, 0x06, 0x0c, 0x0a, 0x0c, 0x0a, - 0x05, 0x06, 0x00, 0x02, 0x01, 0x02, 0x12, 0x03, 0x08, 0x0d, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, - 0x00, 0x02, 0x01, 0x03, 0x12, 0x03, 0x08, 0x25, 0x32, 0x0a, 0x35, 0x0a, 0x04, 0x06, 0x00, 0x02, - 0x02, 0x12, 0x03, 0x0a, 0x02, 0x2f, 0x1a, 0x28, 0x20, 0x4c, 0x69, 0x73, 0x74, 0x20, 0x61, 0x6c, - 0x6c, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x20, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, - 0x20, 0x74, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x20, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x0a, - 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x01, 0x12, 0x03, 0x0a, 0x06, 0x0a, 0x0a, 0x0c, - 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x02, 0x12, 0x03, 0x0a, 0x0b, 0x16, 0x0a, 0x0c, 0x0a, 0x05, - 0x06, 0x00, 0x02, 0x02, 0x03, 0x12, 0x03, 0x0a, 0x21, 0x2d, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x00, - 0x12, 0x04, 0x0d, 0x00, 0x11, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x00, 0x01, 0x12, 0x03, 0x0d, - 0x08, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x05, 0x12, 0x03, 0x0e, 0x02, 0x08, - 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x00, 0x02, 0x00, 0x12, 0x03, 0x0e, 0x02, 0x14, 0x0a, 0x0c, 0x0a, - 0x05, 0x04, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x0e, 0x09, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, - 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x0e, 0x12, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, - 0x01, 0x05, 0x12, 0x03, 0x10, 0x02, 0x08, 0x0a, 0x53, 0x0a, 0x04, 0x04, 0x00, 0x02, 0x01, 0x12, - 0x03, 0x10, 0x02, 0x18, 0x1a, 0x46, 0x20, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x20, - 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x20, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, - 0x20, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x2e, 0x20, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x20, 0x6d, 0x65, 0x61, 0x6e, 0x73, 0x20, 0x75, 0x73, 0x65, 0x20, 0x74, 0x6f, 0x72, 0x61, 0x64, - 0x27, 0x73, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, - 0x04, 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, 0x10, 0x09, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, - 0x02, 0x01, 0x03, 0x12, 0x03, 0x10, 0x16, 0x17, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x01, 0x12, 0x04, - 0x13, 0x00, 0x15, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x01, 0x01, 0x12, 0x03, 0x13, 0x08, 0x13, - 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x05, 0x12, 0x03, 0x14, 0x02, 0x08, 0x0a, 0x0b, - 0x0a, 0x04, 0x04, 0x01, 0x02, 0x00, 0x12, 0x03, 0x14, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, - 0x01, 0x02, 0x00, 0x01, 0x12, 0x03, 0x14, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, - 0x00, 0x03, 0x12, 0x03, 0x14, 0x0e, 0x0f, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x02, 0x12, 0x04, 0x17, - 0x00, 0x19, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x02, 0x01, 0x12, 0x03, 0x17, 0x08, 0x15, 0x0a, - 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x05, 0x12, 0x03, 0x18, 0x02, 0x08, 0x0a, 0x0b, 0x0a, - 0x04, 0x04, 0x02, 0x02, 0x00, 0x12, 0x03, 0x18, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, - 0x02, 0x00, 0x01, 0x12, 0x03, 0x18, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, - 0x03, 0x12, 0x03, 0x18, 0x0e, 0x0f, 0x0a, 0x09, 0x0a, 0x02, 0x04, 0x03, 0x12, 0x03, 0x1b, 0x00, - 0x16, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x03, 0x01, 0x12, 0x03, 0x1b, 0x08, 0x13, 0x0a, 0x0a, 0x0a, - 0x02, 0x04, 0x04, 0x12, 0x04, 0x1d, 0x00, 0x1f, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x04, 0x01, - 0x12, 0x03, 0x1d, 0x08, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x04, 0x02, 0x00, 0x04, 0x12, 0x03, - 0x1e, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x04, 0x02, 0x00, 0x12, 0x03, 0x1e, 0x02, 0x26, - 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x04, 0x02, 0x00, 0x06, 0x12, 0x03, 0x1e, 0x0b, 0x18, 0x0a, 0x0c, - 0x0a, 0x05, 0x04, 0x04, 0x02, 0x00, 0x01, 0x12, 0x03, 0x1e, 0x19, 0x21, 0x0a, 0x0c, 0x0a, 0x05, - 0x04, 0x04, 0x02, 0x00, 0x03, 0x12, 0x03, 0x1e, 0x24, 0x25, 0x0a, 0x0a, 0x0a, 0x02, 0x05, 0x00, - 0x12, 0x04, 0x21, 0x00, 0x28, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x05, 0x00, 0x01, 0x12, 0x03, 0x21, - 0x05, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x22, 0x02, 0x13, - 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x00, 0x12, 0x03, 0x22, 0x02, 0x18, 0x0a, 0x0c, 0x0a, - 0x05, 0x05, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03, 0x22, 0x16, 0x17, 0x0a, 0x0c, 0x0a, 0x05, 0x05, - 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, 0x23, 0x02, 0x09, 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, - 0x01, 0x12, 0x03, 0x23, 0x02, 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x01, 0x02, 0x12, - 0x03, 0x23, 0x0c, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x02, 0x01, 0x12, 0x03, 0x24, - 0x02, 0x0d, 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x02, 0x12, 0x03, 0x24, 0x02, 0x12, 0x0a, - 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x02, 0x02, 0x12, 0x03, 0x24, 0x10, 0x11, 0x0a, 0x0c, 0x0a, - 0x05, 0x05, 0x00, 0x02, 0x03, 0x01, 0x12, 0x03, 0x25, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x05, - 0x00, 0x02, 0x03, 0x12, 0x03, 0x25, 0x02, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x03, - 0x02, 0x12, 0x03, 0x25, 0x0b, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x04, 0x01, 0x12, - 0x03, 0x26, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x04, 0x12, 0x03, 0x26, 0x02, - 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x04, 0x02, 0x12, 0x03, 0x26, 0x0d, 0x0e, 0x0a, - 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x05, 0x01, 0x12, 0x03, 0x27, 0x02, 0x07, 0x0a, 0x0b, 0x0a, - 0x04, 0x05, 0x00, 0x02, 0x05, 0x12, 0x03, 0x27, 0x02, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, - 0x02, 0x05, 0x02, 0x12, 0x03, 0x27, 0x0a, 0x0b, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x05, 0x12, 0x04, - 0x2a, 0x00, 0x34, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x05, 0x01, 0x12, 0x03, 0x2a, 0x08, 0x15, - 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x00, 0x05, 0x12, 0x03, 0x2b, 0x02, 0x08, 0x0a, 0x0b, - 0x0a, 0x04, 0x04, 0x05, 0x02, 0x00, 0x12, 0x03, 0x2b, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, - 0x05, 0x02, 0x00, 0x01, 0x12, 0x03, 0x2b, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, - 0x00, 0x03, 0x12, 0x03, 0x2b, 0x0e, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x01, 0x05, - 0x12, 0x03, 0x2c, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x01, 0x12, 0x03, 0x2c, - 0x02, 0x17, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x01, 0x01, 0x12, 0x03, 0x2c, 0x09, 0x12, - 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x01, 0x03, 0x12, 0x03, 0x2c, 0x15, 0x16, 0x0a, 0x0c, - 0x0a, 0x05, 0x04, 0x05, 0x02, 0x02, 0x05, 0x12, 0x03, 0x2d, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, - 0x04, 0x05, 0x02, 0x02, 0x12, 0x03, 0x2d, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, - 0x02, 0x01, 0x12, 0x03, 0x2d, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x02, 0x03, - 0x12, 0x03, 0x2d, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x03, 0x05, 0x12, 0x03, - 0x2e, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x03, 0x12, 0x03, 0x2e, 0x02, 0x14, - 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x03, 0x01, 0x12, 0x03, 0x2e, 0x09, 0x0f, 0x0a, 0x0c, - 0x0a, 0x05, 0x04, 0x05, 0x02, 0x03, 0x03, 0x12, 0x03, 0x2e, 0x12, 0x13, 0x0a, 0x0c, 0x0a, 0x05, - 0x04, 0x05, 0x02, 0x04, 0x05, 0x12, 0x03, 0x2f, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, - 0x02, 0x04, 0x12, 0x03, 0x2f, 0x02, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x04, 0x01, - 0x12, 0x03, 0x2f, 0x09, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x04, 0x03, 0x12, 0x03, - 0x2f, 0x17, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x05, 0x05, 0x12, 0x03, 0x30, 0x02, - 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x05, 0x12, 0x03, 0x30, 0x02, 0x19, 0x0a, 0x0c, - 0x0a, 0x05, 0x04, 0x05, 0x02, 0x05, 0x01, 0x12, 0x03, 0x30, 0x09, 0x14, 0x0a, 0x0c, 0x0a, 0x05, - 0x04, 0x05, 0x02, 0x05, 0x03, 0x12, 0x03, 0x30, 0x17, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, - 0x02, 0x06, 0x05, 0x12, 0x03, 0x31, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x06, - 0x12, 0x03, 0x31, 0x02, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x06, 0x01, 0x12, 0x03, - 0x31, 0x09, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x06, 0x03, 0x12, 0x03, 0x31, 0x1c, - 0x1d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x07, 0x06, 0x12, 0x03, 0x32, 0x02, 0x07, 0x0a, - 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x07, 0x12, 0x03, 0x32, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, - 0x04, 0x05, 0x02, 0x07, 0x01, 0x12, 0x03, 0x32, 0x08, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, - 0x02, 0x07, 0x03, 0x12, 0x03, 0x32, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x08, - 0x05, 0x12, 0x03, 0x33, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x08, 0x12, 0x03, - 0x33, 0x02, 0x1b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x08, 0x01, 0x12, 0x03, 0x33, 0x09, - 0x16, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x08, 0x03, 0x12, 0x03, 0x33, 0x19, 0x1a, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6e, 0x74, 0x73, 0x22, 0x42, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x66, + 0x69, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x10, 0x0a, 0x0e, 0x52, 0x65, 0x6d, 0x6f, 0x76, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1e, 0x0a, 0x0c, 0x50, 0x61, 0x75, + 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x0f, 0x0a, 0x0d, 0x50, 0x61, 0x75, + 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, + 0x73, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x10, 0x0a, 0x0e, 0x52, + 0x65, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xec, 0x03, + 0x0a, 0x0d, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x69, 0x6e, 0x66, 0x6f, 0x48, 0x61, 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x75, 0x74, 0x70, + 0x75, 0x74, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, + 0x75, 0x74, 0x70, 0x75, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x6f, 0x74, + 0x61, 0x6c, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, + 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x64, 0x6f, + 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, + 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x24, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x73, 0x70, 0x65, + 0x65, 0x64, 0x5f, 0x62, 0x70, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x64, 0x6f, + 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x70, 0x65, 0x65, 0x64, 0x42, 0x70, 0x73, 0x12, 0x1f, + 0x0a, 0x0b, 0x65, 0x74, 0x61, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x0a, 0x65, 0x74, 0x61, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, + 0x25, 0x0a, 0x0e, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, + 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x65, + 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, + 0x5f, 0x73, 0x70, 0x65, 0x65, 0x64, 0x5f, 0x62, 0x70, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x0e, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x70, 0x65, 0x65, 0x64, 0x42, 0x70, 0x73, + 0x12, 0x14, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x65, 0x65, 0x64, 0x73, 0x18, + 0x0f, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x65, 0x65, 0x64, 0x73, 0x2a, 0x61, 0x0a, 0x05, + 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x15, 0x0a, 0x11, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, + 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, + 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x44, 0x4f, 0x57, + 0x4e, 0x4c, 0x4f, 0x41, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x41, + 0x55, 0x53, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c, 0x0a, 0x08, 0x46, 0x49, 0x4e, 0x49, 0x53, 0x48, + 0x45, 0x44, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, + 0xd9, 0x02, 0x0a, 0x08, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x30, 0x0a, 0x03, + 0x41, 0x64, 0x64, 0x12, 0x13, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x64, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, + 0x6e, 0x74, 0x2e, 0x41, 0x64, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, + 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x16, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x6f, 0x72, 0x72, 0x65, + 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, + 0x12, 0x14, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, + 0x06, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x16, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x17, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x36, 0x0a, 0x05, 0x50, 0x61, 0x75, 0x73, + 0x65, 0x12, 0x15, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x75, 0x73, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x39, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x12, 0x16, 0x2e, 0x74, 0x6f, 0x72, + 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, + 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4a, 0xad, 0x14, 0x0a, 0x06, + 0x12, 0x04, 0x00, 0x00, 0x53, 0x01, 0x0a, 0x08, 0x0a, 0x01, 0x0c, 0x12, 0x03, 0x00, 0x00, 0x12, + 0x0a, 0x08, 0x0a, 0x01, 0x02, 0x12, 0x03, 0x02, 0x00, 0x10, 0x0a, 0x0a, 0x0a, 0x02, 0x06, 0x00, + 0x12, 0x04, 0x04, 0x00, 0x11, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x06, 0x00, 0x01, 0x12, 0x03, 0x04, + 0x08, 0x10, 0x0a, 0x5a, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x00, 0x12, 0x03, 0x06, 0x02, 0x2c, 0x1a, + 0x4d, 0x20, 0x41, 0x64, 0x64, 0x20, 0x61, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x20, + 0x66, 0x72, 0x6f, 0x6d, 0x20, 0x61, 0x20, 0x6d, 0x61, 0x67, 0x6e, 0x65, 0x74, 0x20, 0x6c, 0x69, + 0x6e, 0x6b, 0x20, 0x6f, 0x72, 0x20, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x20, 0x66, + 0x69, 0x6c, 0x65, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x73, 0x74, 0x61, 0x72, 0x74, 0x20, 0x64, 0x6f, + 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x69, 0x6e, 0x67, 0x20, 0x69, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, + 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x06, 0x06, 0x09, 0x0a, 0x0c, 0x0a, 0x05, + 0x06, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03, 0x06, 0x0a, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, + 0x02, 0x00, 0x03, 0x12, 0x03, 0x06, 0x1f, 0x2a, 0x0a, 0x3a, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x01, + 0x12, 0x03, 0x08, 0x02, 0x34, 0x1a, 0x2d, 0x20, 0x47, 0x65, 0x74, 0x20, 0x74, 0x68, 0x65, 0x20, + 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x20, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x20, 0x6f, + 0x66, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, + 0x6e, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, 0x08, + 0x06, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x02, 0x12, 0x03, 0x08, 0x0d, 0x1a, + 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x03, 0x12, 0x03, 0x08, 0x25, 0x32, 0x0a, 0x35, + 0x0a, 0x04, 0x06, 0x00, 0x02, 0x02, 0x12, 0x03, 0x0a, 0x02, 0x2f, 0x1a, 0x28, 0x20, 0x4c, 0x69, + 0x73, 0x74, 0x20, 0x61, 0x6c, 0x6c, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x73, 0x20, + 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x20, 0x74, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x20, 0x64, 0x61, 0x65, + 0x6d, 0x6f, 0x6e, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x01, 0x12, 0x03, + 0x0a, 0x06, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x02, 0x12, 0x03, 0x0a, 0x0b, + 0x16, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x03, 0x12, 0x03, 0x0a, 0x21, 0x2d, 0x0a, + 0x2e, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x03, 0x12, 0x03, 0x0c, 0x02, 0x35, 0x1a, 0x21, 0x20, 0x52, + 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x20, 0x61, 0x20, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x20, + 0x66, 0x72, 0x6f, 0x6d, 0x20, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x2e, 0x0a, 0x0a, + 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x03, 0x01, 0x12, 0x03, 0x0c, 0x06, 0x0c, 0x0a, 0x0c, 0x0a, + 0x05, 0x06, 0x00, 0x02, 0x03, 0x02, 0x12, 0x03, 0x0c, 0x0d, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, + 0x00, 0x02, 0x03, 0x03, 0x12, 0x03, 0x0c, 0x25, 0x33, 0x0a, 0x2b, 0x0a, 0x04, 0x06, 0x00, 0x02, + 0x04, 0x12, 0x03, 0x0e, 0x02, 0x32, 0x1a, 0x1e, 0x20, 0x50, 0x61, 0x75, 0x73, 0x65, 0x20, 0x61, + 0x20, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x69, 0x6e, 0x67, 0x20, 0x74, 0x6f, 0x72, + 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x04, 0x01, 0x12, + 0x03, 0x0e, 0x06, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x04, 0x02, 0x12, 0x03, 0x0e, + 0x0c, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x04, 0x03, 0x12, 0x03, 0x0e, 0x23, 0x30, + 0x0a, 0x27, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x05, 0x12, 0x03, 0x10, 0x02, 0x35, 0x1a, 0x1a, 0x20, + 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x20, 0x61, 0x20, 0x70, 0x61, 0x75, 0x73, 0x65, 0x64, 0x20, + 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, + 0x05, 0x01, 0x12, 0x03, 0x10, 0x06, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x05, 0x02, + 0x12, 0x03, 0x10, 0x0d, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x05, 0x03, 0x12, 0x03, + 0x10, 0x25, 0x33, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x00, 0x12, 0x04, 0x13, 0x00, 0x17, 0x01, 0x0a, + 0x0a, 0x0a, 0x03, 0x04, 0x00, 0x01, 0x12, 0x03, 0x13, 0x08, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, + 0x00, 0x02, 0x00, 0x05, 0x12, 0x03, 0x14, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x00, 0x02, + 0x00, 0x12, 0x03, 0x14, 0x02, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x01, 0x12, + 0x03, 0x14, 0x09, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x14, + 0x12, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x01, 0x05, 0x12, 0x03, 0x16, 0x02, 0x08, + 0x0a, 0x53, 0x0a, 0x04, 0x04, 0x00, 0x02, 0x01, 0x12, 0x03, 0x16, 0x02, 0x18, 0x1a, 0x46, 0x20, + 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x20, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x20, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, + 0x64, 0x65, 0x2e, 0x20, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x20, 0x6d, 0x65, 0x61, 0x6e, 0x73, 0x20, + 0x75, 0x73, 0x65, 0x20, 0x74, 0x6f, 0x72, 0x61, 0x64, 0x27, 0x73, 0x20, 0x64, 0x65, 0x66, 0x61, + 0x75, 0x6c, 0x74, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, + 0x16, 0x09, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x01, 0x03, 0x12, 0x03, 0x16, 0x16, + 0x17, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x01, 0x12, 0x04, 0x19, 0x00, 0x1b, 0x01, 0x0a, 0x0a, 0x0a, + 0x03, 0x04, 0x01, 0x01, 0x12, 0x03, 0x19, 0x08, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, + 0x00, 0x05, 0x12, 0x03, 0x1a, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x01, 0x02, 0x00, 0x12, + 0x03, 0x1a, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x01, 0x12, 0x03, 0x1a, + 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x03, 0x12, 0x03, 0x1a, 0x0e, 0x0f, + 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x02, 0x12, 0x04, 0x1d, 0x00, 0x1f, 0x01, 0x0a, 0x0a, 0x0a, 0x03, + 0x04, 0x02, 0x01, 0x12, 0x03, 0x1d, 0x08, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, + 0x05, 0x12, 0x03, 0x1e, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x00, 0x12, 0x03, + 0x1e, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x01, 0x12, 0x03, 0x1e, 0x09, + 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x03, 0x12, 0x03, 0x1e, 0x0e, 0x0f, 0x0a, + 0x09, 0x0a, 0x02, 0x04, 0x03, 0x12, 0x03, 0x21, 0x00, 0x16, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x03, + 0x01, 0x12, 0x03, 0x21, 0x08, 0x13, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x04, 0x12, 0x04, 0x23, 0x00, + 0x25, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x04, 0x01, 0x12, 0x03, 0x23, 0x08, 0x14, 0x0a, 0x0c, + 0x0a, 0x05, 0x04, 0x04, 0x02, 0x00, 0x04, 0x12, 0x03, 0x24, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, + 0x04, 0x04, 0x02, 0x00, 0x12, 0x03, 0x24, 0x02, 0x26, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x04, 0x02, + 0x00, 0x06, 0x12, 0x03, 0x24, 0x0b, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x04, 0x02, 0x00, 0x01, + 0x12, 0x03, 0x24, 0x19, 0x21, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x04, 0x02, 0x00, 0x03, 0x12, 0x03, + 0x24, 0x24, 0x25, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x05, 0x12, 0x04, 0x27, 0x00, 0x2a, 0x01, 0x0a, + 0x0a, 0x0a, 0x03, 0x04, 0x05, 0x01, 0x12, 0x03, 0x27, 0x08, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, + 0x05, 0x02, 0x00, 0x05, 0x12, 0x03, 0x28, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, + 0x00, 0x12, 0x03, 0x28, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x00, 0x01, 0x12, + 0x03, 0x28, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x00, 0x03, 0x12, 0x03, 0x28, + 0x0e, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x01, 0x05, 0x12, 0x03, 0x29, 0x02, 0x06, + 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x01, 0x12, 0x03, 0x29, 0x02, 0x18, 0x0a, 0x0c, 0x0a, + 0x05, 0x04, 0x05, 0x02, 0x01, 0x01, 0x12, 0x03, 0x29, 0x07, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, + 0x05, 0x02, 0x01, 0x03, 0x12, 0x03, 0x29, 0x16, 0x17, 0x0a, 0x09, 0x0a, 0x02, 0x04, 0x06, 0x12, + 0x03, 0x2c, 0x00, 0x19, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x06, 0x01, 0x12, 0x03, 0x2c, 0x08, 0x16, + 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x07, 0x12, 0x04, 0x2e, 0x00, 0x30, 0x01, 0x0a, 0x0a, 0x0a, 0x03, + 0x04, 0x07, 0x01, 0x12, 0x03, 0x2e, 0x08, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x00, + 0x05, 0x12, 0x03, 0x2f, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x07, 0x02, 0x00, 0x12, 0x03, + 0x2f, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x00, 0x01, 0x12, 0x03, 0x2f, 0x09, + 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x00, 0x03, 0x12, 0x03, 0x2f, 0x0e, 0x0f, 0x0a, + 0x09, 0x0a, 0x02, 0x04, 0x08, 0x12, 0x03, 0x32, 0x00, 0x18, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x08, + 0x01, 0x12, 0x03, 0x32, 0x08, 0x15, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x09, 0x12, 0x04, 0x34, 0x00, + 0x36, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x09, 0x01, 0x12, 0x03, 0x34, 0x08, 0x15, 0x0a, 0x0c, + 0x0a, 0x05, 0x04, 0x09, 0x02, 0x00, 0x05, 0x12, 0x03, 0x35, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, + 0x04, 0x09, 0x02, 0x00, 0x12, 0x03, 0x35, 0x02, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x09, 0x02, + 0x00, 0x01, 0x12, 0x03, 0x35, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x09, 0x02, 0x00, 0x03, + 0x12, 0x03, 0x35, 0x0e, 0x0f, 0x0a, 0x09, 0x0a, 0x02, 0x04, 0x0a, 0x12, 0x03, 0x38, 0x00, 0x19, + 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x0a, 0x01, 0x12, 0x03, 0x38, 0x08, 0x16, 0x0a, 0x0a, 0x0a, 0x02, + 0x05, 0x00, 0x12, 0x04, 0x3a, 0x00, 0x41, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x05, 0x00, 0x01, 0x12, + 0x03, 0x3a, 0x05, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x3b, + 0x02, 0x13, 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x00, 0x12, 0x03, 0x3b, 0x02, 0x18, 0x0a, + 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03, 0x3b, 0x16, 0x17, 0x0a, 0x0c, 0x0a, + 0x05, 0x05, 0x00, 0x02, 0x01, 0x01, 0x12, 0x03, 0x3c, 0x02, 0x09, 0x0a, 0x0b, 0x0a, 0x04, 0x05, + 0x00, 0x02, 0x01, 0x12, 0x03, 0x3c, 0x02, 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x01, + 0x02, 0x12, 0x03, 0x3c, 0x0c, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x02, 0x01, 0x12, + 0x03, 0x3d, 0x02, 0x0d, 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x02, 0x12, 0x03, 0x3d, 0x02, + 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x02, 0x02, 0x12, 0x03, 0x3d, 0x10, 0x11, 0x0a, + 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x03, 0x01, 0x12, 0x03, 0x3e, 0x02, 0x08, 0x0a, 0x0b, 0x0a, + 0x04, 0x05, 0x00, 0x02, 0x03, 0x12, 0x03, 0x3e, 0x02, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, + 0x02, 0x03, 0x02, 0x12, 0x03, 0x3e, 0x0b, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x04, + 0x01, 0x12, 0x03, 0x3f, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x04, 0x12, 0x03, + 0x3f, 0x02, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x04, 0x02, 0x12, 0x03, 0x3f, 0x0d, + 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x05, 0x00, 0x02, 0x05, 0x01, 0x12, 0x03, 0x40, 0x02, 0x07, 0x0a, + 0x0b, 0x0a, 0x04, 0x05, 0x00, 0x02, 0x05, 0x12, 0x03, 0x40, 0x02, 0x0c, 0x0a, 0x0c, 0x0a, 0x05, + 0x05, 0x00, 0x02, 0x05, 0x02, 0x12, 0x03, 0x40, 0x0a, 0x0b, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x0b, + 0x12, 0x04, 0x43, 0x00, 0x53, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x0b, 0x01, 0x12, 0x03, 0x43, + 0x08, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x00, 0x05, 0x12, 0x03, 0x44, 0x02, 0x08, + 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x00, 0x12, 0x03, 0x44, 0x02, 0x10, 0x0a, 0x0c, 0x0a, + 0x05, 0x04, 0x0b, 0x02, 0x00, 0x01, 0x12, 0x03, 0x44, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, + 0x0b, 0x02, 0x00, 0x03, 0x12, 0x03, 0x44, 0x0e, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, + 0x01, 0x05, 0x12, 0x03, 0x45, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x01, 0x12, + 0x03, 0x45, 0x02, 0x17, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x01, 0x01, 0x12, 0x03, 0x45, + 0x09, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x01, 0x03, 0x12, 0x03, 0x45, 0x15, 0x16, + 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x02, 0x05, 0x12, 0x03, 0x46, 0x02, 0x08, 0x0a, 0x0b, + 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x02, 0x12, 0x03, 0x46, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, + 0x0b, 0x02, 0x02, 0x01, 0x12, 0x03, 0x46, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, + 0x02, 0x03, 0x12, 0x03, 0x46, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x03, 0x05, + 0x12, 0x03, 0x47, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x03, 0x12, 0x03, 0x47, + 0x02, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x03, 0x01, 0x12, 0x03, 0x47, 0x09, 0x0f, + 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x03, 0x03, 0x12, 0x03, 0x47, 0x12, 0x13, 0x0a, 0x0c, + 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x04, 0x05, 0x12, 0x03, 0x48, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, + 0x04, 0x0b, 0x02, 0x04, 0x12, 0x03, 0x48, 0x02, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, + 0x04, 0x01, 0x12, 0x03, 0x48, 0x09, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x04, 0x03, + 0x12, 0x03, 0x48, 0x17, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x05, 0x05, 0x12, 0x03, + 0x49, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x05, 0x12, 0x03, 0x49, 0x02, 0x19, + 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x05, 0x01, 0x12, 0x03, 0x49, 0x09, 0x14, 0x0a, 0x0c, + 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x05, 0x03, 0x12, 0x03, 0x49, 0x17, 0x18, 0x0a, 0x0c, 0x0a, 0x05, + 0x04, 0x0b, 0x02, 0x06, 0x05, 0x12, 0x03, 0x4a, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, + 0x02, 0x06, 0x12, 0x03, 0x4a, 0x02, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x06, 0x01, + 0x12, 0x03, 0x4a, 0x09, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x06, 0x03, 0x12, 0x03, + 0x4a, 0x1c, 0x1d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x07, 0x06, 0x12, 0x03, 0x4b, 0x02, + 0x07, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x07, 0x12, 0x03, 0x4b, 0x02, 0x12, 0x0a, 0x0c, + 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x07, 0x01, 0x12, 0x03, 0x4b, 0x08, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, + 0x04, 0x0b, 0x02, 0x07, 0x03, 0x12, 0x03, 0x4b, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, + 0x02, 0x08, 0x05, 0x12, 0x03, 0x4c, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x08, + 0x12, 0x03, 0x4c, 0x02, 0x1b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x08, 0x01, 0x12, 0x03, + 0x4c, 0x09, 0x16, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x08, 0x03, 0x12, 0x03, 0x4c, 0x19, + 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x09, 0x05, 0x12, 0x03, 0x4d, 0x02, 0x08, 0x0a, + 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x09, 0x12, 0x03, 0x4d, 0x02, 0x21, 0x0a, 0x0c, 0x0a, 0x05, + 0x04, 0x0b, 0x02, 0x09, 0x01, 0x12, 0x03, 0x4d, 0x09, 0x1b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, + 0x02, 0x09, 0x03, 0x12, 0x03, 0x4d, 0x1e, 0x20, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0a, + 0x05, 0x12, 0x03, 0x4e, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x0a, 0x12, 0x03, + 0x4e, 0x02, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0a, 0x01, 0x12, 0x03, 0x4e, 0x09, + 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0a, 0x03, 0x12, 0x03, 0x4e, 0x17, 0x19, 0x0a, + 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0b, 0x05, 0x12, 0x03, 0x4f, 0x02, 0x08, 0x0a, 0x0b, 0x0a, + 0x04, 0x04, 0x0b, 0x02, 0x0b, 0x12, 0x03, 0x4f, 0x02, 0x1d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, + 0x02, 0x0b, 0x01, 0x12, 0x03, 0x4f, 0x09, 0x17, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0b, + 0x03, 0x12, 0x03, 0x4f, 0x1a, 0x1c, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0c, 0x05, 0x12, + 0x03, 0x50, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x0c, 0x12, 0x03, 0x50, 0x02, + 0x1f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0c, 0x01, 0x12, 0x03, 0x50, 0x09, 0x19, 0x0a, + 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0c, 0x03, 0x12, 0x03, 0x50, 0x1c, 0x1e, 0x0a, 0x0c, 0x0a, + 0x05, 0x04, 0x0b, 0x02, 0x0d, 0x05, 0x12, 0x03, 0x51, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, + 0x0b, 0x02, 0x0d, 0x12, 0x03, 0x51, 0x02, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0d, + 0x01, 0x12, 0x03, 0x51, 0x09, 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0d, 0x03, 0x12, + 0x03, 0x51, 0x11, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0e, 0x05, 0x12, 0x03, 0x52, + 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0b, 0x02, 0x0e, 0x12, 0x03, 0x52, 0x02, 0x14, 0x0a, + 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x0e, 0x01, 0x12, 0x03, 0x52, 0x09, 0x0e, 0x0a, 0x0c, 0x0a, + 0x05, 0x04, 0x0b, 0x02, 0x0e, 0x03, 0x12, 0x03, 0x52, 0x11, 0x13, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, ]; include!("torrent.serde.rs"); include!("torrent.tonic.rs"); diff --git a/crates/proto/src/generated/torrent/torrent.serde.rs b/crates/proto/src/generated/torrent/torrent.serde.rs index cb5e864..c626f55 100644 --- a/crates/proto/src/generated/torrent/torrent.serde.rs +++ b/crates/proto/src/generated/torrent/torrent.serde.rs @@ -362,6 +362,511 @@ impl<'de> serde::Deserialize<'de> for ListResponse { deserializer.deserialize_struct("torrent.ListResponse", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for PauseRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("torrent.PauseRequest", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for PauseRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &["id"]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + } + 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 { + "id" => Ok(GeneratedField::Id), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = PauseRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent.PauseRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map_.next_value()?); + } + } + } + Ok(PauseRequest { + id: id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("torrent.PauseRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for PauseResponse { + #[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.PauseResponse", len)?; + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for PauseResponse { + #[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 = PauseResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent.PauseResponse") + } + + 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(PauseResponse {}) + } + } + deserializer.deserialize_struct("torrent.PauseResponse", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for RemoveRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + if self.delete_files { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("torrent.RemoveRequest", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + if self.delete_files { + struct_ser.serialize_field("deleteFiles", &self.delete_files)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for RemoveRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &["id", "delete_files", "deleteFiles"]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + DeleteFiles, + } + 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 { + "id" => Ok(GeneratedField::Id), + "deleteFiles" | "delete_files" => Ok(GeneratedField::DeleteFiles), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = RemoveRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent.RemoveRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + let mut delete_files__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map_.next_value()?); + } + GeneratedField::DeleteFiles => { + if delete_files__.is_some() { + return Err(serde::de::Error::duplicate_field("deleteFiles")); + } + delete_files__ = Some(map_.next_value()?); + } + } + } + Ok(RemoveRequest { + id: id__.unwrap_or_default(), + delete_files: delete_files__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("torrent.RemoveRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for RemoveResponse { + #[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.RemoveResponse", len)?; + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for RemoveResponse { + #[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 = RemoveResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent.RemoveResponse") + } + + 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(RemoveResponse {}) + } + } + deserializer.deserialize_struct("torrent.RemoveResponse", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for ResumeRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("torrent.ResumeRequest", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for ResumeRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &["id"]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + } + 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 { + "id" => Ok(GeneratedField::Id), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = ResumeRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent.ResumeRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map_.next_value()?); + } + } + } + Ok(ResumeRequest { + id: id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("torrent.ResumeRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for ResumeResponse { + #[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.ResumeResponse", len)?; + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for ResumeResponse { + #[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 = ResumeResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct torrent.ResumeResponse") + } + + 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(ResumeResponse {}) + } + } + deserializer.deserialize_struct("torrent.ResumeResponse", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for State { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -572,6 +1077,24 @@ impl serde::Serialize for TorrentStatus { if !self.error_message.is_empty() { len += 1; } + if self.download_speed_bps != 0 { + len += 1; + } + if self.eta_seconds != 0 { + len += 1; + } + if self.uploaded_bytes != 0 { + len += 1; + } + if self.upload_speed_bps != 0 { + len += 1; + } + if self.peers != 0 { + len += 1; + } + if self.seeds != 0 { + len += 1; + } let mut struct_ser = serializer.serialize_struct("torrent.TorrentStatus", len)?; if !self.id.is_empty() { struct_ser.serialize_field("id", &self.id)?; @@ -613,6 +1136,44 @@ impl serde::Serialize for TorrentStatus { if !self.error_message.is_empty() { struct_ser.serialize_field("errorMessage", &self.error_message)?; } + if self.download_speed_bps != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field( + "downloadSpeedBps", + ToString::to_string(&self.download_speed_bps).as_str(), + )?; + } + if self.eta_seconds != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field( + "etaSeconds", + ToString::to_string(&self.eta_seconds).as_str(), + )?; + } + if self.uploaded_bytes != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field( + "uploadedBytes", + ToString::to_string(&self.uploaded_bytes).as_str(), + )?; + } + if self.upload_speed_bps != 0 { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field( + "uploadSpeedBps", + ToString::to_string(&self.upload_speed_bps).as_str(), + )?; + } + if self.peers != 0 { + struct_ser.serialize_field("peers", &self.peers)?; + } + if self.seeds != 0 { + struct_ser.serialize_field("seeds", &self.seeds)?; + } struct_ser.end() } } @@ -637,6 +1198,16 @@ impl<'de> serde::Deserialize<'de> for TorrentStatus { "state", "error_message", "errorMessage", + "download_speed_bps", + "downloadSpeedBps", + "eta_seconds", + "etaSeconds", + "uploaded_bytes", + "uploadedBytes", + "upload_speed_bps", + "uploadSpeedBps", + "peers", + "seeds", ]; #[allow(clippy::enum_variant_names)] @@ -650,6 +1221,12 @@ impl<'de> serde::Deserialize<'de> for TorrentStatus { DownloadedBytes, State, ErrorMessage, + DownloadSpeedBps, + EtaSeconds, + UploadedBytes, + UploadSpeedBps, + Peers, + Seeds, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -685,6 +1262,16 @@ impl<'de> serde::Deserialize<'de> for TorrentStatus { } "state" => Ok(GeneratedField::State), "errorMessage" | "error_message" => Ok(GeneratedField::ErrorMessage), + "downloadSpeedBps" | "download_speed_bps" => { + Ok(GeneratedField::DownloadSpeedBps) + } + "etaSeconds" | "eta_seconds" => Ok(GeneratedField::EtaSeconds), + "uploadedBytes" | "uploaded_bytes" => Ok(GeneratedField::UploadedBytes), + "uploadSpeedBps" | "upload_speed_bps" => { + Ok(GeneratedField::UploadSpeedBps) + } + "peers" => Ok(GeneratedField::Peers), + "seeds" => Ok(GeneratedField::Seeds), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -713,6 +1300,12 @@ impl<'de> serde::Deserialize<'de> for TorrentStatus { let mut downloaded_bytes__ = None; let mut state__ = None; let mut error_message__ = None; + let mut download_speed_bps__ = None; + let mut eta_seconds__ = None; + let mut uploaded_bytes__ = None; + let mut upload_speed_bps__ = None; + let mut peers__ = None; + let mut seeds__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::Id => { @@ -775,6 +1368,60 @@ impl<'de> serde::Deserialize<'de> for TorrentStatus { } error_message__ = Some(map_.next_value()?); } + GeneratedField::DownloadSpeedBps => { + if download_speed_bps__.is_some() { + return Err(serde::de::Error::duplicate_field("downloadSpeedBps")); + } + download_speed_bps__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } + GeneratedField::EtaSeconds => { + if eta_seconds__.is_some() { + return Err(serde::de::Error::duplicate_field("etaSeconds")); + } + eta_seconds__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } + GeneratedField::UploadedBytes => { + if uploaded_bytes__.is_some() { + return Err(serde::de::Error::duplicate_field("uploadedBytes")); + } + uploaded_bytes__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } + GeneratedField::UploadSpeedBps => { + if upload_speed_bps__.is_some() { + return Err(serde::de::Error::duplicate_field("uploadSpeedBps")); + } + upload_speed_bps__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } + GeneratedField::Peers => { + if peers__.is_some() { + return Err(serde::de::Error::duplicate_field("peers")); + } + peers__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } + GeneratedField::Seeds => { + if seeds__.is_some() { + return Err(serde::de::Error::duplicate_field("seeds")); + } + seeds__ = Some( + map_.next_value::<::pbjson::private::NumberDeserialize<_>>()? + .0, + ); + } } } Ok(TorrentStatus { @@ -787,6 +1434,12 @@ impl<'de> serde::Deserialize<'de> for TorrentStatus { downloaded_bytes: downloaded_bytes__.unwrap_or_default(), state: state__.unwrap_or_default(), error_message: error_message__.unwrap_or_default(), + download_speed_bps: download_speed_bps__.unwrap_or_default(), + eta_seconds: eta_seconds__.unwrap_or_default(), + uploaded_bytes: uploaded_bytes__.unwrap_or_default(), + upload_speed_bps: upload_speed_bps__.unwrap_or_default(), + peers: peers__.unwrap_or_default(), + seeds: seeds__.unwrap_or_default(), }) } } diff --git a/crates/proto/src/generated/torrent/torrent.tonic.rs b/crates/proto/src/generated/torrent/torrent.tonic.rs index 83e57a7..2f75817 100644 --- a/crates/proto/src/generated/torrent/torrent.tonic.rs +++ b/crates/proto/src/generated/torrent/torrent.tonic.rs @@ -131,6 +131,48 @@ pub mod torrents_client { .insert(GrpcMethod::new("torrent.Torrents", "List")); self.inner.unary(req, path, codec).await } + pub async fn remove( + &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.Torrents/Remove"); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("torrent.Torrents", "Remove")); + self.inner.unary(req, path, codec).await + } + pub async fn pause( + &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.Torrents/Pause"); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("torrent.Torrents", "Pause")); + self.inner.unary(req, path, codec).await + } + pub async fn resume( + &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.Torrents/Resume"); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("torrent.Torrents", "Resume")); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -158,6 +200,18 @@ pub mod torrents_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + async fn remove( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + async fn pause( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + async fn resume( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; } #[derive(Debug)] pub struct TorrentsServer { @@ -343,6 +397,117 @@ pub mod torrents_server { }; Box::pin(fut) } + "/torrent.Torrents/Remove" => { + #[allow(non_camel_case_types)] + struct RemoveSvc(pub Arc); + impl tonic::server::UnaryService for RemoveSvc { + type Response = super::RemoveResponse; + type Future = BoxFuture, tonic::Status>; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { ::remove(&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 = RemoveSvc(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) + } + "/torrent.Torrents/Pause" => { + #[allow(non_camel_case_types)] + struct PauseSvc(pub Arc); + impl tonic::server::UnaryService for PauseSvc { + type Response = super::PauseResponse; + type Future = BoxFuture, tonic::Status>; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { ::pause(&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 = PauseSvc(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) + } + "/torrent.Torrents/Resume" => { + #[allow(non_camel_case_types)] + struct ResumeSvc(pub Arc); + impl tonic::server::UnaryService for ResumeSvc { + type Response = super::ResumeResponse; + type Future = BoxFuture, tonic::Status>; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { ::resume(&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 = ResumeSvc(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(); diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 831eeac..e56e4f6 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -3,7 +3,8 @@ pub mod torrent { } pub use torrent::{ - AddRequest, AddResponse, ListRequest, ListResponse, State, StatusRequest, TorrentStatus, + AddRequest, AddResponse, ListRequest, ListResponse, PauseRequest, PauseResponse, RemoveRequest, + RemoveResponse, ResumeRequest, ResumeResponse, State, StatusRequest, TorrentStatus, torrents_client::TorrentsClient, torrents_server::{Torrents, TorrentsServer}, }; diff --git a/crates/tora/Cargo.toml b/crates/tora/Cargo.toml index 7f1711a..8900d96 100644 --- a/crates/tora/Cargo.toml +++ b/crates/tora/Cargo.toml @@ -6,7 +6,10 @@ edition = "2024" [dependencies] anyhow = "1.0.103" clap = { version = "4.6.1", features = ["derive", "env"] } +colored = "3" hyper-util = { version = "0.1.20", features = ["tokio"] } +nix = { version = "0.31", features = ["signal"] } +serde_json = "1.0.140" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net"] } tonic = "0.14.6" tonic-health = "0.14.6" diff --git a/crates/tora/src/cli.rs b/crates/tora/src/cli.rs index 15a4052..4f4b76c 100644 --- a/crates/tora/src/cli.rs +++ b/crates/tora/src/cli.rs @@ -1,26 +1,56 @@ use std::path::PathBuf; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; #[derive(Parser)] -#[command(name = "tora", about = "CLI client for the tora torrent daemon")] +#[command( + name = "tora", + version, + about = "CLI client for the tora torrent daemon" +)] pub struct Cli { /// Path to torad's Unix domain socket. #[arg(long, global = true, env = "TORAD_SOCKET")] pub socket: Option, + /// Output format for results. + #[arg(long, short = 'o', global = true, value_enum, default_value_t = OutputFormat::Long)] + pub format: OutputFormat, + #[command(subcommand)] pub command: Command, } +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum OutputFormat { + /// Human-readable key-value format (default). + Long, + /// Machine-readable JSON. + Json, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum SortKey { + /// Sort by torrent name (alphabetical). + Name, + /// Sort by download progress (highest first). + Progress, + /// Sort by total size (largest first). + Size, + /// Sort by download speed (fastest first). + Speed, + /// Sort by time added (newest first, default). + Added, +} + #[derive(Subcommand)] pub enum Command { /// Check whether torad is reachable and healthy. Health, - /// Start downloading a torrent from a magnet link. + /// Start downloading a torrent from a magnet link or .torrent file. Download { - /// The magnet link to download. - magnet: String, + /// Magnet link, HTTP(S) URL, or path to a .torrent file. + source: String, /// Directory to save the torrent into. Defaults to torad's configured download directory. #[arg(long)] output_dir: Option, @@ -31,5 +61,38 @@ pub enum Command { id: String, }, /// List all torrents known to torad. - List, + List { + /// Sort torrents by the given key. + #[arg(long, value_enum)] + sort: Option, + }, + /// Remove a torrent from torad. + Remove { + /// The torrent id. + id: String, + /// Also delete downloaded files from disk. + #[arg(long)] + delete_files: bool, + }, + /// Pause a downloading torrent. + Pause { + /// The torrent id. + id: String, + }, + /// Resume a paused torrent. + Resume { + /// The torrent id. + id: String, + }, + /// Start the torad daemon in the background. + Start { + /// Postgres connection string. + #[arg(long, env = "DATABASE_URL")] + database_url: String, + /// Directory torrents are downloaded into by default. + #[arg(long)] + download_dir: Option, + }, + /// Stop the torad daemon. + Stop, } diff --git a/crates/tora/src/main.rs b/crates/tora/src/main.rs index accf76d..e80e620 100644 --- a/crates/tora/src/main.rs +++ b/crates/tora/src/main.rs @@ -1,33 +1,56 @@ mod cli; +mod output; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; use clap::Parser; -use cli::{Cli, Command}; +use cli::{Cli, Command, SortKey}; use hyper_util::rt::TokioIo; +use output::Formatter; use tokio::net::UnixStream; use tonic::transport::{Channel, Endpoint, Uri}; use tonic_health::pb::HealthCheckRequest; use tonic_health::pb::health_check_response::ServingStatus; use tonic_health::pb::health_client::HealthClient; -use tora_proto::{AddRequest, ListRequest, State, StatusRequest, TorrentStatus, TorrentsClient}; +use tora_proto::{ + AddRequest, ListRequest, PauseRequest, RemoveRequest, ResumeRequest, StatusRequest, + TorrentsClient, +}; use tower::service_fn; +const RPC_TIMEOUT: Duration = Duration::from_secs(10); + fn default_socket_path() -> PathBuf { std::env::temp_dir().join("torad.sock") } +fn default_pid_file_path() -> PathBuf { + std::env::temp_dir().join("torad.pid") +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let socket = cli.socket.unwrap_or_else(default_socket_path); + let pid_file = default_pid_file_path(); + let formatter = output::from_format(cli.format); + let f = formatter.as_ref(); match cli.command { - Command::Health => health(&socket).await, - Command::Download { magnet, output_dir } => download(&socket, magnet, output_dir).await, - Command::Status { id } => status(&socket, id).await, - Command::List => list(&socket).await, + Command::Health => health(&socket, f).await, + Command::Download { source, output_dir } => download(&socket, source, output_dir, f).await, + Command::Status { id } => status(&socket, id, f).await, + Command::List { sort } => list(&socket, sort, f).await, + Command::Remove { id, delete_files } => remove(&socket, id, delete_files, f).await, + Command::Pause { id } => pause(&socket, id, f).await, + Command::Resume { id } => resume(&socket, id, f).await, + Command::Start { + database_url, + download_dir, + } => start_daemon(&socket, &pid_file, &database_url, download_dir, f).await, + Command::Stop => stop_daemon(&socket, &pid_file, f).await, } } @@ -41,103 +64,208 @@ async fn connect(socket: &Path) -> Result { .await .with_context(|| { format!( - "could not reach torad at {} - is it running? start it with `torad`", + "could not reach torad at {} - is it running? start it with `tora start`", socket.display() ) })?; Ok(channel) } -async fn health(socket: &Path) -> Result<()> { +async fn health(socket: &Path, f: &dyn Formatter) -> Result<()> { let channel = connect(socket).await?; - let mut client = HealthClient::new(channel); - let response = client - .check(HealthCheckRequest { + let response = tokio::time::timeout( + RPC_TIMEOUT, + client.check(HealthCheckRequest { service: String::new(), - }) - .await - .context("health check RPC failed")?; + }), + ) + .await + .context("health check timed out - is torad responsive?")? + .context("health check RPC failed")?; let status = ServingStatus::try_from(response.into_inner().status).unwrap_or(ServingStatus::Unknown); + let healthy = status == ServingStatus::Serving; - println!("{}", status.as_str_name()); - if status != ServingStatus::Serving { + println!("{}", f.health(healthy, status.as_str_name())?); + + if !healthy { std::process::exit(1); } Ok(()) } -async fn download(socket: &Path, magnet: String, output_dir: Option) -> Result<()> { +async fn download( + socket: &Path, + source: String, + output_dir: Option, + f: &dyn Formatter, +) -> Result<()> { let channel = connect(socket).await?; let mut client = TorrentsClient::new(channel); - let response = client - .add(AddRequest { - magnet, + let response = tokio::time::timeout( + RPC_TIMEOUT, + client.add(AddRequest { + magnet: source, output_dir: output_dir.unwrap_or_default(), - }) - .await - .context("Add RPC failed")?; + }), + ) + .await + .context("add request timed out")? + .context("Add RPC failed")?; - println!("{}", response.into_inner().id); + println!("{}", f.download(&response.into_inner().id)?); Ok(()) } -async fn status(socket: &Path, id: String) -> Result<()> { +async fn status(socket: &Path, id: String, f: &dyn Formatter) -> Result<()> { let channel = connect(socket).await?; let mut client = TorrentsClient::new(channel); - let response = client - .status(StatusRequest { id }) + let response = tokio::time::timeout(RPC_TIMEOUT, client.status(StatusRequest { id })) .await + .context("status request timed out")? .context("Status RPC failed")?; - print_status(&response.into_inner()); + println!("{}", f.status(&response.into_inner())?); Ok(()) } -async fn list(socket: &Path) -> Result<()> { +async fn list(socket: &Path, sort: Option, f: &dyn Formatter) -> Result<()> { let channel = connect(socket).await?; let mut client = TorrentsClient::new(channel); - let response = client - .list(ListRequest {}) + let response = tokio::time::timeout(RPC_TIMEOUT, client.list(ListRequest {})) .await + .context("list request timed out")? .context("List RPC failed")? .into_inner(); - if response.torrents.is_empty() { - println!("no torrents"); - return Ok(()); - } - for torrent in &response.torrents { - print_status(torrent); - println!(); + let mut torrents = response.torrents; + if let Some(key) = sort { + sort_torrents(&mut torrents, key); } + + println!("{}", f.list(&torrents)?); Ok(()) } -fn print_status(status: &TorrentStatus) { - let state = State::try_from(status.state).unwrap_or(State::Unspecified); - println!("id: {}", status.id); - println!("info_hash: {}", status.info_hash); - println!( - "name: {}", - if status.name.is_empty() { - "(unknown)" - } else { - &status.name - } - ); - println!("state: {}", state.as_str_name()); - println!( - "progress: {} / {} bytes", - status.downloaded_bytes, status.total_bytes - ); - println!("output_path: {}", status.output_path); - if !status.error_message.is_empty() { - println!("error: {}", status.error_message); +fn sort_torrents(torrents: &mut [tora_proto::TorrentStatus], key: SortKey) { + match key { + SortKey::Name => torrents.sort_by(|a, b| a.name.cmp(&b.name)), + SortKey::Progress => torrents.sort_by_key(|t| std::cmp::Reverse(pct(t))), + SortKey::Size => torrents.sort_by_key(|t| std::cmp::Reverse(t.total_bytes)), + SortKey::Speed => torrents.sort_by_key(|t| std::cmp::Reverse(t.download_speed_bps)), + SortKey::Added => {} + } +} + +fn pct(t: &tora_proto::TorrentStatus) -> u64 { + t.downloaded_bytes + .checked_mul(100) + .and_then(|v| v.checked_div(t.total_bytes)) + .unwrap_or(0) +} + +async fn remove(socket: &Path, id: String, delete_files: bool, f: &dyn Formatter) -> Result<()> { + let channel = connect(socket).await?; + let mut client = TorrentsClient::new(channel); + + tokio::time::timeout( + RPC_TIMEOUT, + client.remove(RemoveRequest { id, delete_files }), + ) + .await + .context("remove request timed out")? + .context("Remove RPC failed")?; + + println!("{}", f.remove(delete_files)?); + Ok(()) +} + +async fn pause(socket: &Path, id: String, f: &dyn Formatter) -> Result<()> { + let channel = connect(socket).await?; + let mut client = TorrentsClient::new(channel); + + tokio::time::timeout(RPC_TIMEOUT, client.pause(PauseRequest { id })) + .await + .context("pause request timed out")? + .context("Pause RPC failed")?; + + println!("{}", f.pause()?); + Ok(()) +} + +async fn resume(socket: &Path, id: String, f: &dyn Formatter) -> Result<()> { + let channel = connect(socket).await?; + let mut client = TorrentsClient::new(channel); + + tokio::time::timeout(RPC_TIMEOUT, client.resume(ResumeRequest { id })) + .await + .context("resume request timed out")? + .context("Resume RPC failed")?; + + println!("{}", f.resume()?); + Ok(()) +} + +async fn start_daemon( + socket: &Path, + pid_file: &Path, + database_url: &str, + download_dir: Option, + f: &dyn Formatter, +) -> Result<()> { + let mut cmd = std::process::Command::new("torad"); + cmd.arg("--database-url").arg(database_url); + cmd.arg("--socket").arg(socket); + cmd.arg("--pid-file").arg(pid_file); + if let Some(dir) = download_dir { + cmd.arg("--download-dir").arg(dir); + } + + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::null()); + + cmd.spawn() + .context("failed to spawn torad - is the binary installed and on PATH?")?; + + println!("{}", f.start()?); + Ok(()) +} + +async fn stop_daemon(socket: &Path, pid_file: &Path, f: &dyn Formatter) -> Result<()> { + let pid_str = std::fs::read_to_string(pid_file).with_context(|| { + format!( + "failed to read PID file at {} - is torad running?", + pid_file.display() + ) + })?; + let pid: i32 = pid_str + .trim() + .parse() + .with_context(|| format!("invalid PID in file: {pid_str:?}"))?; + + let result = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGTERM, + ); + + match result { + Ok(()) => { + let _ = std::fs::remove_file(socket); + let _ = std::fs::remove_file(pid_file); + println!("{}", f.stop()?); + Ok(()) + } + Err(nix::errno::Errno::ESRCH) => { + let _ = std::fs::remove_file(socket); + let _ = std::fs::remove_file(pid_file); + anyhow::bail!("torad (PID {pid}) is not running - cleaned up stale PID file") + } + Err(err) => Err(err).context("failed to send SIGTERM to torad"), } } diff --git a/crates/tora/src/output.rs b/crates/tora/src/output.rs new file mode 100644 index 0000000..cfbd08a --- /dev/null +++ b/crates/tora/src/output.rs @@ -0,0 +1,1028 @@ +use std::fmt::Write; + +use anyhow::Result; +use colored::Colorize; +use tora_proto::{State, TorrentStatus}; + +use crate::cli::OutputFormat; + +const BAR_WIDTH: usize = 20; + +pub trait Formatter { + fn health(&self, healthy: bool, status_name: &str) -> Result; + fn download(&self, id: &str) -> Result; + fn status(&self, status: &TorrentStatus) -> Result; + fn list(&self, torrents: &[TorrentStatus]) -> Result; + fn remove(&self, delete_files: bool) -> Result; + fn pause(&self) -> Result; + fn resume(&self) -> Result; + fn start(&self) -> Result; + fn stop(&self) -> Result; +} + +pub fn from_format(format: OutputFormat) -> Box { + match format { + OutputFormat::Long => Box::new(LongFormatter), + OutputFormat::Json => Box::new(JsonFormatter), + } +} + +struct LongFormatter; + +impl Formatter for LongFormatter { + fn health(&self, healthy: bool, status_name: &str) -> Result { + let mut out = String::new(); + if healthy { + writeln!(out, " {} {}", "●".green(), "torad is running".green())?; + } else { + writeln!( + out, + " {} {}", + "●".red(), + format!("torad is not running ({status_name})").red(), + )?; + } + Ok(out.trim_end().to_string()) + } + + fn download(&self, id: &str) -> Result { + Ok(format!(" {} added torrent {}", "✓".green(), id.dimmed())) + } + + fn status(&self, status: &TorrentStatus) -> Result { + Ok(format_status_long(status)) + } + + fn list(&self, torrents: &[TorrentStatus]) -> Result { + Ok(format_list_long(torrents)) + } + + fn remove(&self, delete_files: bool) -> Result { + let detail = if delete_files { " (files deleted)" } else { "" }; + Ok(format!(" {} removed torrent{}", "✓".green(), detail)) + } + + fn pause(&self) -> Result { + Ok(format!(" {} torrent paused", "⏸ ".cyan())) + } + + fn resume(&self) -> Result { + Ok(format!(" {} torrent resumed", "▶ ".green())) + } + + fn start(&self) -> Result { + Ok(format!(" {} torad started", "●".green())) + } + + fn stop(&self) -> Result { + Ok(format!(" {} torad stopped", "●".yellow())) + } +} + +struct JsonFormatter; + +impl Formatter for JsonFormatter { + fn health(&self, healthy: bool, status_name: &str) -> Result { + let json = serde_json::json!({ "status": status_name, "healthy": healthy }); + Ok(serde_json::to_string_pretty(&json)?) + } + + fn download(&self, id: &str) -> Result { + Ok(serde_json::to_string_pretty( + &serde_json::json!({ "id": id }), + )?) + } + + fn status(&self, status: &TorrentStatus) -> Result { + Ok(serde_json::to_string_pretty(status)?) + } + + fn list(&self, torrents: &[TorrentStatus]) -> Result { + Ok(serde_json::to_string_pretty(torrents)?) + } + + fn remove(&self, _delete_files: bool) -> Result { + Ok(serde_json::to_string_pretty( + &serde_json::json!({ "removed": true }), + )?) + } + + fn pause(&self) -> Result { + Ok(serde_json::to_string_pretty( + &serde_json::json!({ "paused": true }), + )?) + } + + fn resume(&self) -> Result { + Ok(serde_json::to_string_pretty( + &serde_json::json!({ "resumed": true }), + )?) + } + + fn start(&self) -> Result { + Ok(serde_json::to_string_pretty( + &serde_json::json!({ "started": true }), + )?) + } + + fn stop(&self) -> Result { + Ok(serde_json::to_string_pretty( + &serde_json::json!({ "stopped": true }), + )?) + } +} + +fn format_status_long(status: &TorrentStatus) -> String { + let state = State::try_from(status.state).unwrap_or(State::Unspecified); + let name = if status.name.is_empty() { + "(unknown)" + } else { + &status.name + }; + + let mut out = String::new(); + let _ = writeln!(out); + let _ = writeln!(out, " {} {}", name.bold(), colored_state(state)); + let _ = writeln!(out, " {}", "─".repeat(70).dimmed()); + let _ = writeln!(out, " {}{}", pad("id", 14).dimmed(), status.id); + let _ = writeln!( + out, + " {}{}", + pad("info hash", 14).dimmed(), + status.info_hash + ); + let _ = writeln!( + out, + " {}{} ({})", + pad("progress", 14).dimmed(), + progress_bar(status.downloaded_bytes, status.total_bytes), + format_progress(status.downloaded_bytes, status.total_bytes), + ); + if status.download_speed_bps > 0 { + let _ = writeln!( + out, + " {}{}", + pad("speed", 14).dimmed(), + humanize_speed(status.download_speed_bps), + ); + } + if status.eta_seconds > 0 { + let _ = writeln!( + out, + " {}{}", + pad("eta", 14).dimmed(), + humanize_eta(status.eta_seconds), + ); + } + if status.upload_speed_bps > 0 { + let _ = writeln!( + out, + " {}{}", + pad("upload", 14).dimmed(), + humanize_speed(status.upload_speed_bps).green(), + ); + } + if status.uploaded_bytes > 0 { + let _ = writeln!( + out, + " {}{}", + pad("uploaded", 14).dimmed(), + humanize_bytes(status.uploaded_bytes), + ); + } + if status.peers > 0 || status.seeds > 0 { + let _ = writeln!( + out, + " {}{} peers {} seeds", + pad("peers", 14).dimmed(), + status.peers, + status.seeds, + ); + } + let _ = writeln!( + out, + " {}{}", + pad("output", 14).dimmed(), + status.output_path + ); + if !status.error_message.is_empty() { + let _ = writeln!( + out, + " {}{}", + pad("error", 14).red().bold(), + status.error_message + ); + } + out +} + +fn format_list_long(torrents: &[TorrentStatus]) -> String { + if torrents.is_empty() { + return " no torrents".dimmed().to_string(); + } + + let id_w = 8usize; + let name_w = torrents + .iter() + .map(|t| display_name(&t.name).chars().count()) + .max() + .unwrap_or(4) + .clamp(4, 40); + let state_w = 11; + + let mut out = String::new(); + let _ = writeln!(out); + let _ = writeln!( + out, + " {} {} {} {}", + pad("ID", id_w).bold(), + pad("NAME", name_w).bold(), + pad("STATE", state_w).bold(), + "PROGRESS".bold(), + ); + let _ = writeln!( + out, + " {}", + "─".repeat(id_w + name_w + state_w + 32).dimmed() + ); + + for t in torrents { + let state = State::try_from(t.state).unwrap_or(State::Unspecified); + let state_str = state.as_str_name().to_lowercase(); + let id = short_id(&t.id); + let name = display_name(&t.name); + let bar = progress_bar(t.downloaded_bytes, t.total_bytes); + let size = format_progress(t.downloaded_bytes, t.total_bytes); + + let speed_eta = if state == State::Downloading && t.download_speed_bps > 0 { + let mut s = format!( + " {} {}", + "↓".yellow(), + humanize_speed(t.download_speed_bps).yellow() + ); + if t.eta_seconds > 0 { + s.push_str(&format!( + " {} {}", + "ETA".dimmed(), + humanize_eta(t.eta_seconds) + )); + } + if t.peers > 0 { + s.push_str(&format!(" {} {}", "peers".dimmed(), t.peers)); + } + s + } else { + String::new() + }; + + let _ = writeln!( + out, + " {} {} {} {} ({}){}", + pad(&id, id_w).dimmed(), + pad(&name, name_w), + pad_colored(&state_str, state_w, state), + bar, + size.dimmed(), + speed_eta, + ); + } + + let count = torrents.len(); + let noun = if count == 1 { "torrent" } else { "torrents" }; + let _ = writeln!(out); + let _ = writeln!(out, " {count} {noun}"); + out +} + +fn colored_state(state: State) -> colored::ColoredString { + let s = state.as_str_name().to_lowercase(); + match state { + State::Finished => s.green().bold(), + State::Downloading => s.yellow(), + State::Paused => s.cyan(), + State::Error => s.red().bold(), + State::Pending | State::Unspecified => s.dimmed(), + } +} + +fn pad_colored(s: &str, width: usize, state: State) -> colored::ColoredString { + let padded = pad(s, width); + match state { + State::Finished => padded.green().bold(), + State::Downloading => padded.yellow(), + State::Paused => padded.cyan(), + State::Error => padded.red().bold(), + State::Pending | State::Unspecified => padded.dimmed(), + } +} + +fn progress_bar(downloaded: u64, total: u64) -> String { + let pct = percentage(downloaded, total); + let filled = if total == 0 { + 0 + } else { + pct as usize * BAR_WIDTH / 100 + }; + let empty = BAR_WIDTH - filled; + format!("{}{} {:>3}%", "█".repeat(filled), "░".repeat(empty), pct) +} + +fn pad(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + s.to_string() + } else { + format!("{s}{}", " ".repeat(width - len)) + } +} + +fn short_id(id: &str) -> String { + id.split('-').next().unwrap_or(id).to_string() +} + +fn display_name(name: &str) -> String { + if name.is_empty() { + return "(unknown)".to_string(); + } + const MAX: usize = 40; + if name.chars().count() > MAX { + let truncated: String = name.chars().take(MAX - 3).collect(); + format!("{truncated}...") + } else { + name.to_string() + } +} + +fn format_progress(downloaded: u64, total: u64) -> String { + if total == 0 { + return format!("{} / ?", humanize_bytes(downloaded)); + } + format!("{} / {}", humanize_bytes(downloaded), humanize_bytes(total)) +} + +fn percentage(downloaded: u64, total: u64) -> u64 { + if total == 0 { + return 0; + } + downloaded * 100 / total +} + +fn humanize_bytes(bytes: u64) -> String { + const UNITS: &[(&str, u64)] = &[ + ("PiB", 1_125_899_906_842_624), + ("TiB", 1_099_511_627_776), + ("GiB", 1_073_741_824), + ("MiB", 1_048_576), + ("KiB", 1_024), + ]; + for (unit, threshold) in UNITS { + if bytes >= *threshold { + let value = bytes as f64 / *threshold as f64; + if value >= 100.0 { + return format!("{:.0} {}", value, unit); + } + if value >= 10.0 { + return format!("{:.1} {}", value, unit); + } + return format!("{:.2} {}", value, unit); + } + } + format!("{} B", bytes) +} + +fn humanize_speed(bps: u64) -> String { + if bps == 0 { + return "—".to_string(); + } + format!("{}/s", humanize_bytes(bps)) +} + +fn humanize_eta(seconds: u64) -> String { + if seconds == 0 { + return "—".to_string(); + } + let d = seconds / 86400; + let h = (seconds % 86400) / 3600; + let m = (seconds % 3600) / 60; + let s = seconds % 60; + if d > 0 { + format!("{d}d {h}h") + } else if h > 0 { + format!("{h}h {m}m") + } else if m > 0 { + format!("{m}m {s}s") + } else { + format!("{s}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tora_proto::TorrentStatus; + + fn disable_color() { + colored::control::set_override(false); + } + + fn sample_torrent() -> TorrentStatus { + TorrentStatus { + id: "a3f1b2c4-1234-5678-9abc-def012345678".into(), + info_hash: "8c4e1234abcd5678ef901234567890abcdef1234".into(), + name: "ubuntu-24.04.iso".into(), + source: "magnet:?xt=urn:btih:8c4e1234abcd".into(), + output_path: "/home/user/Downloads".into(), + total_bytes: 8_589_934_592, + downloaded_bytes: 4_294_967_296, + state: State::Downloading as i32, + error_message: String::new(), + download_speed_bps: 2_621_440, + eta_seconds: 1992, + uploaded_bytes: 104_857_600, + upload_speed_bps: 524_288, + peers: 42, + seeds: 8, + } + } + + fn finished_torrent() -> TorrentStatus { + TorrentStatus { + id: "b7c2d3e5-aaaa-bbbb-cccc-dddddddddddd".into(), + info_hash: "1234567890abcdef1234567890abcdef12345678".into(), + name: "debian-12.iso".into(), + source: "magnet:?xt=urn:btih:1234567890ab".into(), + output_path: "/home/user/Downloads".into(), + total_bytes: 4_000_000_000, + downloaded_bytes: 4_000_000_000, + state: State::Finished as i32, + error_message: String::new(), + download_speed_bps: 0, + eta_seconds: 0, + uploaded_bytes: 500_000_000, + upload_speed_bps: 1_048_576, + peers: 15, + seeds: 0, + } + } + + fn pending_torrent() -> TorrentStatus { + TorrentStatus { + id: "c9d4e5f6-1111-2222-3333-444444444444".into(), + info_hash: "abcdef1234567890abcdef1234567890abcdef12".into(), + name: String::new(), + source: "magnet:?xt=urn:btih:abcdef123456".into(), + output_path: "/tmp/downloads".into(), + total_bytes: 0, + downloaded_bytes: 0, + state: State::Pending as i32, + error_message: String::new(), + download_speed_bps: 0, + eta_seconds: 0, + uploaded_bytes: 0, + upload_speed_bps: 0, + peers: 0, + seeds: 0, + } + } + + fn error_torrent() -> TorrentStatus { + TorrentStatus { + id: "d0e5f6a7-3333-4444-5555-666666666666".into(), + info_hash: "9999999988887777666655544443332211100ffd".into(), + name: "broken-download.zip".into(), + source: "magnet:?xt=urn:btih:99999999".into(), + output_path: "/home/user/Downloads".into(), + total_bytes: 0, + downloaded_bytes: 0, + state: State::Error as i32, + error_message: "connection timed out".into(), + download_speed_bps: 0, + eta_seconds: 0, + uploaded_bytes: 0, + upload_speed_bps: 0, + peers: 0, + seeds: 0, + } + } + + // ---- LongFormatter tests ---- + + #[test] + fn long_status_contains_name_and_state() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("ubuntu-24.04.iso")); + assert!(out.contains("downloading")); + } + + #[test] + fn long_status_contains_id_and_hash() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("a3f1b2c4-1234-5678-9abc-def012345678")); + assert!(out.contains("8c4e1234abcd5678ef901234567890abcdef1234")); + } + + #[test] + fn long_status_contains_progress_bar_and_percentage() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("50%")); + assert!(out.contains("4.00 GiB / 8.00 GiB")); + assert!(out.contains("█")); + assert!(out.contains("░")); + } + + #[test] + fn long_status_contains_output_path() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("/home/user/Downloads")); + } + + #[test] + fn long_status_shows_error_message() { + disable_color(); + let f = LongFormatter; + let out = f.status(&error_torrent()).unwrap(); + assert!(out.contains("connection timed out")); + } + + #[test] + fn long_status_unknown_name_for_empty() { + disable_color(); + let f = LongFormatter; + let out = f.status(&pending_torrent()).unwrap(); + assert!(out.contains("(unknown)")); + } + + #[test] + fn long_status_zero_progress_shows_question_mark() { + disable_color(); + let f = LongFormatter; + let out = f.status(&pending_torrent()).unwrap(); + assert!(out.contains("0%")); + assert!(out.contains("0 B / ?")); + } + + #[test] + fn long_list_empty_shows_no_torrents() { + disable_color(); + let f = LongFormatter; + let out = f.list(&[]).unwrap(); + assert!(out.contains("no torrents")); + } + + #[test] + fn long_list_single_shows_header_and_count() { + disable_color(); + let f = LongFormatter; + let out = f.list(&[sample_torrent()]).unwrap(); + assert!(out.contains("ID")); + assert!(out.contains("NAME")); + assert!(out.contains("STATE")); + assert!(out.contains("PROGRESS")); + assert!(out.contains("1 torrent")); + } + + #[test] + fn long_list_multiple_shows_plural_count() { + disable_color(); + let f = LongFormatter; + let out = f + .list(&[sample_torrent(), finished_torrent(), pending_torrent()]) + .unwrap(); + assert!(out.contains("3 torrents")); + } + + #[test] + fn long_list_truncates_long_names() { + disable_color(); + let f = LongFormatter; + let t = TorrentStatus { + name: "a".repeat(50), + ..sample_torrent() + }; + let out = f.list(&[t]).unwrap(); + assert!(out.contains("...")); + assert!(!out.contains(&"a".repeat(50))); + } + + #[test] + fn long_list_shows_short_id() { + disable_color(); + let f = LongFormatter; + let out = f.list(&[sample_torrent()]).unwrap(); + assert!(out.contains("a3f1b2c4")); + assert!(!out.contains("a3f1b2c4-1234")); + } + + #[test] + fn long_health_healthy() { + disable_color(); + let f = LongFormatter; + let out = f.health(true, "SERVING").unwrap(); + assert!(out.contains("torad is running")); + } + + #[test] + fn long_health_unhealthy() { + disable_color(); + let f = LongFormatter; + let out = f.health(false, "NOT_SERVING").unwrap(); + assert!(out.contains("not running")); + assert!(out.contains("NOT_SERVING")); + } + + #[test] + fn long_download_contains_id() { + disable_color(); + let f = LongFormatter; + let out = f.download("a3f1b2c4-1234-5678-9abc-def012345678").unwrap(); + assert!(out.contains("a3f1b2c4-1234-5678-9abc-def012345678")); + assert!(out.contains("added")); + } + + #[test] + fn long_remove_no_files() { + disable_color(); + let f = LongFormatter; + let out = f.remove(false).unwrap(); + assert!(out.contains("removed")); + assert!(!out.contains("files deleted")); + } + + #[test] + fn long_remove_with_files() { + disable_color(); + let f = LongFormatter; + let out = f.remove(true).unwrap(); + assert!(out.contains("removed")); + assert!(out.contains("files deleted")); + } + + #[test] + fn long_pause_output() { + disable_color(); + let f = LongFormatter; + let out = f.pause().unwrap(); + assert!(out.contains("paused")); + } + + #[test] + fn long_resume_output() { + disable_color(); + let f = LongFormatter; + let out = f.resume().unwrap(); + assert!(out.contains("resumed")); + } + + #[test] + fn long_start_output() { + disable_color(); + let f = LongFormatter; + let out = f.start().unwrap(); + assert!(out.contains("started")); + } + + #[test] + fn long_stop_output() { + disable_color(); + let f = LongFormatter; + let out = f.stop().unwrap(); + assert!(out.contains("stopped")); + } + + #[test] + fn long_status_shows_peers_and_seeds() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("42 peers")); + assert!(out.contains("8 seeds")); + } + + #[test] + fn long_status_shows_upload_speed() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("upload")); + assert!(out.contains("512 KiB/s")); + } + + #[test] + fn long_status_shows_uploaded_bytes() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("uploaded")); + assert!(out.contains("100 MiB")); + } + + #[test] + fn long_list_shows_peers_for_downloading() { + disable_color(); + let f = LongFormatter; + let out = f.list(&[sample_torrent()]).unwrap(); + assert!(out.contains("peers")); + assert!(out.contains("42")); + } + + #[test] + fn json_pause_output() { + let f = JsonFormatter; + let out = f.pause().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["paused"], true); + } + + #[test] + fn json_resume_output() { + let f = JsonFormatter; + let out = f.resume().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["resumed"], true); + } + + #[test] + fn json_start_output() { + let f = JsonFormatter; + let out = f.start().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["started"], true); + } + + #[test] + fn json_stop_output() { + let f = JsonFormatter; + let out = f.stop().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["stopped"], true); + } + + // ---- JsonFormatter tests ---- + + #[test] + fn json_status_is_valid_json_with_fields() { + let f = JsonFormatter; + let out = f.status(&sample_torrent()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["id"], "a3f1b2c4-1234-5678-9abc-def012345678"); + assert_eq!(parsed["name"], "ubuntu-24.04.iso"); + assert_eq!(parsed["state"], "DOWNLOADING"); + assert_eq!( + parsed["infoHash"], + "8c4e1234abcd5678ef901234567890abcdef1234" + ); + } + + #[test] + fn json_list_empty_is_empty_array() { + let f = JsonFormatter; + let out = f.list(&[]).unwrap(); + assert_eq!(out.trim(), "[]"); + } + + #[test] + fn json_list_multiple_is_array() { + let f = JsonFormatter; + let out = f.list(&[sample_torrent(), finished_torrent()]).unwrap(); + let parsed: Vec = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0]["name"], "ubuntu-24.04.iso"); + assert_eq!(parsed[1]["state"], "FINISHED"); + } + + #[test] + fn json_health_output() { + let f = JsonFormatter; + let out = f.health(true, "SERVING").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["healthy"], true); + assert_eq!(parsed["status"], "SERVING"); + } + + #[test] + fn json_download_output() { + let f = JsonFormatter; + let out = f.download("abc-123").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["id"], "abc-123"); + } + + #[test] + fn json_remove_output() { + let f = JsonFormatter; + let out = f.remove(false).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["removed"], true); + } + + // ---- Helper function tests ---- + + #[test] + fn humanize_bytes_various_sizes() { + assert_eq!(humanize_bytes(0), "0 B"); + assert_eq!(humanize_bytes(512), "512 B"); + assert_eq!(humanize_bytes(1023), "1023 B"); + assert_eq!(humanize_bytes(1024), "1.00 KiB"); + assert_eq!(humanize_bytes(1_048_576), "1.00 MiB"); + assert_eq!(humanize_bytes(1_073_741_824), "1.00 GiB"); + assert_eq!(humanize_bytes(1_099_511_627_776), "1.00 TiB"); + } + + #[test] + fn humanize_bytes_precision() { + assert_eq!(humanize_bytes(1_500), "1.46 KiB"); + assert_eq!(humanize_bytes(15_000_000), "14.3 MiB"); + assert_eq!(humanize_bytes(150_000_000_000), "140 GiB"); + } + + #[test] + fn percentage_normal() { + assert_eq!(percentage(500, 1000), 50); + assert_eq!(percentage(0, 1000), 0); + assert_eq!(percentage(1000, 1000), 100); + assert_eq!(percentage(333, 1000), 33); + } + + #[test] + fn percentage_zero_total() { + assert_eq!(percentage(100, 0), 0); + assert_eq!(percentage(0, 0), 0); + } + + #[test] + fn progress_bar_zero() { + let bar = progress_bar(0, 1000); + assert!(bar.contains("░")); + assert!(!bar.contains("█")); + assert!(bar.contains(" 0%")); + } + + #[test] + fn progress_bar_full() { + let bar = progress_bar(1000, 1000); + assert!(bar.contains("█")); + assert!(!bar.contains("░")); + assert!(bar.contains("100%")); + } + + #[test] + fn progress_bar_half() { + let bar = progress_bar(500, 1000); + assert!(bar.contains("█")); + assert!(bar.contains("░")); + assert!(bar.contains(" 50%")); + } + + #[test] + fn progress_bar_zero_total_is_empty() { + let bar = progress_bar(0, 0); + assert!(!bar.contains("█")); + assert!(bar.contains("░")); + } + + #[test] + fn pad_short_string() { + assert_eq!(pad("ab", 5), "ab "); + assert_eq!(pad("", 3), " "); + } + + #[test] + fn pad_exact_length() { + assert_eq!(pad("abc", 3), "abc"); + } + + #[test] + fn pad_longer_string_unchanged() { + assert_eq!(pad("abcdef", 3), "abcdef"); + } + + #[test] + fn pad_unicode() { + assert_eq!(pad("é", 4), "é "); + } + + #[test] + fn short_id_from_uuid() { + assert_eq!(short_id("a3f1b2c4-1234-5678-9abc-def012345678"), "a3f1b2c4",); + } + + #[test] + fn short_id_non_uuid() { + assert_eq!(short_id("hello"), "hello"); + } + + #[test] + fn display_name_normal() { + assert_eq!(display_name("ubuntu.iso"), "ubuntu.iso"); + } + + #[test] + fn display_name_empty() { + assert_eq!(display_name(""), "(unknown)"); + } + + #[test] + fn display_name_truncates_long() { + let long = "x".repeat(50); + let result = display_name(&long); + assert!(result.ends_with("...")); + assert_eq!(result.chars().count(), 40); + } + + #[test] + fn display_name_boundary_not_truncated() { + let exactly_40 = "x".repeat(40); + assert_eq!(display_name(&exactly_40), exactly_40); + } + + #[test] + fn format_progress_with_total() { + assert_eq!(format_progress(500, 1000), "500 B / 1000 B"); + } + + #[test] + fn format_progress_zero_total() { + assert_eq!(format_progress(100, 0), "100 B / ?"); + } + + #[test] + fn humanize_speed_various() { + assert_eq!(humanize_speed(0), "—"); + assert_eq!(humanize_speed(512), "512 B/s"); + assert_eq!(humanize_speed(1_048_576), "1.00 MiB/s"); + assert_eq!(humanize_speed(2_621_440), "2.50 MiB/s"); + assert_eq!(humanize_speed(1_073_741_824), "1.00 GiB/s"); + } + + #[test] + fn humanize_eta_various() { + assert_eq!(humanize_eta(0), "—"); + assert_eq!(humanize_eta(45), "45s"); + assert_eq!(humanize_eta(1992), "33m 12s"); + assert_eq!(humanize_eta(3600), "1h 0m"); + assert_eq!(humanize_eta(9000), "2h 30m"); + assert_eq!(humanize_eta(90000), "1d 1h"); + } + + #[test] + fn long_status_shows_speed_and_eta() { + disable_color(); + let f = LongFormatter; + let out = f.status(&sample_torrent()).unwrap(); + assert!(out.contains("2.50 MiB/s")); + assert!(out.contains("33m 12s")); + } + + #[test] + fn long_status_hides_speed_when_zero() { + disable_color(); + let f = LongFormatter; + let out = f.status(&finished_torrent()).unwrap(); + assert!(!out.contains("speed")); + assert!(!out.contains("eta")); + } + + #[test] + fn long_list_shows_speed_for_downloading() { + disable_color(); + let f = LongFormatter; + let out = f.list(&[sample_torrent()]).unwrap(); + assert!(out.contains("2.50 MiB/s")); + assert!(out.contains("ETA")); + assert!(out.contains("33m")); + } + + #[test] + fn long_list_hides_speed_for_finished() { + disable_color(); + let f = LongFormatter; + let out = f.list(&[finished_torrent()]).unwrap(); + assert!(!out.contains("MiB/s")); + } + + // ---- from_format factory test ---- + + #[test] + fn from_format_returns_long() { + disable_color(); + let f = from_format(OutputFormat::Long); + let out = f.health(true, "SERVING").unwrap(); + assert!(out.contains("torad is running")); + } + + #[test] + fn from_format_returns_json() { + let f = from_format(OutputFormat::Json); + let out = f.health(true, "SERVING").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["healthy"], true); + } +} diff --git a/crates/torad/Cargo.toml b/crates/torad/Cargo.toml index 95fe10a..6837f82 100644 --- a/crates/torad/Cargo.toml +++ b/crates/torad/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" anyhow = "1.0.103" clap = { version = "4.6.1", features = ["derive", "env"] } librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "http-api-client"] } +librqbit-core = { version = "5.0.0", default-features = false } sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls", "uuid"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "signal"] } tokio-stream = { version = "0.1.18", features = ["net"] } diff --git a/crates/torad/src/db.rs b/crates/torad/src/db.rs index bce9158..166f86c 100644 --- a/crates/torad/src/db.rs +++ b/crates/torad/src/db.rs @@ -38,7 +38,6 @@ pub struct TorrentRow { pub error_message: Option, } -/// Insert a new pending torrent, or return the existing row if the info_hash is already tracked. pub async fn insert_pending( pool: &PgPool, info_hash: &str, @@ -80,6 +79,17 @@ pub async fn list(pool: &PgPool) -> Result> { Ok(rows) } +pub async fn delete(pool: &PgPool, id: Uuid) -> Result> { + let row = sqlx::query_as::<_, TorrentRow>( + "DELETE FROM torrents WHERE id = $1 + RETURNING id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message", + ) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row) +} + pub async fn list_pending(pool: &PgPool) -> Result> { let rows = sqlx::query_as::<_, TorrentRow>( "SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message @@ -90,6 +100,24 @@ pub async fn list_pending(pool: &PgPool) -> Result> { Ok(rows) } +pub async fn find_ids_by_prefix(pool: &PgPool, prefix: &str) -> Result> { + let rows = + sqlx::query_scalar::<_, Uuid>("SELECT id FROM torrents WHERE id::text LIKE $1 || '%'") + .bind(prefix) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn set_state(pool: &PgPool, id: Uuid, state: TorrentState) -> Result<()> { + sqlx::query("UPDATE torrents SET state = $2, updated_at = now() WHERE id = $1") + .bind(id) + .bind(state) + .execute(pool) + .await?; + Ok(()) +} + pub struct Progress<'a> { pub name: Option<&'a str>, pub total_bytes: i64, diff --git a/crates/torad/src/magnet.rs b/crates/torad/src/magnet.rs index 2df9faa..d2fe171 100644 --- a/crates/torad/src/magnet.rs +++ b/crates/torad/src/magnet.rs @@ -1,11 +1,17 @@ use anyhow::{Context, Result}; use librqbit::Magnet; -/// Extract the BTIH info hash from a magnet link, normalized to lowercase hex. -pub fn info_hash(magnet: &str) -> Result { - let parsed = Magnet::parse(magnet).context("failed to parse magnet link")?; - let id20 = parsed - .as_id20() - .context("magnet link has no v1 (BTIH) info hash")?; - Ok(id20.as_string()) +pub fn info_hash(source: &str) -> Result { + if source.starts_with("magnet:") { + let parsed = Magnet::parse(source).context("failed to parse magnet link")?; + let id20 = parsed + .as_id20() + .context("magnet link has no v1 (BTIH) info hash")?; + return Ok(id20.as_string()); + } + let bytes = + std::fs::read(source).with_context(|| format!("failed to read torrent file: {source}"))?; + let parsed = librqbit::torrent_from_bytes_ext::>(&bytes) + .context("failed to parse .torrent file")?; + Ok(parsed.meta.info_hash.as_string()) } diff --git a/crates/torad/src/torrents.rs b/crates/torad/src/torrents.rs index c0a3375..f911c83 100644 --- a/crates/torad/src/torrents.rs +++ b/crates/torad/src/torrents.rs @@ -4,12 +4,15 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; +use librqbit::api::TorrentIdOrHash; use librqbit::{AddTorrent, AddTorrentOptions, ManagedTorrent, Session, TorrentStatsState}; +use librqbit_core::Id20; use sqlx::PgPool; use tokio::sync::Mutex; use tonic::{Request, Response, Status}; use tora_proto::{ - AddRequest, AddResponse, ListRequest, ListResponse, State as ProtoState, StatusRequest, + AddRequest, AddResponse, ListRequest, ListResponse, PauseRequest, PauseResponse, RemoveRequest, + RemoveResponse, ResumeRequest, ResumeResponse, State as ProtoState, StatusRequest, TorrentStatus, Torrents, }; use tracing::{error, info, warn}; @@ -19,6 +22,13 @@ use crate::db::{self, TorrentRow, TorrentState}; use crate::magnet; const POLL_INTERVAL: Duration = Duration::from_secs(2); +const BYTES_PER_MIB: f64 = 1_048_576.0; + +pub enum ResolveError { + NotFound, + Ambiguous(usize), + Internal(anyhow::Error), +} pub struct TorrentManager { pool: PgPool, @@ -55,12 +65,100 @@ impl TorrentManager { db::insert_pending(&self.pool, &info_hash, magnet, &output_path).await } - pub async fn get(&self, id: Uuid) -> Result> { - db::get(&self.pool, id).await + pub async fn get_status(&self, id: Uuid) -> Result> { + let Some(row) = db::get(&self.pool, id).await? else { + return Ok(None); + }; + let tracked = self.tracked.lock().await; + let live = extract_live_stats(&tracked, &row.id); + Ok(Some(row_to_status(row, live))) } - pub async fn list(&self) -> Result> { - db::list(&self.pool).await + pub async fn list_statuses(&self) -> Result> { + let rows = db::list(&self.pool).await?; + let tracked = self.tracked.lock().await; + Ok(rows + .into_iter() + .map(|row| { + let live = extract_live_stats(&tracked, &row.id); + row_to_status(row, live) + }) + .collect()) + } + + pub async fn resolve_id(&self, id_or_prefix: &str) -> Result { + if let Ok(uuid) = Uuid::parse_str(id_or_prefix) { + return Ok(uuid); + } + let matches = db::find_ids_by_prefix(&self.pool, id_or_prefix) + .await + .map_err(ResolveError::Internal)?; + match matches.len() { + 0 => Err(ResolveError::NotFound), + 1 => Ok(matches[0]), + _ => Err(ResolveError::Ambiguous(matches.len())), + } + } + + pub async fn remove(&self, id: Uuid, delete_files: bool) -> Result> { + let row = db::get(&self.pool, id).await?; + if let Some(ref row) = row { + let mut tracked = self.tracked.lock().await; + tracked.remove(&id); + drop(tracked); + + if let Ok(info_hash) = row.info_hash.parse::() + && let Err(err) = self + .session + .delete(TorrentIdOrHash::Hash(info_hash), delete_files) + .await + { + warn!(id = %id, error = %err, "failed to delete torrent from session"); + } + + if delete_files { + let _ = std::fs::remove_dir_all(&row.output_path); + } + } + db::delete(&self.pool, id).await + } + + pub async fn pause(&self, id: Uuid) -> Result> { + let row = db::get(&self.pool, id).await?; + if row.is_none() { + return Ok(None); + } + let tracked = self.tracked.lock().await; + if let Some(handle) = tracked.get(&id) { + self.session + .pause(handle) + .await + .context("failed to pause torrent in librqbit session")?; + } else { + // Torrent not yet tracked by librqbit (still pending in DB). + // Set state to Paused so the poller won't pick it up. + db::set_state(&self.pool, id, TorrentState::Paused).await?; + } + Ok(row) + } + + pub async fn resume(&self, id: Uuid) -> Result> { + let row = db::get(&self.pool, id).await?; + if row.is_none() { + return Ok(None); + } + let tracked = self.tracked.lock().await; + if let Some(handle) = tracked.get(&id) { + self.session + .unpause(handle) + .await + .context("failed to resume torrent in librqbit session")?; + } else { + // Torrent not tracked by librqbit. Set state to Pending so + // the poller picks it up again. + db::set_state(&self.pool, id, TorrentState::Pending).await?; + } + Ok(row) } pub fn spawn_poller(self: Arc) { @@ -96,11 +194,14 @@ impl TorrentManager { overwrite: true, ..Default::default() }; - match self - .session - .add_torrent(AddTorrent::from_url(row.source.clone()), Some(options)) - .await - { + let add = match AddTorrent::from_cli_argument(&row.source) { + Ok(add) => add, + Err(err) => { + error!(id = %row.id, source = %row.source, error = %err, "failed to parse torrent source"); + continue; + } + }; + match self.session.add_torrent(add, Some(options)).await { Ok(response) => match response.into_handle() { Some(handle) => { info!(id = %row.id, info_hash = %row.info_hash, "torrent added to session"); @@ -155,6 +256,59 @@ impl TorrentManager { } } +#[derive(Default)] +struct LiveTorrentStats { + download_speed_bps: u64, + eta_seconds: u64, + uploaded_bytes: u64, + upload_speed_bps: u64, + peers: u32, + seeds: u32, +} + +fn extract_live_stats(tracked: &HashMap>, id: &Uuid) -> LiveTorrentStats { + let Some(handle) = tracked.get(id) else { + return LiveTorrentStats::default(); + }; + let stats = handle.stats(); + let uploaded_bytes = stats.uploaded_bytes; + + let Some(live) = stats.live else { + return LiveTorrentStats { + uploaded_bytes, + ..Default::default() + }; + }; + + let download_bps = if live.download_speed.mbps > 0.0 { + (live.download_speed.mbps * BYTES_PER_MIB) as u64 + } else { + 0 + }; + let upload_bps = if live.upload_speed.mbps > 0.0 { + (live.upload_speed.mbps * BYTES_PER_MIB) as u64 + } else { + 0 + }; + let total = stats.total_bytes; + let downloaded = stats.progress_bytes; + let remaining = total.saturating_sub(downloaded); + let eta = if download_bps > 0 { + (remaining as f64 / download_bps as f64) as u64 + } else { + 0 + }; + + LiveTorrentStats { + download_speed_bps: download_bps, + eta_seconds: eta, + uploaded_bytes, + upload_speed_bps: upload_bps, + peers: live.snapshot.peer_stats.live as u32, + seeds: live.snapshot.peer_stats.not_needed as u32, + } +} + fn proto_state(state: TorrentState) -> ProtoState { match state { TorrentState::Pending => ProtoState::Pending, @@ -165,7 +319,7 @@ fn proto_state(state: TorrentState) -> ProtoState { } } -fn row_to_status(row: TorrentRow) -> TorrentStatus { +fn row_to_status(row: TorrentRow, live: LiveTorrentStats) -> TorrentStatus { TorrentStatus { id: row.id.to_string(), info_hash: row.info_hash, @@ -176,6 +330,12 @@ fn row_to_status(row: TorrentRow) -> TorrentStatus { downloaded_bytes: row.downloaded_bytes as u64, state: proto_state(row.state) as i32, error_message: row.error_message.unwrap_or_default(), + download_speed_bps: live.download_speed_bps, + eta_seconds: live.eta_seconds, + uploaded_bytes: live.uploaded_bytes, + upload_speed_bps: live.upload_speed_bps, + peers: live.peers, + seeds: live.seeds, } } @@ -191,6 +351,16 @@ impl GrpcTorrents { } } +fn resolve_err(e: ResolveError) -> Status { + match e { + ResolveError::NotFound => Status::not_found("torrent not found"), + ResolveError::Ambiguous(n) => { + Status::invalid_argument(format!("ambiguous prefix, matches {n} torrents")) + } + ResolveError::Internal(err) => Status::internal(err.to_string()), + } +} + #[tonic::async_trait] impl Torrents for GrpcTorrents { async fn add(&self, request: Request) -> Result, Status> { @@ -210,25 +380,84 @@ impl Torrents for GrpcTorrents { &self, request: Request, ) -> Result, Status> { - let id = Uuid::parse_str(&request.into_inner().id) - .map_err(|_| Status::invalid_argument("invalid id"))?; - let row = self + let id = self .manager - .get(id) + .resolve_id(&request.into_inner().id) + .await + .map_err(resolve_err)?; + let status = self + .manager + .get_status(id) .await .map_err(|err| Status::internal(err.to_string()))? .ok_or_else(|| Status::not_found("torrent not found"))?; - Ok(Response::new(row_to_status(row))) + Ok(Response::new(status)) } async fn list(&self, _request: Request) -> Result, Status> { - let rows = self + let torrents = self .manager - .list() + .list_statuses() .await .map_err(|err| Status::internal(err.to_string()))?; - Ok(Response::new(ListResponse { - torrents: rows.into_iter().map(row_to_status).collect(), - })) + Ok(Response::new(ListResponse { torrents })) + } + + async fn remove( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let id = self + .manager + .resolve_id(&req.id) + .await + .map_err(resolve_err)?; + let row = self + .manager + .remove(id, req.delete_files) + .await + .map_err(|err| Status::internal(err.to_string()))? + .ok_or_else(|| Status::not_found("torrent not found"))?; + info!(id = %id, name = ?row.name, "torrent removed"); + Ok(Response::new(RemoveResponse {})) + } + + async fn pause( + &self, + request: Request, + ) -> Result, Status> { + let id = self + .manager + .resolve_id(&request.into_inner().id) + .await + .map_err(resolve_err)?; + let row = self + .manager + .pause(id) + .await + .map_err(|err| Status::internal(err.to_string()))? + .ok_or_else(|| Status::not_found("torrent not found"))?; + info!(id = %id, name = ?row.name, "torrent paused"); + Ok(Response::new(PauseResponse {})) + } + + async fn resume( + &self, + request: Request, + ) -> Result, Status> { + let id = self + .manager + .resolve_id(&request.into_inner().id) + .await + .map_err(resolve_err)?; + let row = self + .manager + .resume(id) + .await + .map_err(|err| Status::internal(err.to_string()))? + .ok_or_else(|| Status::not_found("torrent not found"))?; + info!(id = %id, name = ?row.name, "torrent resumed"); + Ok(Response::new(ResumeResponse {})) } } diff --git a/justfile b/justfile new file mode 100644 index 0000000..e10937f --- /dev/null +++ b/justfile @@ -0,0 +1,2 @@ +proto: + buf generate diff --git a/proto/torrent.proto b/proto/torrent.proto index 92f156c..e865975 100644 --- a/proto/torrent.proto +++ b/proto/torrent.proto @@ -3,12 +3,18 @@ syntax = "proto3"; package torrent; service Torrents { - // Add a torrent from a magnet link and start downloading it. + // Add a torrent from a magnet link or .torrent file and start downloading it. rpc Add(AddRequest) returns (AddResponse); // Get the current status of a single torrent. rpc Status(StatusRequest) returns (TorrentStatus); // List all torrents known to the daemon. rpc List(ListRequest) returns (ListResponse); + // Remove a torrent from tracking. + rpc Remove(RemoveRequest) returns (RemoveResponse); + // Pause a downloading torrent. + rpc Pause(PauseRequest) returns (PauseResponse); + // Resume a paused torrent. + rpc Resume(ResumeRequest) returns (ResumeResponse); } message AddRequest { @@ -31,6 +37,25 @@ message ListResponse { repeated TorrentStatus torrents = 1; } +message RemoveRequest { + string id = 1; + bool delete_files = 2; +} + +message RemoveResponse {} + +message PauseRequest { + string id = 1; +} + +message PauseResponse {} + +message ResumeRequest { + string id = 1; +} + +message ResumeResponse {} + enum State { STATE_UNSPECIFIED = 0; PENDING = 1; @@ -50,4 +75,10 @@ message TorrentStatus { uint64 downloaded_bytes = 7; State state = 8; string error_message = 9; + uint64 download_speed_bps = 10; + uint64 eta_seconds = 11; + uint64 uploaded_bytes = 12; + uint64 upload_speed_bps = 13; + uint32 peers = 14; + uint32 seeds = 15; }