Skip to content

Commit d0a0607

Browse files
committed
feat: v0.5.0 — reannounce, sequential download, per-file priority
3 new features: - tsm reannounce <id> — force tracker reannounce - tsm sequential <id> [--on|--off] — toggle sequential download (Transmission 4.0+) - tsm files <id> --priority high/normal/low --priority-indices 0,1 --skip 2,3 --unskip 2 — per-file priority and download selection with index validation and updated display columns
1 parent 25ce28d commit d0a0607

11 files changed

Lines changed: 310 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "transmission-cli"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
edition = "2024"
55
description = "A CLI for Transmission BitTorrent client"
66
license = "MIT"

src/cli.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,26 @@ pub enum Command {
136136
id: i64,
137137
},
138138

139-
/// List files in a torrent
139+
/// List files in a torrent, or set file priority/skip
140140
Files {
141141
/// Torrent ID
142142
id: i64,
143+
144+
/// Set file priority (requires --priority-indices)
145+
#[arg(long, requires = "priority_indices")]
146+
priority: Option<FilePriority>,
147+
148+
/// File indices for priority change (comma-separated, 0-based)
149+
#[arg(long = "priority-indices", value_delimiter = ',')]
150+
priority_indices: Option<Vec<usize>>,
151+
152+
/// Skip files (comma-separated indices, 0-based)
153+
#[arg(long, value_delimiter = ',')]
154+
skip: Option<Vec<usize>>,
155+
156+
/// Unskip files (comma-separated indices, 0-based)
157+
#[arg(long, value_delimiter = ',')]
158+
unskip: Option<Vec<usize>>,
143159
},
144160

145161
/// Show or set speed limits (session-level or per-torrent)
@@ -228,6 +244,26 @@ pub enum Command {
228244
/// Check connectivity, disk space, and port status
229245
Health,
230246

247+
/// Toggle sequential download mode (Transmission 4.0+)
248+
Sequential {
249+
/// Torrent ID
250+
id: i64,
251+
252+
/// Enable sequential download
253+
#[arg(long, conflicts_with = "off")]
254+
on: bool,
255+
256+
/// Disable sequential download
257+
#[arg(long, conflicts_with = "on")]
258+
off: bool,
259+
},
260+
261+
/// Force tracker reannounce
262+
Reannounce {
263+
/// Torrent ID
264+
id: i64,
265+
},
266+
231267
/// Manage torrent trackers
232268
Tracker {
233269
#[command(subcommand)]
@@ -331,6 +367,13 @@ pub enum PolicyAction {
331367
},
332368
}
333369

370+
#[derive(Debug, Clone, ValueEnum)]
371+
pub enum FilePriority {
372+
High,
373+
Normal,
374+
Low,
375+
}
376+
334377
#[derive(Debug, Clone, ValueEnum)]
335378
pub enum BandwidthPriority {
336379
Low,

src/commands/info.rs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::cli::FilePriority;
12
use crate::client::TransmissionClient;
23
use crate::error::Error;
34
use crate::output::{json, table};
@@ -14,13 +15,74 @@ pub fn execute_info(client: &TransmissionClient, id: i64, json_output: bool) ->
1415
}
1516
}
1617

17-
pub fn execute_files(client: &TransmissionClient, id: i64, json_output: bool) -> Result<(), Error> {
18-
let (name, files) = methods::torrent_get_files(client, id)?;
18+
#[allow(clippy::too_many_arguments)]
19+
pub fn execute_files(
20+
client: &TransmissionClient,
21+
id: i64,
22+
priority: Option<&FilePriority>,
23+
priority_indices: Option<&[usize]>,
24+
skip: Option<&[usize]>,
25+
unskip: Option<&[usize]>,
26+
json_output: bool,
27+
) -> Result<(), Error> {
28+
let has_mutation = priority.is_some() || skip.is_some() || unskip.is_some();
29+
30+
if has_mutation {
31+
// Pre-fetch to validate indices
32+
let (_, files, _) = methods::torrent_get_files(client, id)?;
33+
let file_count = files.len();
34+
35+
// Validate all indices
36+
let all_indices: Vec<usize> = priority_indices
37+
.into_iter()
38+
.flatten()
39+
.chain(skip.into_iter().flatten())
40+
.chain(unskip.into_iter().flatten())
41+
.copied()
42+
.collect();
43+
44+
for &idx in &all_indices {
45+
if idx >= file_count {
46+
return Err(Error::Config(format!(
47+
"File index {idx} out of range (torrent has {file_count} files, indices 0-{})",
48+
file_count.saturating_sub(1)
49+
)));
50+
}
51+
}
52+
53+
// Build priority arrays
54+
let (p_high, p_normal, p_low) = match priority {
55+
Some(FilePriority::High) => (priority_indices, None, None),
56+
Some(FilePriority::Normal) => (None, priority_indices, None),
57+
Some(FilePriority::Low) => (None, None, priority_indices),
58+
None => (None, None, None),
59+
};
60+
61+
methods::torrent_set_file_properties(client, id, p_high, p_normal, p_low, unskip, skip)?;
62+
}
63+
64+
// Fetch and display
65+
let (name, files, stats) = methods::torrent_get_files(client, id)?;
1966

2067
if json_output {
21-
json::print_json(&files)
68+
let json_files: Vec<serde_json::Value> = files
69+
.iter()
70+
.enumerate()
71+
.map(|(i, f)| {
72+
let stat = stats.get(i);
73+
serde_json::json!({
74+
"index": i,
75+
"name": f.name,
76+
"length": f.length,
77+
"bytesCompleted": f.bytes_completed,
78+
"priority": table::priority_string(stat.map(|s| s.priority).unwrap_or(0)),
79+
"wanted": stat.map(|s| s.wanted).unwrap_or(true),
80+
})
81+
})
82+
.collect();
83+
json::print_json(&json_files)
2284
} else {
23-
table::print_torrent_files(&name, &files);
85+
table::print_torrent_files(&name, &files, &stats);
2486
Ok(())
2587
}
2688
}

src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ pub mod label;
66
pub mod list;
77
pub mod login;
88
pub mod policy;
9+
pub mod reannounce;
910
pub mod relocate;
1011
pub mod remove;
1112
pub mod search;
13+
pub mod sequential;
1214
pub mod session;
1315
pub mod speed;
1416
pub mod start_stop;

src/commands/reannounce.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use crate::client::TransmissionClient;
2+
use crate::error::Error;
3+
use crate::rpc::methods;
4+
5+
pub fn execute(client: &TransmissionClient, id: i64) -> Result<(), Error> {
6+
methods::torrent_reannounce(client, id)?;
7+
println!("Reannouncing torrent {id}.");
8+
Ok(())
9+
}

src/commands/sequential.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use crate::client::TransmissionClient;
2+
use crate::error::Error;
3+
use crate::output::json as json_output;
4+
use crate::rpc::methods;
5+
6+
pub fn execute(
7+
client: &TransmissionClient,
8+
id: i64,
9+
_on: bool,
10+
off: bool,
11+
json_mode: bool,
12+
) -> Result<(), Error> {
13+
let enable = !off; // default to on when neither flag is set
14+
methods::torrent_set_sequential(client, id, enable)?;
15+
16+
let (name, sequential) = methods::torrent_get_sequential(client, id)?;
17+
18+
match sequential {
19+
Some(state) => {
20+
if json_mode {
21+
json_output::print_json(&serde_json::json!({
22+
"id": id,
23+
"name": name,
24+
"sequential_download": state,
25+
}))
26+
} else {
27+
let status = if state { "Enabled" } else { "Disabled" };
28+
println!("Sequential download for \"{name}\" (ID: {id}): {status}");
29+
Ok(())
30+
}
31+
}
32+
None => Err(Error::Rpc(
33+
"Sequential download not supported (requires Transmission 4.0+)".to_string(),
34+
)),
35+
}
36+
}

src/main.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,21 @@ fn main() {
9393
Command::Remove { id, delete } => commands::remove::execute(&client, *id, *delete),
9494
Command::Verify { id } => commands::start_stop::execute_verify(&client, *id),
9595
Command::Info { id } => commands::info::execute_info(&client, *id, config.json),
96-
Command::Files { id } => commands::info::execute_files(&client, *id, config.json),
96+
Command::Files {
97+
id,
98+
priority,
99+
priority_indices,
100+
skip,
101+
unskip,
102+
} => commands::info::execute_files(
103+
&client,
104+
*id,
105+
priority.as_ref(),
106+
priority_indices.as_deref(),
107+
skip.as_deref(),
108+
unskip.as_deref(),
109+
config.json,
110+
),
97111
Command::Speed {
98112
id,
99113
set_down,
@@ -121,6 +135,10 @@ fn main() {
121135
commands::session::execute_free(&client, path.as_deref(), config.json)
122136
}
123137
Command::Health => commands::health::execute(&client, config.json),
138+
Command::Sequential { id, on, off } => {
139+
commands::sequential::execute(&client, *id, *on, *off, config.json)
140+
}
141+
Command::Reannounce { id } => commands::reannounce::execute(&client, *id),
124142
Command::Tracker { action } => commands::tracker::execute(&client, action, config.json),
125143
Command::Policy { action } => {
126144
commands::policy::execute(&client, action, &config, config.json)

src/output/table.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,13 @@ pub fn print_torrent_detail(t: &TorrentDetail) {
161161
}
162162
}
163163

164-
pub fn print_torrent_files(name: &str, files: &[TorrentFile]) {
164+
pub fn print_torrent_files(name: &str, files: &[TorrentFile], stats: &[TorrentFileStat]) {
165165
println!("Torrent: {name}");
166166
println!();
167167

168168
let mut table = Table::new();
169169
table.set_content_arrangement(ContentArrangement::Dynamic);
170-
table.set_header(vec!["#", "Name", "Size", "Done"]);
170+
table.set_header(vec!["#", "Name", "Size", "Done", "Priority", "Wanted"]);
171171

172172
for (i, f) in files.iter().enumerate() {
173173
let progress = if f.length > 0 {
@@ -179,17 +179,39 @@ pub fn print_torrent_files(name: &str, files: &[TorrentFile]) {
179179
"0%".to_string()
180180
};
181181

182+
let stat = stats.get(i);
183+
let priority = match stat.map(|s| s.priority).unwrap_or(0) {
184+
1 => "High",
185+
-1 => "Low",
186+
_ => "Normal",
187+
};
188+
let wanted = if stat.map(|s| s.wanted).unwrap_or(true) {
189+
"Yes"
190+
} else {
191+
"No"
192+
};
193+
182194
table.add_row(vec![
183195
i.to_string(),
184196
f.name.clone(),
185197
format_size(f.length),
186198
progress,
199+
priority.to_string(),
200+
wanted.to_string(),
187201
]);
188202
}
189203

190204
println!("{table}");
191205
}
192206

207+
pub fn priority_string(priority: i64) -> &'static str {
208+
match priority {
209+
1 => "High",
210+
-1 => "Low",
211+
_ => "Normal",
212+
}
213+
}
214+
193215
pub fn print_session_info(session: &serde_json::Value) {
194216
let get_str = |key: &str| -> String {
195217
session

0 commit comments

Comments
 (0)