Download torrent when monitor album
This commit is contained in:
Generated
+30
-6
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
info:
|
||||
name: List
|
||||
type: grpc
|
||||
seq: 1
|
||||
|
||||
grpc:
|
||||
url: localhost:50051
|
||||
method: /torrent.Torrents/List
|
||||
methodType: unary
|
||||
message: "{}"
|
||||
auth: inherit
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
+221
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
-4
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user