Skip to content

Commit 49019a6

Browse files
committed
feat: add incomplete file suffix setting and per-file download progress
Add configurable suffix (e.g. .partial) appended to files while downloading, stripped on completion — lets external tools distinguish incomplete files. Add per-file progress bars to the torrent files table in the UI. Auto-refresh Downloads file list when a torrent finishes. Bump version to 1.0.9.
1 parent 7d83493 commit 49019a6

6 files changed

Lines changed: 173 additions & 6 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
@@ -7,7 +7,7 @@ members = [
77

88
[package]
99
name = "cloud-torrent"
10-
version = "1.0.8"
10+
version = "1.0.9"
1111
edition = "2024"
1212

1313
[dependencies]

common/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ pub struct Config {
144144
#[serde(alias = "rss_url", alias = "rssurl", alias = "RSSURL")]
145145
pub rss_url: String,
146146
pub allow_runtime_configure: bool,
147+
#[serde(default)]
148+
pub incomplete_suffix: String,
147149
}
148150

149151
impl Default for Config {
@@ -177,6 +179,7 @@ impl Default for Config {
177179
scraper_url: "https://raw.githubusercontent.com/OctopusTakopi/cloud-torrent-rs/master/scraper-config.json".to_string(),
178180
rss_url: "".to_string(),
179181
allow_runtime_configure: true,
182+
incomplete_suffix: String::new(),
180183
}
181184
}
182185
}

frontend/src/main.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,20 @@ fn config_panel(props: &ConfigPanelProps) -> Html {
355355
} />
356356
</div>
357357

358+
<div class="field">
359+
<label><h5>{ "Incomplete File Suffix " } <span title="Append this suffix (e.g. .partial) to files while downloading. Removed when complete. Allows external tools to distinguish incomplete files."><i class="question circle icon"></i></span></h5></label>
360+
<input type="text" placeholder=".partial" value={c.incomplete_suffix.clone()} oninput={
361+
let config = config.clone();
362+
Callback::from(move |e: InputEvent| {
363+
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
364+
if let Some(mut c) = (*config).clone() {
365+
c.incomplete_suffix = input.value();
366+
config.set(Some(c));
367+
}
368+
})
369+
} />
370+
</div>
371+
358372
<div class="field">
359373
<label><h5>{ "Scraper URL" }</h5></label>
360374
<input type="text" value={c.scraper_url.clone()} oninput={
@@ -640,6 +654,21 @@ fn app() -> Html {
640654
})
641655
};
642656

657+
{
658+
let finished_count = (*state)
659+
.as_ref()
660+
.map(|gs| gs.torrents.iter().filter(|t| t.is_seeding).count())
661+
.unwrap_or(0);
662+
let downloads_expanded = downloads_expanded.clone();
663+
let refresh_files = refresh_files.clone();
664+
use_effect_with(finished_count, move |_| {
665+
if *downloads_expanded {
666+
refresh_files.emit(());
667+
}
668+
|| ()
669+
});
670+
}
671+
643672
let fetch_rss = {
644673
let rss_loading = rss_loading.clone();
645674
let rss_state = rss_state.clone();
@@ -1485,14 +1514,26 @@ fn app() -> Html {
14851514
</div>
14861515
<table class="ui unstackable compact striped downloads table">
14871516
<thead>
1488-
<tr><th>{ "File" }</th><th>{ "Size" }</th></tr>
1517+
<tr><th>{ "File" }</th><th>{ "Size" }</th><th>{ "Progress" }</th></tr>
14891518
</thead>
14901519
<tbody>
1491-
{ for t.files.iter().map(|f| html! {
1520+
{ for t.files.iter().map(|f| {
1521+
let pct = f.get("Percent").and_then(|v| v.as_f64()).unwrap_or(0.0);
1522+
let downloaded = f.get("Downloaded").and_then(|v| v.as_u64()).unwrap_or(0);
1523+
let size = f.get("Size").and_then(|v| v.as_u64()).unwrap_or(0);
1524+
let is_done = pct >= 100.0;
1525+
html! {
14921526
<tr>
14931527
<td>{ f.get("Path").and_then(|v| v.as_str()).unwrap_or("?") }</td>
1494-
<td>{ format_bytes(f.get("Size").and_then(|v| v.as_u64()).unwrap_or(0)) }</td>
1528+
<td class="right aligned collapsing">{ format_bytes(size) }</td>
1529+
<td class="collapsing" style="min-width: 140px;">
1530+
<div class={classes!("ui", "tiny", if is_done { "green" } else { "blue" }, "progress")} style="margin: 0; position: relative;" title={format!("{} / {}", format_bytes(downloaded), format_bytes(size))}>
1531+
<div class="bar" style={format!("width: {}%; min-width: 0;", if pct < 1.0 && pct > 0.0 { 1.0 } else { pct })}></div>
1532+
<div style="position: absolute; top: 0; left: 0; right: 0; text-align: center; font-size: 0.7em; line-height: 1.5em; color: rgba(0,0,0,0.7); pointer-events: none;">{ format!("{:.1}%", pct) }</div>
1533+
</div>
1534+
</td>
14951535
</tr>
1536+
}
14961537
}) }
14971538
</tbody>
14981539
</table>

src/engine/mod.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ impl Engine {
105105
storage,
106106
};
107107

108+
// Strip incomplete suffix from files before restore so librqbit can find them
109+
{
110+
let state = engine.state.read().await;
111+
let suffix = &state.config.incomplete_suffix;
112+
if !suffix.is_empty() {
113+
let dl_path = Path::new(&state.config.download_directory);
114+
strip_incomplete_suffix_recursive(dl_path, suffix);
115+
}
116+
}
117+
108118
// Background manager loop
109119
let engine_clone = engine.clone();
110120
tokio::spawn(async move {
@@ -119,6 +129,9 @@ impl Engine {
119129

120130
async fn manager_loop(&self) {
121131
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
132+
// Tracks whether each torrent has had the incomplete suffix added (false) or
133+
// stripped on completion (true). Absent = not yet handled.
134+
let mut suffix_state: HashMap<String, bool> = HashMap::new();
122135
loop {
123136
interval.tick().await;
124137
let config = self.get_config().await;
@@ -176,6 +189,44 @@ impl Engine {
176189
}
177190
}
178191

192+
if !config.incomplete_suffix.is_empty() {
193+
let already_done = suffix_state.get(&info_hash).copied();
194+
let needs_action = match (stats.finished, already_done) {
195+
(true, Some(true)) => false, // already stripped
196+
(false, Some(false)) => false, // already suffixed
197+
_ => true,
198+
};
199+
if needs_action {
200+
let metadata_guard = h.metadata.load();
201+
if let Some(meta) = metadata_guard.as_ref() {
202+
let download_dir = Path::new(&config.download_directory);
203+
let output_dir = if meta.file_infos.len() >= 2 {
204+
let name = h.name().unwrap_or_else(|| info_hash.clone());
205+
download_dir.join(name)
206+
} else {
207+
download_dir.to_path_buf()
208+
};
209+
for f in &meta.file_infos {
210+
let rel = f.relative_filename.to_string_lossy();
211+
if stats.finished {
212+
strip_incomplete_suffix(
213+
&output_dir,
214+
&rel,
215+
&config.incomplete_suffix,
216+
);
217+
} else {
218+
add_incomplete_suffix(
219+
&output_dir,
220+
&rel,
221+
&config.incomplete_suffix,
222+
);
223+
}
224+
}
225+
suffix_state.insert(info_hash.clone(), stats.finished);
226+
}
227+
}
228+
}
229+
179230
if !matches!(stats.state, TorrentStatsState::Paused) {
180231
if let Some(remaining_bytes) = remaining_bytes {
181232
if remaining_bytes > available_download_budget {
@@ -781,10 +832,19 @@ impl Engine {
781832
let mut files = vec![];
782833
let metadata_guard = h.metadata.load();
783834
if let Some(meta) = metadata_guard.as_ref() {
784-
for f in &meta.file_infos {
835+
let file_progress = &stats.file_progress;
836+
for (i, f) in meta.file_infos.iter().enumerate() {
837+
let have_bytes = file_progress.get(i).copied().unwrap_or(0);
838+
let pct = if f.len > 0 {
839+
(have_bytes as f64 / f.len as f64) * 100.0
840+
} else {
841+
100.0
842+
};
785843
files.push(serde_json::json!({
786844
"Path": f.relative_filename.to_string_lossy(),
787845
"Size": f.len,
846+
"Downloaded": have_bytes,
847+
"Percent": (pct * 10.0).round() / 10.0,
788848
}));
789849
}
790850
}

src/engine/utils.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,66 @@ fn format_bytes_for_error(bytes: u64) -> String {
147147
}
148148
format!("{value:.2} {}", units[unit])
149149
}
150+
151+
pub fn strip_incomplete_suffix_recursive(dir: &Path, suffix: &str) {
152+
let Ok(entries) = std::fs::read_dir(dir) else {
153+
return;
154+
};
155+
for entry in entries.flatten() {
156+
let path = entry.path();
157+
if path.is_dir() {
158+
strip_incomplete_suffix_recursive(&path, suffix);
159+
} else if let Some(name) = path.file_name().and_then(|n| n.to_str())
160+
&& let Some(base) = name.strip_suffix(suffix)
161+
{
162+
let dest = path.with_file_name(base);
163+
if !dest.exists() {
164+
if let Err(e) = std::fs::rename(&path, &dest) {
165+
tracing::error!(
166+
"Failed to strip incomplete suffix from {}: {}",
167+
path.display(),
168+
e
169+
);
170+
} else {
171+
tracing::info!(
172+
"Stripped incomplete suffix: {} -> {}",
173+
path.display(),
174+
dest.display()
175+
);
176+
}
177+
}
178+
}
179+
}
180+
}
181+
182+
pub fn add_incomplete_suffix(download_dir: &Path, relative_path: &str, suffix: &str) {
183+
let src = download_dir.join(relative_path);
184+
if src.exists() {
185+
let dest_name = format!("{}{}", relative_path, suffix);
186+
let dest = download_dir.join(&dest_name);
187+
if !dest.exists()
188+
&& let Err(e) = std::fs::rename(&src, &dest)
189+
{
190+
tracing::error!(
191+
"Failed to add incomplete suffix to {}: {}",
192+
src.display(),
193+
e
194+
);
195+
}
196+
}
197+
}
198+
199+
pub fn strip_incomplete_suffix(download_dir: &Path, relative_path: &str, suffix: &str) {
200+
let suffixed_name = format!("{}{}", relative_path, suffix);
201+
let src = download_dir.join(&suffixed_name);
202+
if src.exists() {
203+
let dest = download_dir.join(relative_path);
204+
if let Err(e) = std::fs::rename(&src, &dest) {
205+
tracing::error!(
206+
"Failed to strip incomplete suffix from {}: {}",
207+
src.display(),
208+
e
209+
);
210+
}
211+
}
212+
}

0 commit comments

Comments
 (0)