fix: fix qbit torrent test

This commit is contained in:
master 2025-01-01 07:10:18 +08:00
parent 393f704e52
commit 70932900cd
21 changed files with 625 additions and 210 deletions

5
.gitignore vendored
View File

@ -255,4 +255,7 @@ public/robots.txt
public/sitemap*.xml
# Custom
/data
/data
patches/*
!patches/.gitkeep

View File

@ -28,5 +28,6 @@
"emmet.showExpandedAbbreviation": "never",
"prettier.enable": false,
"tailwindCSS.experimental.configFile": "./packages/tailwind-config/config.ts",
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"rust-analyzer.cargo.features": ["testcontainers"]
}

271
Cargo.lock generated
View File

@ -559,9 +559,9 @@ dependencies = [
[[package]]
name = "bollard"
version = "0.17.1"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d41711ad46fda47cd701f6908e59d1bd6b9a2b7464c0d0aeab95c6d37096ff8a"
checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30"
dependencies = [
"base64 0.22.1",
"bollard-stubs",
@ -588,7 +588,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serde_urlencoded",
"thiserror 1.0.69",
"thiserror 2.0.9",
"tokio",
"tokio-util",
"tower-service",
@ -598,9 +598,9 @@ dependencies = [
[[package]]
name = "bollard-stubs"
version = "1.45.0-rc.26.0.1"
version = "1.47.1-rc.27.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d7c5415e3a6bc6d3e99eff6268e488fd4ee25e7b28c10f08fa6760bd9de16e4"
checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da"
dependencies = [
"serde",
"serde_repr",
@ -896,6 +896,21 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "conquer-once"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d008a441c0f269f36ca13712528069a86a3e60dffee1d98b976eb3b0b2160b4"
dependencies = [
"conquer-util",
]
[[package]]
name = "conquer-util"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e763eef8846b13b380f37dfecda401770b0ca4e56e95170237bd7c25c7db3582"
[[package]]
name = "console"
version = "0.15.10"
@ -965,6 +980,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@ -1371,30 +1396,6 @@ dependencies = [
"syn 2.0.92",
]
[[package]]
name = "dlsignal"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"chrono",
"eyre",
"futures",
"itertools 0.13.0",
"lazy_static",
"librqbit-core",
"qbit-rs",
"quirks_path",
"regex",
"reqwest",
"serde",
"testcontainers",
"testcontainers-modules",
"thiserror 2.0.9",
"tokio",
"url",
]
[[package]]
name = "docker_credential"
version = "1.3.1"
@ -1504,6 +1505,18 @@ dependencies = [
"regex",
]
[[package]]
name = "enum-as-inner"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.92",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@ -1985,6 +1998,51 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-proto"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "447afdcdb8afb9d0a852af6dc65d9b285ce720ed7a59e42a8bf2e931c67bc1b5"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"idna 1.0.3",
"ipnet",
"once_cell",
"rand",
"thiserror 1.0.69",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a2e2aba9c389ce5267d31cf1e4dace82390ae276b0b364ea55630b1fa1b44b4"
dependencies = [
"cfg-if",
"futures-util",
"hickory-proto",
"ipconfig",
"lru-cache",
"once_cell",
"parking_lot 0.12.3",
"rand",
"resolv-conf",
"smallvec",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "hkdf"
version = "0.12.4"
@ -2012,6 +2070,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi",
]
[[package]]
name = "hostname"
version = "0.4.0"
@ -2171,6 +2240,7 @@ dependencies = [
"hyper",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@ -2514,6 +2584,18 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ipconfig"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
dependencies = [
"socket2",
"widestring",
"windows-sys 0.48.0",
"winreg",
]
[[package]]
name = "ipnet"
version = "2.10.1"
@ -2579,6 +2661,17 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "java-properties"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37bf6f484471c451f2b51eabd9e66b3fa7274550c5ec4b6c3d6070840945117f"
dependencies = [
"encoding_rs",
"lazy_static",
"regex",
]
[[package]]
name = "jobserver"
version = "0.1.32"
@ -2808,7 +2901,7 @@ dependencies = [
"fastrand",
"futures-io",
"futures-util",
"hostname",
"hostname 0.4.0",
"httpdate",
"idna 1.0.3",
"mime",
@ -3079,6 +3172,15 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "lru-cache"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "mac"
version = "0.1.1"
@ -3105,6 +3207,12 @@ dependencies = [
"tendril",
]
[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]]
name = "matchers"
version = "0.1.0"
@ -3251,7 +3359,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@ -4054,7 +4162,7 @@ dependencies = [
[[package]]
name = "qbit-rs"
version = "0.4.6"
source = "git+https://github.com/lonelyhentxi/qbit.git?rev=a2c70aa#a2c70aa391d5edc2ab79c92fa8dcfec00d0d714b"
source = "git+https://github.com/lonelyhentxi/qbit.git?rev=72d53138ebe#72d53138ebe1e3de49be46edc213ea9cb7345e55"
dependencies = [
"bytes",
"mod_use",
@ -4086,6 +4194,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.36.2"
@ -4166,6 +4280,7 @@ dependencies = [
"percent-encoding",
"serde",
"thiserror 1.0.69",
"tracing",
"url",
]
@ -4253,26 +4368,31 @@ dependencies = [
name = "recorder"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-auth",
"bollard",
"bytes",
"chrono",
"dlsignal",
"eyre",
"fancy-regex",
"figment",
"futures",
"html-escape",
"insta",
"itertools 0.13.0",
"jwt-authorizer",
"lazy_static",
"leaky-bucket",
"librqbit-core",
"lightningcss",
"loco-rs",
"log",
"maplit",
"once_cell",
"opendal",
"qbit-rs",
"quirks_path",
"regex",
"reqwest",
@ -4287,6 +4407,8 @@ dependencies = [
"serde_json",
"serde_with",
"serial_test",
"testcontainers",
"testcontainers-modules",
"thiserror 2.0.9",
"tokio",
"tracing",
@ -4419,6 +4541,7 @@ dependencies = [
"futures-core",
"futures-util",
"h2",
"hickory-resolver",
"http 1.2.0",
"http-body",
"http-body-util",
@ -4437,6 +4560,7 @@ dependencies = [
"pin-project-lite",
"quinn",
"rustls",
"rustls-native-certs",
"rustls-pemfile",
"rustls-pki-types",
"serde",
@ -4522,6 +4646,16 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "resolv-conf"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00"
dependencies = [
"hostname 0.3.1",
"quick-error",
]
[[package]]
name = "retry-policies"
version = "0.4.0"
@ -4709,15 +4843,14 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.7.3"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.1.0",
]
[[package]]
@ -5015,7 +5148,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.6.0",
"core-foundation",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81d3f8c9bfcc3cbb6b0179eb57042d75b1582bdc65c3cb95f3fa999509c03cbc"
dependencies = [
"bitflags 2.6.0",
"core-foundation 0.10.0",
"core-foundation-sys",
"libc",
"security-framework-sys",
@ -5065,6 +5211,17 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-java-properties"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b5db85b934578ef8a8acc8ef7956b313d9e920d4d4160ef7862bd4c85d4bc7"
dependencies = [
"encoding_rs",
"java-properties",
"serde",
]
[[package]]
name = "serde-value"
version = "0.7.0"
@ -5295,6 +5452,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
@ -5834,7 +6001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.6.0",
"core-foundation",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@ -5920,13 +6087,13 @@ dependencies = [
[[package]]
name = "testcontainers"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f40cc2bd72e17f328faf8ca7687fe337e61bccd8acf9674fa78dd3792b045e1"
source = "git+https://github.com/testcontainers/testcontainers-rs.git?rev=af21727#af2172714bbb79c6ce648b699135922f85cafc0c"
dependencies = [
"async-trait",
"bollard",
"bollard-stubs",
"bytes",
"conquer-once",
"docker_credential",
"either",
"etcetera",
@ -5935,14 +6102,18 @@ dependencies = [
"memchr",
"parse-display",
"pin-project-lite",
"reqwest",
"serde",
"serde-java-properties",
"serde_json",
"serde_with",
"thiserror 1.0.69",
"signal-hook",
"thiserror 2.0.9",
"tokio",
"tokio-stream",
"tokio-tar",
"tokio-util",
"ulid",
"url",
]
@ -6831,6 +7002,12 @@ dependencies = [
"wasite",
]
[[package]]
name = "widestring"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311"
[[package]]
name = "winapi"
version = "0.3.9"
@ -7068,6 +7245,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "write16"
version = "1.0.0"

View File

@ -1,3 +1,9 @@
[workspace]
members = ["apps/recorder", "packages/quirks-path", "packages/dlsignal"]
members = ["apps/recorder", "packages/quirks-path"]
resolver = "2"
[patch.crates-io]
testcontainers = { git = "https://github.com/testcontainers/testcontainers-rs.git", rev = "af21727" }
# [patch."https://github.com/lonelyhentxi/qbit.git"]
# qbit-rs = { path = "./patches/qbit-rs" }

View File

@ -13,14 +13,21 @@ name = "recorder_cli"
path = "src/bin/main.rs"
required-features = []
[features]
default = []
testcontainers = [
"dep:testcontainers",
"dep:testcontainers-modules",
"dep:bollard",
]
[dependencies]
quirks_path = { path = "../../packages/quirks-path" }
dlsignal = { path = "../../packages/dlsignal" }
loco-rs = { version = "0.13" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
eyre = "0.6"
tokio = { version = "1.42", default-features = false }
tokio = { version = "1.42", features = ["macros", "fs", "rt-multi-thread"] }
async-trait = "0.1.83"
tracing = "0.1"
chrono = "0.4"
@ -60,6 +67,25 @@ leaky-bucket = "1.1.2"
serde_with = "3"
jwt-authorizer = "0.15.0"
axum-auth = "0.7.0"
futures = "0.3.31"
librqbit-core = "4"
qbit-rs = { git = "https://github.com/lonelyhentxi/qbit.git", rev = "72d53138ebe", features = [
"default",
"builder",
] }
testcontainers = { version = "0.23.1", features = [
"default",
"properties-config",
"watchdog",
"http_wait",
"reusable-containers",
], optional = true }
testcontainers-modules = { version = "0.11.4", optional = true }
log = "0.4.22"
anyhow = "1.0.95"
bollard = { version = "0.18", optional = true }
[dev-dependencies]
serial_test = "3"

View File

@ -1,4 +1,3 @@
pub const MIKAN_BUCKET_KEY: &str = "mikan";
pub const MIKAN_BASE_URL: &str = "https://mikanani.me/";
pub const MIKAN_UNKNOWN_FANSUB_NAME: &str = "生肉/不明字幕";
pub const MIKAN_UNKNOWN_FANSUB_ID: &str = "202";

View File

@ -6,7 +6,7 @@ pub mod web_parser;
pub use client::{AppMikanClient, AppMikanClientInitializer};
pub use config::AppMikanConfig;
pub use constants::{MIKAN_BASE_URL, MIKAN_BUCKET_KEY};
pub use constants::MIKAN_BUCKET_KEY;
pub use rss_parser::{
build_mikan_bangumi_rss_link, build_mikan_subscriber_aggregation_rss_link,
parse_mikan_bangumi_id_from_rss_link, parse_mikan_rss_channel_from_rss_link,

View File

@ -1,17 +1,22 @@
use std::ops::Deref;
use chrono::DateTime;
use dlsignal::core::BITTORRENT_MIME_TYPE;
use itertools::Itertools;
use reqwest::IntoUrl;
use serde::{Deserialize, Serialize};
use url::Url;
use super::{
web_parser::{parse_mikan_episode_id_from_homepage, MikanEpisodeHomepage},
AppMikanClient,
use crate::{
extract::{
errors::ParseError,
mikan::{
web_parser::{parse_mikan_episode_id_from_homepage, MikanEpisodeHomepage},
AppMikanClient,
},
},
fetch::bytes::fetch_bytes,
sync::core::BITTORRENT_MIME_TYPE,
};
use crate::{extract::errors::ParseError, fetch::bytes::fetch_bytes};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanRssItem {
@ -297,11 +302,12 @@ pub async fn parse_mikan_rss_channel_from_rss_link(
mod tests {
use std::assert_matches::assert_matches;
use dlsignal::core::BITTORRENT_MIME_TYPE;
use crate::extract::mikan::{
parse_mikan_rss_channel_from_rss_link, MikanBangumiAggregationRssChannel,
MikanBangumiRssChannel, MikanRssChannel,
use crate::{
extract::mikan::{
parse_mikan_rss_channel_from_rss_link, MikanBangumiAggregationRssChannel,
MikanBangumiRssChannel, MikanRssChannel,
},
sync::core::BITTORRENT_MIME_TYPE,
};
#[tokio::test]

View File

@ -31,6 +31,12 @@ pub struct HttpClient {
pub config: HttpClientConfig,
}
impl Into<ClientWithMiddleware> for HttpClient {
fn into(self) -> ClientWithMiddleware {
self.client
}
}
impl Deref for HttpClient {
type Target = ClientWithMiddleware;

View File

@ -1,6 +1,7 @@
#![feature(duration_constructors, assert_matches, unboxed_closures)]
pub mod app;
pub mod auth;
pub mod config;
pub mod controllers;
pub mod dal;
@ -8,7 +9,9 @@ pub mod extract;
pub mod fetch;
pub mod migrations;
pub mod models;
pub mod sync;
pub mod tasks;
#[cfg(test)]
pub mod test_utils;
pub mod views;
pub mod workers;
pub mod auth;

View File

@ -1,4 +1,5 @@
use bytes::Bytes;
use std::fmt::Debug;
use itertools::Itertools;
use lazy_static::lazy_static;
use librqbit_core::{
@ -7,23 +8,14 @@ use librqbit_core::{
};
use quirks_path::{Path, PathBuf};
use regex::Regex;
use reqwest::IntoUrl;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{QbitTorrent, QbitTorrentContent, TorrentDownloadError};
use super::{QbitTorrent, QbitTorrentContent, TorrentDownloadError};
use crate::fetch::{fetch_bytes, HttpClient};
pub const BITTORRENT_MIME_TYPE: &str = "application/x-bittorrent";
pub const MAGNET_SCHEMA: &str = "magnet";
pub const DEFAULT_TORRENT_USER_AGENT: &str = "Wget/1.13.4 (linux-gnu)";
async fn download_torrent_file<T: IntoUrl>(url: T) -> eyre::Result<Bytes> {
let request_client = reqwest::Client::builder()
.user_agent(DEFAULT_TORRENT_USER_AGENT)
.build()?;
let bytes = request_client.get(url).send().await?.bytes().await?;
Ok(bytes)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -46,7 +38,7 @@ lazy_static! {
static ref TORRENT_EXT_RE: Regex = Regex::new(r"\.torrent$").unwrap();
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub enum TorrentSource {
MagnetUrl {
url: Url,
@ -64,7 +56,7 @@ pub enum TorrentSource {
}
impl TorrentSource {
pub async fn parse(url: &str) -> eyre::Result<Self> {
pub async fn parse(client: Option<&HttpClient>, url: &str) -> eyre::Result<Self> {
let url = Url::parse(url)?;
let source = if url.scheme() == MAGNET_SCHEMA {
TorrentSource::from_magnet_url(url)?
@ -79,11 +71,11 @@ impl TorrentSource {
) {
TorrentSource::from_torrent_url(url, match_hash.as_str().to_string())?
} else {
let contents = download_torrent_file(url).await?;
let contents = fetch_bytes(client, url).await?;
TorrentSource::from_torrent_file(contents.to_vec(), Some(basename.to_string()))?
}
} else {
let contents = download_torrent_file(url).await?;
let contents = fetch_bytes(client, url).await?;
TorrentSource::from_torrent_file(contents.to_vec(), None)?
};
Ok(source)
@ -137,6 +129,24 @@ impl TorrentSource {
}
}
impl Debug for TorrentSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TorrentSource::MagnetUrl { url, .. } => {
write!(f, "MagnetUrl {{ url: {} }}", url.as_str())
}
TorrentSource::TorrentUrl { url, .. } => {
write!(f, "TorrentUrl {{ url: {} }}", url.as_str())
}
TorrentSource::TorrentFile { name, hash, .. } => write!(
f,
"TorrentFile {{ name: \"{}\", hash: \"{hash}\" }}",
name.as_deref().unwrap_or_default()
),
}
}
}
pub trait TorrentContent {
fn get_name(&self) -> &str;

View File

@ -2,7 +2,10 @@ pub mod core;
pub mod error;
pub mod qbit;
pub use core::{Torrent, TorrentContent, TorrentDownloader, TorrentFilter, TorrentSource};
pub use core::{
Torrent, TorrentContent, TorrentDownloader, TorrentFilter, TorrentSource, BITTORRENT_MIME_TYPE,
MAGNET_SCHEMA,
};
pub use error::TorrentDownloadError;
pub use qbit::{

View File

@ -14,9 +14,10 @@ use qbit_rs::{
};
use quirks_path::{path_equals_as_file_url, Path, PathBuf};
use tokio::time::sleep;
use tracing::instrument;
use url::Url;
use crate::{Torrent, TorrentDownloadError, TorrentDownloader, TorrentFilter, TorrentSource};
use super::{Torrent, TorrentDownloadError, TorrentDownloader, TorrentFilter, TorrentSource};
impl From<TorrentSource> for QbitTorrentSource {
fn from(value: TorrentSource) -> Self {
@ -81,7 +82,9 @@ impl QBittorrentDownloader {
) -> Result<Self, TorrentDownloadError> {
let endpoint_url =
Url::parse(&creation.endpoint).map_err(TorrentDownloadError::InvalidUrlParse)?;
let credential = Credential::new(creation.username, creation.password);
let client = Qbit::new(endpoint_url.clone(), credential);
client
@ -100,6 +103,7 @@ impl QBittorrentDownloader {
})
}
#[instrument(level = "debug")]
pub async fn api_version(&self) -> eyre::Result<String> {
let result = self.client.get_webapi_version().await?;
Ok(result)
@ -116,8 +120,9 @@ impl QBittorrentDownloader {
H: FnOnce() -> E,
G: Fn(Arc<Qbit>, E) -> Fut,
Fut: Future<Output = eyre::Result<D>>,
F: FnMut(D) -> bool,
F: FnMut(&D) -> bool,
E: Clone,
D: Debug + serde::Serialize,
{
let mut next_wait_ms = 32u64;
let mut all_wait_ms = 0u64;
@ -129,9 +134,10 @@ impl QBittorrentDownloader {
if all_wait_ms >= timeout.as_millis() as u64 {
// full update
let sync_data = fetch_data_fn(self.client.clone(), env.clone()).await?;
if stop_wait_fn(sync_data) {
if stop_wait_fn(&sync_data) {
break;
} else {
tracing::warn!(name = "wait_until timeout", sync_data = serde_json::to_string(&sync_data).unwrap(), timeout = ?timeout);
return Err(TorrentDownloadError::TimeoutError {
action: Cow::Borrowed("QBittorrentDownloader::wait_unit"),
timeout,
@ -140,7 +146,7 @@ impl QBittorrentDownloader {
}
}
let sync_data = fetch_data_fn(self.client.clone(), env.clone()).await?;
if stop_wait_fn(sync_data) {
if stop_wait_fn(&sync_data) {
break;
}
next_wait_ms *= 2;
@ -148,6 +154,7 @@ impl QBittorrentDownloader {
Ok(())
}
#[instrument(level = "trace", skip(self, stop_wait_fn))]
pub async fn wait_torrents_until<F>(
&self,
arg: GetTorrentListArg,
@ -155,7 +162,7 @@ impl QBittorrentDownloader {
timeout: Option<Duration>,
) -> eyre::Result<()>
where
F: FnMut(Vec<QbitTorrent>) -> bool,
F: FnMut(&Vec<QbitTorrent>) -> bool,
{
self.wait_until(
|| arg,
@ -171,7 +178,8 @@ impl QBittorrentDownloader {
.await
}
pub async fn wait_sync_until<F: FnMut(SyncData) -> bool>(
#[instrument(level = "debug", skip(self, stop_wait_fn))]
pub async fn wait_sync_until<F: FnMut(&SyncData) -> bool>(
&self,
stop_wait_fn: F,
timeout: Option<Duration>,
@ -188,7 +196,8 @@ impl QBittorrentDownloader {
.await
}
async fn wait_torrent_contents_until<F: FnMut(Vec<QbitTorrentContent>) -> bool>(
#[instrument(level = "debug", skip(self, stop_wait_fn))]
async fn wait_torrent_contents_until<F: FnMut(&Vec<QbitTorrentContent>) -> bool>(
&self,
hash: &str,
stop_wait_fn: F,
@ -211,6 +220,7 @@ impl QBittorrentDownloader {
#[async_trait::async_trait]
impl TorrentDownloader for QBittorrentDownloader {
#[instrument(level = "debug", skip(self))]
async fn get_torrents_info(
&self,
status_filter: TorrentFilter,
@ -239,6 +249,7 @@ impl TorrentDownloader for QBittorrentDownloader {
.collect::<Vec<_>>())
}
#[instrument(level = "debug", skip(self))]
async fn add_torrents(
&self,
source: TorrentSource,
@ -268,6 +279,7 @@ impl TorrentDownloader for QBittorrentDownloader {
|sync_data| {
sync_data
.torrents
.as_ref()
.is_some_and(|t| t.contains_key(source_hash))
},
None,
@ -276,6 +288,7 @@ impl TorrentDownloader for QBittorrentDownloader {
Ok(())
}
#[instrument(level = "debug", skip(self))]
async fn delete_torrents(&self, hashes: Vec<String>) -> eyre::Result<()> {
self.client
.delete_torrents(hashes.clone(), Some(true))
@ -291,6 +304,7 @@ impl TorrentDownloader for QBittorrentDownloader {
Ok(())
}
#[instrument(level = "debug", skip(self))]
async fn rename_torrent_file(
&self,
hash: &str,
@ -304,7 +318,11 @@ impl TorrentDownloader for QBittorrentDownloader {
hash,
|contents| -> bool {
contents.iter().any(|c| {
path_equals_as_file_url(save_path.join(&c.name), &new_path).unwrap_or(false)
path_equals_as_file_url(save_path.join(&c.name), &new_path)
.inspect_err(|error| {
tracing::warn!(name = "path_equals_as_file_url", error = ?error);
})
.unwrap_or(false)
})
},
None,
@ -313,19 +331,23 @@ impl TorrentDownloader for QBittorrentDownloader {
Ok(())
}
#[instrument(level = "debug", skip(self))]
async fn move_torrents(&self, hashes: Vec<String>, new_path: &str) -> eyre::Result<()> {
self.client
.set_torrent_location(hashes.clone(), new_path)
.await?;
self.wait_torrents_until(
GetTorrentListArg::builder()
.hashes(hashes.join("|"))
.build(),
|torrents| -> bool {
torrents.iter().all(|t| {
t.save_path
.as_ref()
.is_some_and(|p| path_equals_as_file_url(p, new_path).unwrap_or(false))
torrents.iter().flat_map(|t| t.save_path.as_ref()).any(|p| {
path_equals_as_file_url(p, new_path)
.inspect_err(|error| {
tracing::warn!(name = "path_equals_as_file_url", error = ?error);
})
.unwrap_or(false)
})
},
None,
@ -346,11 +368,13 @@ impl TorrentDownloader for QBittorrentDownloader {
Ok(torrent.save_path.take())
}
#[instrument(level = "debug", skip(self))]
async fn check_connection(&self) -> eyre::Result<()> {
self.api_version().await?;
Ok(())
}
#[instrument(level = "debug", skip(self))]
async fn set_torrents_category(&self, hashes: Vec<String>, category: &str) -> eyre::Result<()> {
let result = self
.client
@ -379,6 +403,7 @@ impl TorrentDownloader for QBittorrentDownloader {
Ok(())
}
#[instrument(level = "debug", skip(self))]
async fn add_torrent_tags(&self, hashes: Vec<String>, tags: Vec<String>) -> eyre::Result<()> {
if tags.is_empty() {
return Err(eyre::eyre!("add torrent tags can not be empty"));
@ -408,6 +433,7 @@ impl TorrentDownloader for QBittorrentDownloader {
Ok(())
}
#[instrument(level = "debug", skip(self))]
async fn add_category(&self, category: &str) -> eyre::Result<()> {
self.client
.add_category(
@ -419,6 +445,7 @@ impl TorrentDownloader for QBittorrentDownloader {
|sync_data| {
sync_data
.categories
.as_ref()
.is_some_and(|s| s.contains_key(category))
},
None,
@ -445,11 +472,12 @@ impl Debug for QBittorrentDownloader {
#[cfg(test)]
pub mod tests {
use itertools::Itertools;
use testcontainers_modules::testcontainers::ImageExt;
use super::*;
fn get_tmp_qbit_test_folder() -> &'static str {
if cfg!(windows) {
if cfg!(all(windows, not(feature = "testcontainers"))) {
"C:\\Windows\\Temp\\konobangu\\qbit"
} else {
"/tmp/konobangu/qbit"
@ -457,65 +485,117 @@ pub mod tests {
}
#[cfg(feature = "testcontainers")]
pub fn create_qbit_testcontainer() -> testcontainers::RunnableImage<testcontainers::GenericImage>
{
let image = testcontainers::RunnableImage::from(
testcontainers::GenericImage::new("linuxserver/qbittorrent", "latest")
.with_wait_for(testcontainers::core::WaitFor::message_on_stderr(
"Connection to localhost",
))
.with_env_var("WEBUI_PORT", "8080")
.with_env_var("TZ", "Asia/Singapore")
.with_env_var("TORRENTING_PORT", "6881")
.with_exposed_port(8080)
.with_exposed_port(6881),
);
pub async fn create_qbit_testcontainer(
) -> eyre::Result<testcontainers::ContainerRequest<testcontainers::GenericImage>> {
use testcontainers::{
core::{
ContainerPort,
// ReuseDirective,
WaitFor,
},
GenericImage,
};
image
use crate::test_utils::testcontainers::ContainerRequestEnhancedExt;
let container = GenericImage::new("linuxserver/qbittorrent", "latest")
.with_wait_for(WaitFor::message_on_stderr("Connection to localhost"))
.with_env_var("WEBUI_PORT", "8080")
.with_env_var("TZ", "Asia/Singapore")
.with_env_var("TORRENTING_PORT", "6881")
.with_mapped_port(6881, ContainerPort::Tcp(6881))
.with_mapped_port(8080, ContainerPort::Tcp(8080))
// .with_reuse(ReuseDirective::Always)
.with_default_log_consumer()
.with_prune_existed_label("qbit-downloader", true, true)
.await?;
Ok(container)
}
#[cfg(not(feature = "testcontainers"))]
#[tokio::test]
async fn test_qbittorrent_downloader() {
test_qbittorrent_downloader_impl().await;
test_qbittorrent_downloader_impl(None, None).await;
}
// @TODO: not support now, testcontainers crate not support to read logs to get
// password
#[cfg(feature = "testcontainers")]
#[tokio::test]
async fn test_qbittorrent_downloader() {
let docker = testcontainers::clients::Cli::default();
let image = create_qbit_testcontainer();
#[tokio::test(flavor = "multi_thread")]
async fn test_qbittorrent_downloader() -> eyre::Result<()> {
use testcontainers::runners::AsyncRunner;
use tokio::io::AsyncReadExt;
let _container = docker.run(image);
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_test_writer()
.init();
test_qbittorrent_downloader_impl().await;
let image = create_qbit_testcontainer().await?;
let container = image.start().await?;
let mut logs = String::new();
container.stdout(false).read_to_string(&mut logs).await?;
let username = logs
.lines()
.find_map(|line| {
if line.contains("The WebUI administrator username is") {
line.split_whitespace().last()
} else {
None
}
})
.expect("should have username")
.trim();
let password = logs
.lines()
.find_map(|line| {
if line.contains("A temporary password is provided for this session") {
line.split_whitespace().last()
} else {
None
}
})
.expect("should have password")
.trim();
tracing::info!(username, password);
test_qbittorrent_downloader_impl(Some(username), Some(password)).await?;
Ok(())
}
async fn test_qbittorrent_downloader_impl() {
async fn test_qbittorrent_downloader_impl(
username: Option<&str>,
password: Option<&str>,
) -> eyre::Result<()> {
let base_save_path = Path::new(get_tmp_qbit_test_folder());
let downloader = QBittorrentDownloader::from_creation(QBittorrentDownloaderCreation {
endpoint: "http://localhost:8080".to_string(),
password: "".to_string(),
username: "".to_string(),
let mut downloader = QBittorrentDownloader::from_creation(QBittorrentDownloaderCreation {
endpoint: "http://127.0.0.1:8080".to_string(),
password: password.unwrap_or_default().to_string(),
username: username.unwrap_or_default().to_string(),
subscriber_id: 0,
save_path: base_save_path.to_string(),
})
.await
.unwrap();
.await?;
downloader.check_connection().await.unwrap();
downloader.wait_sync_timeout = Duration::from_secs(3);
downloader.check_connection().await?;
downloader
.delete_torrents(vec!["47ee2d69e7f19af783ad896541a07b012676f858".to_string()])
.await
.unwrap();
.await?;
let torrent_source = TorrentSource::parse(
None,
"https://mikanani.me/Download/20240301/47ee2d69e7f19af783ad896541a07b012676f858.torrent"
).await.unwrap();
).await?;
let save_path = base_save_path.join(format!(
"test_add_torrents_{}",
@ -524,8 +604,7 @@ pub mod tests {
downloader
.add_torrents(torrent_source, save_path.to_string(), Some("bangumi"))
.await
.unwrap();
.await?;
let get_torrent = async || -> eyre::Result<Torrent> {
let torrent_infos = downloader
@ -540,7 +619,7 @@ pub mod tests {
Ok(result)
};
let target_torrent = get_torrent().await.unwrap();
let target_torrent = get_torrent().await?;
let files = target_torrent.iter_files().collect_vec();
assert!(!files.is_empty());
@ -558,10 +637,9 @@ pub mod tests {
vec!["47ee2d69e7f19af783ad896541a07b012676f858".to_string()],
vec![test_tag.clone()],
)
.await
.unwrap();
.await?;
let target_torrent = get_torrent().await.unwrap();
let target_torrent = get_torrent().await?;
assert!(target_torrent.get_tags().iter().any(|s| s == &test_tag));
@ -572,10 +650,9 @@ pub mod tests {
vec!["47ee2d69e7f19af783ad896541a07b012676f858".to_string()],
&test_category,
)
.await
.unwrap();
.await?;
let target_torrent = get_torrent().await.unwrap();
let target_torrent = get_torrent().await?;
assert_eq!(Some(test_category.as_str()), target_torrent.get_category());
@ -589,10 +666,9 @@ pub mod tests {
vec!["47ee2d69e7f19af783ad896541a07b012676f858".to_string()],
moved_save_path.as_str(),
)
.await
.unwrap();
.await?;
let target_torrent = get_torrent().await.unwrap();
let target_torrent = get_torrent().await?;
let content_path = target_torrent.iter_files().next().unwrap().get_name();
@ -604,10 +680,9 @@ pub mod tests {
content_path,
new_content_path,
)
.await
.unwrap();
.await?;
let target_torrent = get_torrent().await.unwrap();
let target_torrent = get_torrent().await?;
let content_path = target_torrent.iter_files().next().unwrap().get_name();
@ -615,14 +690,14 @@ pub mod tests {
downloader
.delete_torrents(vec!["47ee2d69e7f19af783ad896541a07b012676f858".to_string()])
.await
.unwrap();
.await?;
let torrent_infos1 = downloader
.get_torrents_info(TorrentFilter::All, None, None)
.await
.unwrap();
.await?;
assert!(torrent_infos1.is_empty());
Ok(())
}
}

View File

@ -0,0 +1,107 @@
#[cfg(feature = "testcontainers")]
pub mod testcontainers {
use bollard::container::ListContainersOptions;
use itertools::Itertools;
use testcontainers::{
core::logs::consumer::logging_consumer::LoggingConsumer, ContainerRequest, Image, ImageExt,
};
pub const TESTCONTAINERS_PROJECT_KEY: &str = "tech.enfw.testcontainers.project";
pub const TESTCONTAINERS_CONTAINER_KEY: &str = "tech.enfw.testcontainers.container";
pub const TESTCONTAINERS_PRUNE_KEY: &str = "tech.enfw.testcontainers.prune";
#[async_trait::async_trait]
pub trait ContainerRequestEnhancedExt<I>: Sized + ImageExt<I>
where
I: Image,
{
async fn with_prune_existed_label(
self,
container_label: &str,
prune: bool,
force: bool,
) -> eyre::Result<Self>;
fn with_default_log_consumer(self) -> Self;
}
#[async_trait::async_trait]
impl<I> ContainerRequestEnhancedExt<I> for ContainerRequest<I>
where
I: Image,
{
async fn with_prune_existed_label(
self,
container_label: &str,
prune: bool,
force: bool,
) -> eyre::Result<Self> {
use std::collections::HashMap;
use bollard::container::PruneContainersOptions;
use testcontainers::core::client::docker_client_instance;
if prune {
let client = docker_client_instance().await?;
let mut filters = HashMap::<String, Vec<String>>::new();
filters.insert(
String::from("label"),
vec![
format!("{TESTCONTAINERS_PRUNE_KEY}=true"),
format!("{}={}", TESTCONTAINERS_PROJECT_KEY, "konobangu"),
format!("{}={}", TESTCONTAINERS_CONTAINER_KEY, container_label),
],
);
if force {
let result = client
.list_containers(Some(ListContainersOptions {
all: false,
filters: filters.clone(),
..Default::default()
}))
.await?;
let remove_containers = result
.iter()
.filter(|c| matches!(c.state.as_deref(), Some("running")))
.flat_map(|c| c.id.as_deref())
.collect_vec();
futures::future::try_join_all(
remove_containers
.iter()
.map(|c| client.stop_container(c, None)),
)
.await?;
tracing::warn!(name = "stop running containers", result = ?remove_containers);
}
let result = client
.prune_containers(Some(PruneContainersOptions { filters }))
.await?;
tracing::warn!(name = "prune existed containers", result = ?result);
}
let result = self.with_labels([
(TESTCONTAINERS_PRUNE_KEY, "true"),
(TESTCONTAINERS_PROJECT_KEY, "konobangu"),
(TESTCONTAINERS_CONTAINER_KEY, container_label),
]);
Ok(result)
}
fn with_default_log_consumer(self) -> Self {
self.with_log_consumer(
LoggingConsumer::new()
.with_stdout_level(log::Level::Info)
.with_stderr_level(log::Level::Error),
)
}
}
}

View File

@ -1,17 +0,0 @@
**/config/local.yaml
**/config/*.local.yaml
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

View File

@ -1,38 +0,0 @@
[package]
name = "dlsignal"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "dlsignal"
path = "src/lib.rs"
[features]
default = []
testcontainers = []
[dependencies]
quirks_path = { path = "../quirks-path" }
async-trait = "0.1.83"
chrono = "0.4.39"
eyre = "0.6.12"
futures = "0.3.31"
itertools = "0.13.0"
lazy_static = "1.5.0"
librqbit-core = "4"
qbit-rs = { git = "https://github.com/lonelyhentxi/qbit.git", rev = "a2c70aa", features = [
"default",
"builder",
] }
regex = "1.11.1"
serde = "1.0.216"
thiserror = "2.0.9"
tokio = "1.42.0"
url = "2.5.4"
reqwest = "0.12.11"
bytes = "1.9.0"
[dev-dependencies]
testcontainers = { version = "0.23.1" }
testcontainers-modules = { version = "0.11.4" }

View File

@ -8,4 +8,8 @@ nom = "7.1.3"
percent-encoding = "2.3.1"
serde = { version = "1.0.197", features = ["derive"] }
thiserror = "1.0.57"
tracing = "0.1.41"
url = "2.5.0"
[dev-dependencies]
tracing = "0.1.41"

View File

@ -1702,11 +1702,11 @@ mod tests {
);
assert!(matches!(
test_fn(r"\\?\abc\path"),
Err(PathToUrlError::UrlNotSupportedPrefix { .. })
Err(PathToUrlError::NotSupportedPrefixError { .. })
));
assert!(matches!(
test_fn(r"\\.\device\path"),
Err(PathToUrlError::UrlNotSupportedPrefix { .. })
Err(PathToUrlError::NotSupportedPrefixError { .. })
));
assert!(matches!(
test_fn(r"~/a"),

View File

@ -10,12 +10,20 @@ const URL_PATH_SEGMENT: &AsciiSet = &URL_PATH.add(b'/').add(b'%');
#[derive(thiserror::Error, Debug)]
pub enum PathToUrlError {
#[error("Path not absolute: {path}")]
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
#[error("PathNotAbsoluteError {{ path = {path} }}")]
PathNotAbsoluteError { path: Cow<'static, str> },
#[error("Invalid UNC path")]
ParseUrlError(#[from] ::url::ParseError),
#[error("Path prefix can not be a url: {path}")]
UrlNotSupportedPrefix { path: Cow<'static, str> },
#[error("NotSupportedPrefixError {{ path = {path}, prefix = {prefix} }}")]
NotSupportedPrefixError {
path: Cow<'static, str>,
prefix: Cow<'static, str>,
},
#[error("NotSupportedFirstComponentError {{ path = {path}, comp = {comp} }}")]
NotSupportedFirstComponentError {
path: Cow<'static, str>,
comp: Cow<'static, str>,
},
}
#[inline]
@ -60,15 +68,41 @@ pub(crate) fn path_to_file_url_segments(
serialization.extend(percent_encode(share.as_bytes(), URL_PATH_SEGMENT));
}
_ => {
return Err(PathToUrlError::UrlNotSupportedPrefix {
return Err(PathToUrlError::NotSupportedPrefixError {
path: Cow::Owned(path.as_str().to_string()),
})
prefix: Cow::Owned(p.as_str().to_string()),
});
}
},
_ => {
return Err(PathToUrlError::UrlNotSupportedPrefix {
Some(Component::RootDir(_)) => {
let host_end = to_u32(serialization.len()).unwrap();
let mut empty = true;
for component in components {
empty = false;
serialization.push('/');
serialization.extend(percent_encode(
component.as_str().as_bytes(),
URL_PATH_SEGMENT,
));
}
if empty {
serialization.push('/');
}
return Ok((host_end, None));
}
Some(comp) => {
return Err(PathToUrlError::NotSupportedFirstComponentError {
path: Cow::Owned(path.as_str().to_string()),
})
comp: Cow::Owned(comp.as_str().to_string()),
});
}
None => {
return Err(PathToUrlError::NotSupportedFirstComponentError {
path: Cow::Owned(path.as_str().to_string()),
comp: Cow::Borrowed("null"),
});
}
}

0
patches/.gitkeep Normal file
View File