Add search for torrents

This commit is contained in:
Alexander
2026-07-12 10:36:14 +02:00
parent 38bee269db
commit 18f2fa9475
26 changed files with 3588 additions and 716 deletions
+3
View File
@@ -4,3 +4,6 @@
[submodule "tora"]
path = tora
url = /home/fujin/Code/agregators/tora
[submodule "metadata-agregator"]
path = metadata-agregator
url = ssh://git@gitea.susano-homelab.duckdns.org:2222/fujin/metadata-agregator.git
Generated
+1126 -6
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -25,5 +25,7 @@ tokio = { version = "1.52.3", features = [ "macros", "rt-multi-thread" ] }
tonic-reflection = "0.14.6"
tonic-health = "0.14.6"
toml = "1.1.2"
reqwest = { version = "0.13.4", default-features = false, features = ["query", "rustls"] }
quick-xml = { version = "0.41.0", features = ["serialize"] }
[workspace]
+3 -3
View File
@@ -1,9 +1,9 @@
version: v2
inputs:
- directory: proto
- directory: ../metadata-agregator/proto
- directory: ../musicfs/proto
- directory: ../tora/proto
- directory: ./metadata-agregator/proto
- directory: ./musicfs/proto
- directory: ./tora/proto
plugins:
- local: protoc-gen-prost
#path: ../../target/debug/protoc-gen-prost
+37
View File
@@ -0,0 +1,37 @@
{
pkgs,
lib,
config,
...
}:
{
# Rootless container runtime. No daemon needed, unlike Docker.
packages = [ pkgs.podman ];
# Jackett indexer, running rootless via podman so it joins `devenv up`
# alongside postgres/torad/musicfs. Foreground (no `-d`) so process-compose
# supervises it and streams logs; `--rm` cleans up on `devenv up` shutdown.
# UI: http://localhost:9117
#
# Note: podman (unlike docker) does NOT auto-create bind-mount source dirs,
# so we `mkdir -p` them first or podman fails with "statfs: no such file".
processes.jackett.exec =
let
jackettConfig = "${config.env.DEVENV_RUNTIME}/jackett-config";
jackettDownloads = "${config.env.DEVENV_RUNTIME}/jackett-downloads";
in
''
mkdir -p "${jackettConfig}" "${jackettDownloads}"
${pkgs.podman}/bin/podman run --rm \
--name devenv-jackett \
-p 9117:9117 \
-e PUID=1000 \
-e PGID=100 \
-e TZ=Europe/Warsaw \
-e AUTO_UPDATE=true \
-v "${jackettConfig}":/config \
-v "${jackettDownloads}":/downloads \
lscr.io/linuxserver/jackett:latest
'';
}
+9
View File
@@ -0,0 +1,9 @@
# Secrets
.env*
# Dependencies
node_modules
# OS files
.DS_Store
Thumbs.db
+11
View File
@@ -0,0 +1,11 @@
info:
name: Health
type: grpc
seq: 1
grpc:
url: localhost:50051
method: /health.Health/Check
methodType: unary
message: "{}"
auth: inherit
+24
View File
@@ -0,0 +1,24 @@
info:
name: Search
type: http
seq: 2
http:
method: GET
url: http://localhost:9117/api/v2.0/indexers/all/results/torznab/api?apikey=bjivq1zp3bfq7eaorhsswkfhvpc5issh&t=search&q=ДДТ
params:
- name: apikey
value: bjivq1zp3bfq7eaorhsswkfhvpc5issh
type: query
- name: t
value: search
type: query
- name: q
value: ДДТ
type: query
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
+7
View File
@@ -0,0 +1,7 @@
info:
name: Jackett
type: folder
seq: 3
request:
auth: inherit
+14
View File
@@ -0,0 +1,14 @@
info:
name: Search
type: grpc
seq: 2
grpc:
url: localhost:50051
method: /torrent_manager.TorrentManager/Search
methodType: unary
message: |-
{
"name": "ДДТ"
}
auth: inherit
+7
View File
@@ -0,0 +1,7 @@
info:
name: Torrent
type: folder
seq: 2
request:
auth: inherit
+25
View File
@@ -0,0 +1,25 @@
opencollection: 1.0.0
info:
name: Music Agregator
config:
proxy:
inherit: true
config:
protocol: http
hostname: ""
port: ""
auth:
username: ""
password: ""
bypassProxy: ""
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
presets:
request:
type: grpc
url: localhost:50051
+2 -1
View File
@@ -5,8 +5,9 @@
inputs,
...
}:
{
imports = [ ./containers/jackett.nix ];
languages.rust.enable = true;
git-hooks.hooks = {
+3
View File
@@ -6,3 +6,6 @@ build: proto
run *FLAGS: proto
cargo run -- {{FLAGS}}
up:
devenv up
Submodule metadata-agregator added at 995397e495
+21
View File
@@ -0,0 +1,21 @@
syntax = "proto3";
package torrent_manager;
service TorrentManager {
rpc Search(SearchRequest) returns (SearchResponse);
}
message SearchRequest {
string name = 1;
}
message SearchResponse {
repeated SearchItem item = 1;
}
message SearchItem {
string name = 1;
string url = 2;
}
+5
View File
@@ -0,0 +1,5 @@
# Declares the edition for `rustfmt` when it parses buffers via stdin
# (e.g. emacs `rust-format-buffer` / DOOM `format-all`). rustfmt does not
# consult Cargo.toml for stdin input, so without this it defaults to 2015
# and rejects `async fn` with E0670. Must match edition in Cargo.toml.
edition = "2024"
+7
View File
@@ -3,6 +3,7 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
pub service: ServiceConfig,
pub indexer: IndexerConfig,
pub torrent: TorrentConfig,
}
@@ -11,6 +12,12 @@ pub struct ServiceConfig {
pub address: String,
}
#[derive(Debug, Deserialize)]
pub struct IndexerConfig {
pub address: String,
pub api: String,
}
#[derive(Debug, Deserialize)]
pub struct TorrentConfig {
pub address: String,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
// @generated
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SearchRequest {
#[prost(string, tag = "1")]
pub name: ::prost::alloc::string::String,
}
impl ::prost::Name for SearchRequest {
const NAME: &'static str = "SearchRequest";
const PACKAGE: &'static str = "torrent_manager";
fn full_name() -> ::prost::alloc::string::String {
"torrent_manager.SearchRequest".into()
}
fn type_url() -> ::prost::alloc::string::String {
"/torrent_manager.SearchRequest".into()
}
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchResponse {
#[prost(message, repeated, tag = "1")]
pub item: ::prost::alloc::vec::Vec<SearchItem>,
}
impl ::prost::Name for SearchResponse {
const NAME: &'static str = "SearchResponse";
const PACKAGE: &'static str = "torrent_manager";
fn full_name() -> ::prost::alloc::string::String {
"torrent_manager.SearchResponse".into()
}
fn type_url() -> ::prost::alloc::string::String {
"/torrent_manager.SearchResponse".into()
}
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SearchItem {
#[prost(string, tag = "1")]
pub name: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub url: ::prost::alloc::string::String,
}
impl ::prost::Name for SearchItem {
const NAME: &'static str = "SearchItem";
const PACKAGE: &'static str = "torrent_manager";
fn full_name() -> ::prost::alloc::string::String {
"torrent_manager.SearchItem".into()
}
fn type_url() -> ::prost::alloc::string::String {
"/torrent_manager.SearchItem".into()
}
}
/// Encoded file descriptor set for the `torrent_manager` package
pub const FILE_DESCRIPTOR_SET: &[u8] = &[
0x0a, 0xc0, 0x05, 0x0a, 0x0c, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x12, 0x0f, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x72, 0x22, 0x23, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x41, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63,
0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x04, 0x69, 0x74, 0x65,
0x6d, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x74, 0x6f, 0x72, 0x72, 0x65, 0x6e,
0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
0x49, 0x74, 0x65, 0x6d, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x32, 0x0a, 0x0a, 0x53, 0x65,
0x61, 0x72, 0x63, 0x68, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03,
0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x32, 0x5b,
0x0a, 0x0e, 0x54, 0x6f, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72,
0x12, 0x49, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1e, 0x2e, 0x74, 0x6f, 0x72,
0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x61,
0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x74, 0x6f, 0x72,
0x72, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x2e, 0x53, 0x65, 0x61,
0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4a, 0x9d, 0x03, 0x0a, 0x06,
0x12, 0x04, 0x00, 0x00, 0x14, 0x01, 0x0a, 0x08, 0x0a, 0x01, 0x0c, 0x12, 0x03, 0x00, 0x00, 0x12,
0x0a, 0x08, 0x0a, 0x01, 0x02, 0x12, 0x03, 0x02, 0x00, 0x18, 0x0a, 0x0a, 0x0a, 0x02, 0x06, 0x00,
0x12, 0x04, 0x04, 0x00, 0x06, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x06, 0x00, 0x01, 0x12, 0x03, 0x04,
0x08, 0x16, 0x0a, 0x0b, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x00, 0x12, 0x03, 0x05, 0x02, 0x35, 0x0a,
0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x05, 0x06, 0x0c, 0x0a, 0x0c, 0x0a,
0x05, 0x06, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03, 0x05, 0x0d, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x06,
0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x05, 0x25, 0x33, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x00, 0x12,
0x04, 0x09, 0x00, 0x0b, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x00, 0x01, 0x12, 0x03, 0x09, 0x08,
0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x05, 0x12, 0x03, 0x0a, 0x02, 0x08, 0x0a,
0x0b, 0x0a, 0x04, 0x04, 0x00, 0x02, 0x00, 0x12, 0x03, 0x0a, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x0a, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00,
0x02, 0x00, 0x03, 0x12, 0x03, 0x0a, 0x10, 0x11, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x01, 0x12, 0x04,
0x0d, 0x00, 0x0f, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x01, 0x01, 0x12, 0x03, 0x0d, 0x08, 0x16,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x04, 0x12, 0x03, 0x0e, 0x02, 0x0a, 0x0a, 0x0b,
0x0a, 0x04, 0x04, 0x01, 0x02, 0x00, 0x12, 0x03, 0x0e, 0x02, 0x1f, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x01, 0x02, 0x00, 0x06, 0x12, 0x03, 0x0e, 0x0b, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02,
0x00, 0x01, 0x12, 0x03, 0x0e, 0x16, 0x1a, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x03,
0x12, 0x03, 0x0e, 0x1d, 0x1e, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x02, 0x12, 0x04, 0x11, 0x00, 0x14,
0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x02, 0x01, 0x12, 0x03, 0x11, 0x08, 0x12, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x02, 0x02, 0x00, 0x05, 0x12, 0x03, 0x12, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04,
0x02, 0x02, 0x00, 0x12, 0x03, 0x12, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00,
0x01, 0x12, 0x03, 0x12, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x03, 0x12,
0x03, 0x12, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x01, 0x05, 0x12, 0x03, 0x13,
0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x01, 0x12, 0x03, 0x13, 0x02, 0x11, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x01, 0x01, 0x12, 0x03, 0x13, 0x09, 0x0c, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x02, 0x02, 0x01, 0x03, 0x12, 0x03, 0x13, 0x0f, 0x10, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
];
include!("torrent_manager.serde.rs");
include!("torrent_manager.tonic.rs");
// @@protoc_insertion_point(module)
@@ -0,0 +1,293 @@
// @generated
impl serde::Serialize for SearchItem {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut len = 0;
if !self.name.is_empty() {
len += 1;
}
if !self.url.is_empty() {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("torrent_manager.SearchItem", len)?;
if !self.name.is_empty() {
struct_ser.serialize_field("name", &self.name)?;
}
if !self.url.is_empty() {
struct_ser.serialize_field("url", &self.url)?;
}
struct_ser.end()
}
}
impl<'de> serde::Deserialize<'de> for SearchItem {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &["name", "url"];
#[allow(clippy::enum_variant_names)]
enum GeneratedField {
Name,
Url,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
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<E>(self, value: &str) -> std::result::Result<GeneratedField, E>
where
E: serde::de::Error,
{
match value {
"name" => Ok(GeneratedField::Name),
"url" => Ok(GeneratedField::Url),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(GeneratedVisitor)
}
}
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = SearchItem;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("struct torrent_manager.SearchItem")
}
fn visit_map<V>(self, mut map_: V) -> std::result::Result<SearchItem, V::Error>
where
V: serde::de::MapAccess<'de>,
{
let mut name__ = None;
let mut url__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::Name => {
if name__.is_some() {
return Err(serde::de::Error::duplicate_field("name"));
}
name__ = Some(map_.next_value()?);
}
GeneratedField::Url => {
if url__.is_some() {
return Err(serde::de::Error::duplicate_field("url"));
}
url__ = Some(map_.next_value()?);
}
}
}
Ok(SearchItem {
name: name__.unwrap_or_default(),
url: url__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("torrent_manager.SearchItem", FIELDS, GeneratedVisitor)
}
}
impl serde::Serialize for SearchRequest {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut len = 0;
if !self.name.is_empty() {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("torrent_manager.SearchRequest", len)?;
if !self.name.is_empty() {
struct_ser.serialize_field("name", &self.name)?;
}
struct_ser.end()
}
}
impl<'de> serde::Deserialize<'de> for SearchRequest {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &["name"];
#[allow(clippy::enum_variant_names)]
enum GeneratedField {
Name,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
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<E>(self, value: &str) -> std::result::Result<GeneratedField, E>
where
E: serde::de::Error,
{
match value {
"name" => Ok(GeneratedField::Name),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(GeneratedVisitor)
}
}
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = SearchRequest;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("struct torrent_manager.SearchRequest")
}
fn visit_map<V>(self, mut map_: V) -> std::result::Result<SearchRequest, V::Error>
where
V: serde::de::MapAccess<'de>,
{
let mut name__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::Name => {
if name__.is_some() {
return Err(serde::de::Error::duplicate_field("name"));
}
name__ = Some(map_.next_value()?);
}
}
}
Ok(SearchRequest {
name: name__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("torrent_manager.SearchRequest", FIELDS, GeneratedVisitor)
}
}
impl serde::Serialize for SearchResponse {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut len = 0;
if !self.item.is_empty() {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("torrent_manager.SearchResponse", len)?;
if !self.item.is_empty() {
struct_ser.serialize_field("item", &self.item)?;
}
struct_ser.end()
}
}
impl<'de> serde::Deserialize<'de> for SearchResponse {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &["item"];
#[allow(clippy::enum_variant_names)]
enum GeneratedField {
Item,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
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<E>(self, value: &str) -> std::result::Result<GeneratedField, E>
where
E: serde::de::Error,
{
match value {
"item" => Ok(GeneratedField::Item),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(GeneratedVisitor)
}
}
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = SearchResponse;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("struct torrent_manager.SearchResponse")
}
fn visit_map<V>(self, mut map_: V) -> std::result::Result<SearchResponse, V::Error>
where
V: serde::de::MapAccess<'de>,
{
let mut item__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::Item => {
if item__.is_some() {
return Err(serde::de::Error::duplicate_field("item"));
}
item__ = Some(map_.next_value()?);
}
}
}
Ok(SearchResponse {
item: item__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("torrent_manager.SearchResponse", FIELDS, GeneratedVisitor)
}
}
@@ -0,0 +1,271 @@
// @generated
/// Generated client implementations.
pub mod torrent_manager_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value
)]
use tonic::codegen::http::Uri;
use tonic::codegen::*;
#[derive(Debug, Clone)]
pub struct TorrentManagerClient<T> {
inner: tonic::client::Grpc<T>,
}
impl TorrentManagerClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> TorrentManagerClient<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> TorrentManagerClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<http::Request<tonic::body::Body>>>::Error:
Into<StdError> + std::marker::Send + std::marker::Sync,
{
TorrentManagerClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn search(
&mut self,
request: impl tonic::IntoRequest<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path =
http::uri::PathAndQuery::from_static("/torrent_manager.TorrentManager/Search");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("torrent_manager.TorrentManager", "Search"));
self.inner.unary(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod torrent_manager_server {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value
)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with TorrentManagerServer.
#[async_trait]
pub trait TorrentManager: std::marker::Send + std::marker::Sync + 'static {
async fn search(
&self,
request: tonic::Request<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status>;
}
#[derive(Debug)]
pub struct TorrentManagerServer<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> TorrentManagerServer<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(inner: T, interceptor: F) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for TorrentManagerServer<T>
where
T: TorrentManager,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/torrent_manager.TorrentManager/Search" => {
#[allow(non_camel_case_types)]
struct SearchSvc<T: TorrentManager>(pub Arc<T>);
impl<T: TorrentManager> tonic::server::UnaryService<super::SearchRequest> for SearchSvc<T> {
type Response = super::SearchResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::SearchRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as TorrentManager>::search(&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 = SearchSvc(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();
headers.insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
headers.insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
}),
}
}
}
impl<T> Clone for TorrentManagerServer<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "torrent_manager.TorrentManager";
impl<T> tonic::server::NamedService for TorrentManagerServer<T> {
const NAME: &'static str = SERVICE_NAME;
}
}
+189
View File
@@ -0,0 +1,189 @@
use serde::Deserialize;
use crate::config::IndexerConfig;
const TORZNAB_PATH: &str = "api/v2.0/indexers/all/results/torznab/api";
#[derive(Debug, Clone, PartialEq)]
pub struct SearchResult {
pub title: String,
pub link: String,
pub size: Option<u64>,
pub seeders: Option<u32>,
pub peers: Option<u32>,
}
#[derive(Debug)]
pub enum IndexerError {
Request(reqwest::Error),
Parse(quick_xml::de::DeError),
}
impl std::fmt::Display for IndexerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Request(err) => write!(f, "jackett request failed: {err}"),
Self::Parse(err) => write!(f, "jackett response could not be parsed: {err}"),
}
}
}
impl std::error::Error for IndexerError {}
impl From<reqwest::Error> for IndexerError {
fn from(err: reqwest::Error) -> Self {
Self::Request(err)
}
}
impl From<quick_xml::de::DeError> for IndexerError {
fn from(err: quick_xml::de::DeError) -> Self {
Self::Parse(err)
}
}
#[tonic::async_trait]
pub trait Indexer: Send + Sync {
async fn search(&self, query: &str) -> Result<Vec<SearchResult>, IndexerError>;
}
#[derive(Debug, Clone)]
pub struct Jackett {
http: reqwest::Client,
address: String,
api_key: String,
}
impl Jackett {
pub fn new(config: &IndexerConfig) -> Self {
Self {
http: reqwest::Client::new(),
address: config.address.trim_end_matches('/').to_string(),
api_key: config.api.clone(),
}
}
}
#[tonic::async_trait]
impl Indexer for Jackett {
async fn search(&self, query: &str) -> Result<Vec<SearchResult>, IndexerError> {
let url = format!("{}/{TORZNAB_PATH}", self.address);
let body = self
.http
.get(url)
.query(&[
("t", "search"),
("apikey", self.api_key.as_str()),
("q", query),
])
.send()
.await?
.error_for_status()?
.text()
.await?;
parse_results(&body)
}
}
fn parse_results(body: &str) -> Result<Vec<SearchResult>, IndexerError> {
let rss: Rss = quick_xml::de::from_str(body)?;
Ok(rss.channel.items.into_iter().map(Into::into).collect())
}
#[derive(Debug, Deserialize)]
struct Rss {
channel: Channel,
}
#[derive(Debug, Deserialize)]
struct Channel {
#[serde(rename = "item", default)]
items: Vec<Item>,
}
#[derive(Debug, Deserialize)]
struct Item {
title: String,
link: String,
#[serde(rename = "attr", default)]
attrs: Vec<Attr>,
}
#[derive(Debug, Deserialize)]
struct Attr {
#[serde(rename = "@name")]
name: String,
#[serde(rename = "@value")]
value: String,
}
impl Item {
fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|attr| attr.name == name)
.map(|attr| attr.value.as_str())
}
}
impl From<Item> for SearchResult {
fn from(item: Item) -> Self {
SearchResult {
size: item.attr("size").and_then(|v| v.parse().ok()),
seeders: item.attr("seeders").and_then(|v| v.parse().ok()),
peers: item.attr("peers").and_then(|v| v.parse().ok()),
title: item.title,
link: item.link,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_RESPONSE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<title>Jackett</title>
<item>
<title>Some.Album.2020.FLAC</title>
<guid>https://example.com/123</guid>
<link>https://example.com/download/123.torrent</link>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<torznab:attr name="category" value="3040"/>
<torznab:attr name="size" value="1073741824"/>
<torznab:attr name="seeders" value="42"/>
<torznab:attr name="peers" value="50"/>
</item>
</channel>
</rss>"#;
#[test]
fn parses_torznab_results() {
let results = parse_results(SAMPLE_RESPONSE).expect("valid torznab response");
assert_eq!(
results,
vec![SearchResult {
title: "Some.Album.2020.FLAC".to_string(),
link: "https://example.com/download/123.torrent".to_string(),
size: Some(1_073_741_824),
seeders: Some(42),
peers: Some(50),
}]
);
}
#[test]
fn parses_empty_channel() {
let results = parse_results(
r#"<rss xmlns:torznab="http://torznab.com/schemas/2015/feed"><channel></channel></rss>"#,
)
.expect("valid torznab response");
assert!(results.is_empty());
}
}
+15 -1
View File
@@ -1,7 +1,10 @@
mod config;
mod greeter;
mod health;
mod indexer;
mod torrent;
mod torrent_manager;
mod generated {
pub mod hello {
include!("generated/hello/hello.rs");
@@ -11,21 +14,28 @@ mod generated {
include!("generated/torrent/torrent.rs");
}
pub mod torrent_manager {
include!("generated/torrent_manager/torrent_manager.rs");
}
pub mod health {
include!("generated/health/health.rs");
}
}
use std::fs;
use std::{fs, sync::Arc};
use generated::{
health::health_server::HealthServer as AggregatorHealthServer,
hello::greeter_server::GreeterServer, torrent::torrents_server::TorrentsServer,
torrent_manager::torrent_manager_server::TorrentManagerServer,
};
use greeter::GreeterService;
use health::HealthService;
use indexer::Jackett;
use tonic_health::pb::health_server::HealthServer;
use torrent::TorrentsService;
use torrent_manager::TorrentMananagerService;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -37,11 +47,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.register_encoded_file_descriptor_set(generated::hello::FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(generated::torrent::FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(generated::health::FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(generated::torrent_manager::FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
.build_v1()?;
let torrents = TorrentsService::new(&config.torrent.address);
let health = HealthService::new(torrents.channel());
let indexer = Arc::new(Jackett::new(&config.indexer));
let torrent_manager = TorrentMananagerService::new(torrents.clone(), indexer);
tonic::transport::Server::builder()
.add_service(reflection)
@@ -49,6 +62,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.add_service(AggregatorHealthServer::new(health))
.add_service(GreeterServer::new(GreeterService::default()))
.add_service(TorrentsServer::new(torrents))
.add_service(TorrentManagerServer::new(torrent_manager))
.serve(addr)
.await?;
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::generated::torrent::{torrents_client::TorrentsClient, torrents_server::Torrents, *};
use tonic::transport::{Channel, Endpoint};
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct TorrentsService {
channel: Channel,
}
+53
View File
@@ -0,0 +1,53 @@
use std::sync::Arc;
use crate::{
generated::{
torrent::torrents_client,
torrent_manager::{
SearchItem, SearchRequest, SearchResponse, torrent_manager_server::TorrentManager,
},
},
indexer::{Indexer, SearchResult},
torrent::TorrentsService,
};
pub struct TorrentMananagerService {
torrents_service: TorrentsService,
indexer: Arc<dyn Indexer>,
}
impl TorrentMananagerService {
pub fn new(torrents_service: TorrentsService, indexer: Arc<dyn Indexer>) -> Self {
return Self {
torrents_service,
indexer,
};
}
}
#[tonic::async_trait]
impl TorrentManager for TorrentMananagerService {
async fn search(
&self,
request: tonic::Request<SearchRequest>,
) -> Result<tonic::Response<SearchResponse>, tonic::Status> {
let name: String = request.into_inner().name;
let search_items: Vec<SearchItem> = self
.indexer
.search(&name)
.await
.unwrap_or(Vec::new())
.iter()
.map(|item| {
return SearchItem {
name: item.title.clone(),
url: item.link.clone(),
};
})
.collect();
let response = SearchResponse { item: search_items };
return tonic::Result::Ok(tonic::Response::new(response));
}
}