Skip to content

Commit 935facb

Browse files
committed
feat: [#453] new console command
New console command to upload torrent to the Index remotely by using the API client. ```console cargo run --bin seeder -- --api-base-url <API_BASE_URL> --number-of-torrents <NUMBER_OF_TORRENTS> --user <USER> --password <PASSWORD> --interval <INTERVAL> ``` For example: ```console cargo run --bin seeder -- --api-base-url "localhost:3001" --number-of-torrents 1000 --user admin --password 12345678 --interval 0 ``` That command would upload 1000 random torrents to the Index using the user account `admin` with password `123456` and waiting `1` second between uploads.
1 parent df3a9be commit 935facb

File tree

8 files changed

+249
-40
lines changed

8 files changed

+249
-40
lines changed

src/bin/seeder.rs

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Program to upload random torrents to a live Index API.
22
use torrust_index::console::commands::seeder::app;
33

4-
fn main() -> anyhow::Result<()> {
5-
app::run()
4+
#[tokio::main]
5+
async fn main() -> anyhow::Result<()> {
6+
app::run().await
67
}

src/console/commands/seeder/api.rs

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use crate::web::api::client::v1::client::Client;
2+
use crate::web::api::client::v1::contexts::category::forms::AddCategoryForm;
3+
use crate::web::api::client::v1::contexts::category::responses::{ListItem, ListResponse};
4+
use crate::web::api::client::v1::contexts::torrent::forms::UploadTorrentMultipartForm;
5+
use crate::web::api::client::v1::contexts::torrent::responses::{UploadedTorrent, UploadedTorrentResponse};
6+
use crate::web::api::client::v1::contexts::user::forms::LoginForm;
7+
use crate::web::api::client::v1::contexts::user::responses::{LoggedInUserData, SuccessfulLoginResponse};
8+
use crate::web::api::client::v1::responses::TextResponse;
9+
10+
use log::debug;
11+
use thiserror::Error;
12+
13+
#[derive(Error, Debug)]
14+
pub enum Error {
15+
#[error("Torrent with the same info-hash already exist in the database")]
16+
TorrentInfoHashAlreadyExists,
17+
#[error("Torrent with the same title already exist in the database")]
18+
TorrentTitleAlreadyExists,
19+
}
20+
21+
/// It uploads a torrent file to the Torrust Index.
22+
///
23+
/// # Errors
24+
///
25+
/// It returns an error if the torrent already exists in the database.
26+
///
27+
/// # Panics
28+
///
29+
/// Panics if the response body is not a valid JSON.
30+
pub async fn upload_torrent(client: &Client, upload_torrent_form: UploadTorrentMultipartForm) -> Result<UploadedTorrent, Error> {
31+
let categories = get_categories(client).await;
32+
33+
if !contains_category_with_name(&categories, &upload_torrent_form.category) {
34+
add_category(client, &upload_torrent_form.category).await;
35+
}
36+
37+
let response = client.upload_torrent(upload_torrent_form.into()).await;
38+
39+
debug!(target:"seeder", "response: {}", response.status);
40+
41+
if response.status == 400 {
42+
if response.body.contains("This torrent already exists in our database") {
43+
return Err(Error::TorrentInfoHashAlreadyExists);
44+
}
45+
46+
if response.body.contains("This torrent title has already been used") {
47+
return Err(Error::TorrentTitleAlreadyExists);
48+
}
49+
}
50+
51+
assert!(response.is_json_and_ok(), "Error uploading torrent: {}", response.body);
52+
53+
let uploaded_torrent_response: UploadedTorrentResponse =
54+
serde_json::from_str(&response.body).expect("a valid JSON response should be returned from the Torrust Index API");
55+
56+
Ok(uploaded_torrent_response.data)
57+
}
58+
59+
/// It logs in the user and returns the user data.
60+
///
61+
/// # Panics
62+
///
63+
/// Panics if the response body is not a valid JSON.
64+
pub async fn login(client: &Client, username: &str, password: &str) -> LoggedInUserData {
65+
let response = client
66+
.login_user(LoginForm {
67+
login: username.to_owned(),
68+
password: password.to_owned(),
69+
})
70+
.await;
71+
72+
let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap_or_else(|_| {
73+
panic!(
74+
"a valid JSON response should be returned after login. Received: {}",
75+
response.body
76+
)
77+
});
78+
79+
res.data
80+
}
81+
82+
/// It returns all the index categories.
83+
///
84+
/// # Panics
85+
///
86+
/// Panics if the response body is not a valid JSON.
87+
pub async fn get_categories(client: &Client) -> Vec<ListItem> {
88+
let response = client.get_categories().await;
89+
90+
let res: ListResponse = serde_json::from_str(&response.body).unwrap();
91+
92+
res.data
93+
}
94+
95+
/// It adds a new category.
96+
pub async fn add_category(client: &Client, name: &str) -> TextResponse {
97+
client
98+
.add_category(AddCategoryForm {
99+
name: name.to_owned(),
100+
icon: None,
101+
})
102+
.await
103+
}
104+
105+
/// It checks if the category list contains the given category.
106+
fn contains_category_with_name(items: &[ListItem], category_name: &str) -> bool {
107+
items.iter().any(|item| item.name == category_name)
108+
}

src/console/commands/seeder/app.rs

+98-31
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,52 @@
33
//! Run with:
44
//!
55
//! ```text
6-
//! cargo run --bin seeder -- --number-of-torrents <NUMBER_OF_TORRENTS> --user <USER> --password <PASSWORD> --interval <INTERVAL>
6+
//! cargo run --bin seeder -- --api-base-url <API_BASE_URL> --number-of-torrents <NUMBER_OF_TORRENTS> --user <USER> --password <PASSWORD> --interval <INTERVAL>
77
//! ```
88
//!
99
//! For example:
1010
//!
1111
//! ```text
12-
//! cargo run --bin seeder -- --number-of-torrents 1000 --user admin --password 12345678 --interval 0
12+
//! cargo run --bin seeder -- --api-base-url "localhost:3001" --number-of-torrents 1000 --user admin --password 12345678 --interval 0
1313
//! ```
1414
//!
15-
//! That command would upload 100o random torrents to the Index using the user
15+
//! That command would upload 1000 random torrents to the Index using the user
1616
//! account admin with password 123456 and waiting 1 second between uploads.
17+
use std::{thread::sleep, time::Duration};
18+
19+
use anyhow::Context;
1720
use clap::Parser;
18-
use log::{debug, LevelFilter};
21+
use log::{debug, info, LevelFilter};
22+
use text_colorizer::Colorize;
23+
use uuid::Uuid;
24+
25+
use crate::{
26+
console::commands::seeder::{
27+
api::{login, upload_torrent},
28+
logging,
29+
},
30+
services::torrent_file::generate_random_torrent,
31+
utils::parse_torrent,
32+
web::api::client::v1::{
33+
client::Client,
34+
contexts::{
35+
torrent::{
36+
forms::{BinaryFile, UploadTorrentMultipartForm},
37+
responses::UploadedTorrent,
38+
},
39+
user::responses::LoggedInUserData,
40+
},
41+
},
42+
};
43+
44+
use super::api::Error;
1945

2046
#[derive(Parser, Debug)]
2147
#[clap(author, version, about, long_about = None)]
2248
struct Args {
49+
#[arg(short, long)]
50+
api_base_url: String,
51+
2352
#[arg(short, long)]
2453
number_of_torrents: i32,
2554

@@ -30,46 +59,84 @@ struct Args {
3059
password: String,
3160

3261
#[arg(short, long)]
33-
interval: i32,
62+
interval: u64,
3463
}
3564

3665
/// # Errors
3766
///
3867
/// Will not return any errors for the time being.
39-
pub fn run() -> anyhow::Result<()> {
40-
setup_logging(LevelFilter::Info);
68+
pub async fn run() -> anyhow::Result<()> {
69+
logging::setup(LevelFilter::Info);
4170

4271
let args = Args::parse();
4372

44-
println!("Number of torrents: {}", args.number_of_torrents);
45-
println!("User: {}", args.user);
46-
println!("Password: {}", args.password);
47-
println!("Interval: {:?}", args.interval);
73+
let api_user = login_index_api(&args.api_base_url, &args.user, &args.password).await;
74+
75+
let api_client = Client::authenticated(&args.api_base_url, &api_user.token);
76+
77+
info!(target:"seeder", "Uploading { } random torrents to the Torrust Index with a { } seconds interval...", args.number_of_torrents.to_string().yellow(), args.interval.to_string().yellow());
4878

49-
/* todo:
50-
- Use a client to upload a random torrent every "interval" seconds.
51-
*/
79+
for i in 1..=args.number_of_torrents {
80+
info!(target:"seeder", "Uploading torrent #{} ...", i.to_string().yellow());
81+
82+
match upload_random_torrent(&api_client).await {
83+
Ok(uploaded_torrent) => {
84+
debug!(target:"seeder", "Uploaded torrent {uploaded_torrent:?}");
85+
86+
let json = serde_json::to_string(&uploaded_torrent).context("failed to serialize upload response into JSON")?;
87+
88+
info!(target:"seeder", "Uploaded torrent: {}", json.yellow());
89+
}
90+
Err(err) => print!("Error uploading torrent {err:?}"),
91+
};
92+
93+
if i != args.number_of_torrents {
94+
sleep(Duration::from_secs(args.interval));
95+
}
96+
}
5297

5398
Ok(())
5499
}
55100

56-
fn setup_logging(level: LevelFilter) {
57-
if let Err(_err) = fern::Dispatch::new()
58-
.format(|out, message, record| {
59-
out.finish(format_args!(
60-
"{} [{}][{}] {}",
61-
chrono::Local::now().format("%+"),
62-
record.target(),
63-
record.level(),
64-
message
65-
));
66-
})
67-
.level(level)
68-
.chain(std::io::stdout())
69-
.apply()
70-
{
71-
panic!("Failed to initialize logging.")
101+
/// It logs in a user in the Index API.
102+
pub async fn login_index_api(api_url: &str, username: &str, password: &str) -> LoggedInUserData {
103+
let unauthenticated_client = Client::unauthenticated(api_url);
104+
105+
info!(target:"seeder", "Trying to login with username: {} ...", username.yellow());
106+
107+
let user: LoggedInUserData = login(&unauthenticated_client, username, password).await;
108+
109+
if user.admin {
110+
info!(target:"seeder", "Logged as admin with account: {} ", username.yellow());
111+
} else {
112+
info!(target:"seeder", "Logged as {} ", username.yellow());
72113
}
73114

74-
debug!("logging initialized.");
115+
user
116+
}
117+
118+
async fn upload_random_torrent(api_client: &Client) -> Result<UploadedTorrent, Error> {
119+
let uuid = Uuid::new_v4();
120+
121+
info!(target:"seeder", "Uploading torrent with uuid: {} ...", uuid.to_string().yellow());
122+
123+
let torrent_file = generate_random_torrent_file(uuid);
124+
125+
let upload_form = UploadTorrentMultipartForm {
126+
title: format!("title-{uuid}"),
127+
description: format!("description-{uuid}"),
128+
category: "test".to_string(),
129+
torrent_file,
130+
};
131+
132+
upload_torrent(api_client, upload_form).await
133+
}
134+
135+
/// It returns the bencoded binary data of the torrent meta file.
136+
fn generate_random_torrent_file(uuid: Uuid) -> BinaryFile {
137+
let torrent = generate_random_torrent(uuid);
138+
139+
let bytes = parse_torrent::encode_torrent(&torrent).expect("msg:the torrent should be bencoded");
140+
141+
BinaryFile::from_bytes(torrent.info.name, bytes)
75142
}
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
use log::{debug, LevelFilter};
2+
3+
/// # Panics
4+
///
5+
///
6+
pub fn setup(level: LevelFilter) {
7+
if let Err(_err) = fern::Dispatch::new()
8+
.format(|out, message, record| {
9+
out.finish(format_args!(
10+
"{} [{}][{}] {}",
11+
chrono::Local::now().format("%+"),
12+
record.target(),
13+
record.level(),
14+
message
15+
));
16+
})
17+
.level(level)
18+
.chain(std::io::stdout())
19+
.apply()
20+
{
21+
panic!("Failed to initialize logging.")
22+
}
23+
24+
debug!("logging initialized.");
25+
}

src/console/commands/seeder/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
pub mod api;
12
pub mod app;
3+
pub mod logging;

src/web/api/client/v1/client.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use serde::Serialize;
44
use super::connection_info::ConnectionInfo;
55
use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm};
66
use super::contexts::tag::forms::{AddTagForm, DeleteTagForm};
7-
use super::contexts::torrent::forms::UpdateTorrentFrom;
7+
use super::contexts::torrent::forms::UpdateTorrentForm;
88
use super::contexts::torrent::requests::InfoHash;
99
use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username};
1010
use super::http::{Query, ReqwestQuery};
@@ -119,7 +119,7 @@ impl Client {
119119
self.http_client.delete(&format!("/torrent/{info_hash}")).await
120120
}
121121

122-
pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentFrom) -> TextResponse {
122+
pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentForm) -> TextResponse {
123123
self.http_client
124124
.put(&format!("/torrent/{info_hash}"), &update_torrent_form)
125125
.await

src/web/api/client/v1/contexts/torrent/forms.rs

+9-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::path::Path;
44
use serde::{Deserialize, Serialize};
55

66
#[derive(Deserialize, Serialize)]
7-
pub struct UpdateTorrentFrom {
7+
pub struct UpdateTorrentForm {
88
pub title: Option<String>,
99
pub description: Option<String>,
1010
pub category: Option<i64>,
@@ -28,9 +28,9 @@ pub struct BinaryFile {
2828

2929
impl BinaryFile {
3030
/// # Panics
31-
///
31+
///
3232
/// Will panic if:
33-
///
33+
///
3434
/// - The path is not a file.
3535
/// - The path can't be converted into string.
3636
/// - The file can't be read.
@@ -41,6 +41,12 @@ impl BinaryFile {
4141
contents: fs::read(path).unwrap(),
4242
}
4343
}
44+
45+
/// Build the binary file directly from the binary data provided.
46+
#[must_use]
47+
pub fn from_bytes(name: String, contents: Vec<u8>) -> Self {
48+
BinaryFile { name, contents }
49+
}
4450
}
4551

4652
impl From<UploadTorrentMultipartForm> for Form {

src/web/api/client/v1/contexts/torrent/responses.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use serde::Deserialize;
1+
use serde::{Deserialize, Serialize};
22

33
pub type Id = i64;
44
pub type CategoryId = i64;
@@ -102,7 +102,7 @@ pub struct UploadedTorrentResponse {
102102
pub data: UploadedTorrent,
103103
}
104104

105-
#[derive(Deserialize, PartialEq, Debug)]
105+
#[derive(Deserialize, Serialize, PartialEq, Debug)]
106106
pub struct UploadedTorrent {
107107
pub torrent_id: Id,
108108
pub info_hash: String,

0 commit comments

Comments
 (0)