Download torrent when monitor album

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