add Deluge and qBittorrent sections to Settings page

Both torrent clients now configurable from /ui/settings with enabled
toggle, URL, password, poll interval, and labels/categories fields.
Settings persist to DB and apply on next restart.
This commit is contained in:
2026-05-19 21:24:41 -07:00
parent c6b9b676a4
commit 6cb9428567
3 changed files with 175 additions and 0 deletions
+22
View File
@@ -1193,6 +1193,28 @@ pub async fn apply_settings_overrides(pool: &SqlitePool, cfg: &mut crate::config
}
"tmdb.enabled" => cfg.tmdb.enabled = value == "true",
"tmdb.api_key" => cfg.tmdb.api_key = value.clone(),
"deluge.enabled" => cfg.publisher.deluge.enabled = value == "true",
"deluge.url" => cfg.publisher.deluge.url = value.clone(),
"deluge.password" => cfg.publisher.deluge.password = value.clone(),
"deluge.poll_interval_secs" => {
if let Ok(n) = value.parse::<u64>() { cfg.publisher.deluge.poll_interval_secs = n; }
}
"deluge.labels" => {
if let Ok(v) = serde_json::from_str::<Vec<String>>(value) {
cfg.publisher.deluge.labels = v;
}
}
"qbittorrent.url" => cfg.publisher.qbittorrent.url = value.clone(),
"qbittorrent.username" => cfg.publisher.qbittorrent.username = value.clone(),
"qbittorrent.password" => cfg.publisher.qbittorrent.password = value.clone(),
"qbittorrent.poll_interval_secs" => {
if let Ok(n) = value.parse::<u64>() { cfg.publisher.qbittorrent.poll_interval_secs = n; }
}
"qbittorrent.categories" => {
if let Ok(v) = serde_json::from_str::<Vec<String>>(value) {
cfg.publisher.qbittorrent.categories = v;
}
}
_ => {}
}
}
+2
View File
@@ -33,6 +33,8 @@ pub fn router() -> Router<AppState> {
.route("/ui/settings/tmdb", post(settings::save_tmdb))
.route("/ui/settings/apikeys/create", post(settings::create_api_key))
.route("/ui/settings/apikeys/delete", post(settings::delete_api_key))
.route("/ui/settings/deluge", post(settings::save_deluge))
.route("/ui/settings/qbittorrent", post(settings::save_qbittorrent))
.route("/torrent/{info_hash}", get(torrent_blob_handler))
}
+151
View File
@@ -221,6 +221,93 @@ async fn render(state: &AppState, params: &PageParams) -> anyhow::Result<Html<St
api_key = esc(&cfg.tmdb.api_key),
);
// ── Deluge ────────────────────────────────────────────────────────────────
let d = &cfg.publisher.deluge;
let deluge_enabled = if d.enabled { " checked" } else { "" };
let deluge_labels = d.labels.join(", ");
let deluge_section = format!(
r#"<div class="card">
<div class="card-title">Deluge <span class="badge badge-warn" style="font-size:10px;vertical-align:middle">restart required</span></div>
<form method="POST" action="/ui/settings/deluge">
<div class="setting-row">
<label class="setting-label">Enabled</label>
<label class="toggle-label">
<input type="checkbox" name="enabled" value="true"{deluge_enabled}>
Poll Deluge for completed downloads
</label>
</div>
<div class="setting-row">
<label class="setting-label">Web UI URL
<span class="setting-hint">Enable the Web UI plugin in Deluge, then log in once in a browser to set a password. Default port is 8112.</span>
</label>
<input type="text" name="url" value="{d_url}" placeholder="http://127.0.0.1:8112" style="width:280px">
</div>
<div class="setting-row">
<label class="setting-label">Password
<span class="setting-hint">The password you set when you first logged into the Deluge web UI</span>
</label>
<input type="password" name="password" value="{d_pass}" placeholder="••••••••" style="width:220px">
</div>
<div class="setting-row">
<label class="setting-label">Poll interval (seconds)</label>
<input type="number" name="poll_interval_secs" value="{d_poll}" min="10" style="width:100px">
</div>
<div class="setting-row">
<label class="setting-label">Labels to watch
<span class="setting-hint">Comma-separated Deluge Label plugin labels. Leave blank to watch all completed torrents.</span>
</label>
<input type="text" name="labels" value="{deluge_labels}" placeholder="publish-nostr" style="width:320px">
</div>
<div style="margin-top:.75rem">
<button type="submit" class="btn-accent">Save Deluge settings</button>
</div>
</form>
</div>"#,
d_url = esc(&d.url),
d_pass = esc(&d.password),
d_poll = d.poll_interval_secs,
);
// ── qBittorrent ───────────────────────────────────────────────────────────
let qb = &cfg.publisher.qbittorrent;
let qb_cats = qb.categories.join(", ");
let qbittorrent_section = format!(
r#"<div class="card">
<div class="card-title">qBittorrent <span class="badge badge-warn" style="font-size:10px;vertical-align:middle">restart required</span></div>
<form method="POST" action="/ui/settings/qbittorrent">
<div class="setting-row">
<label class="setting-label">Web UI URL</label>
<input type="text" name="url" value="{qb_url}" placeholder="http://127.0.0.1:8080" style="width:280px">
</div>
<div class="setting-row">
<label class="setting-label">Username</label>
<input type="text" name="username" value="{qb_user}" placeholder="admin" style="width:180px">
</div>
<div class="setting-row">
<label class="setting-label">Password</label>
<input type="password" name="password" value="{qb_pass}" placeholder="••••••••" style="width:220px">
</div>
<div class="setting-row">
<label class="setting-label">Poll interval (seconds)</label>
<input type="number" name="poll_interval_secs" value="{qb_poll}" min="10" style="width:100px">
</div>
<div class="setting-row">
<label class="setting-label">Categories to watch
<span class="setting-hint">Comma-separated qBittorrent category names. Leave blank to watch all completed torrents.</span>
</label>
<input type="text" name="categories" value="{qb_cats}" placeholder="publish-nostr" style="width:320px">
</div>
<div style="margin-top:.75rem">
<button type="submit" class="btn-accent">Save qBittorrent settings</button>
</div>
</form>
</div>"#,
qb_url = esc(&qb.url),
qb_user = esc(&qb.username),
qb_pass = esc(&qb.password),
qb_poll = qb.poll_interval_secs,
);
// ── API Keys ──────────────────────────────────────────────────────────────
let key_rows: String = if api_keys.is_empty() {
"<tr><td colspan='4' class='empty'>No API keys yet — create one below</td></tr>".into()
@@ -295,6 +382,11 @@ function copyKey() {
<div class="section-title">API Keys</div>
{apikeys_section}
</div>
<div class="section">
<div class="section-title">Torrent clients</div>
{deluge_section}
{qbittorrent_section}
</div>
<div class="section">
<div class="section-title">Network</div>
{network_section}
@@ -467,6 +559,65 @@ pub async fn delete_api_key(
Redirect::to("/ui/settings?msg=Key+revoked")
}
#[derive(Deserialize)]
pub struct DelugeForm {
#[serde(default)]
pub enabled: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub password: String,
pub poll_interval_secs: u64,
#[serde(default)]
pub labels: String,
}
pub async fn save_deluge(
State(state): State<AppState>,
Form(form): Form<DelugeForm>,
) -> Redirect {
let enabled = form.enabled == "true";
let labels: Vec<String> = form.labels.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let _ = db::set_setting(&state.pool, "deluge.enabled", if enabled { "true" } else { "false" }).await;
let _ = db::set_setting(&state.pool, "deluge.url", &form.url).await;
let _ = db::set_setting(&state.pool, "deluge.password", &form.password).await;
let _ = db::set_setting(&state.pool, "deluge.poll_interval_secs", &form.poll_interval_secs.to_string()).await;
let _ = db::set_setting(&state.pool, "deluge.labels", &serde_json::to_string(&labels).unwrap_or_default()).await;
Redirect::to("/ui/settings?msg=Deluge+settings+saved")
}
#[derive(Deserialize)]
pub struct QbittorrentForm {
#[serde(default)]
pub url: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
pub poll_interval_secs: u64,
#[serde(default)]
pub categories: String,
}
pub async fn save_qbittorrent(
State(state): State<AppState>,
Form(form): Form<QbittorrentForm>,
) -> Redirect {
let categories: Vec<String> = form.categories.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let _ = db::set_setting(&state.pool, "qbittorrent.url", &form.url).await;
let _ = db::set_setting(&state.pool, "qbittorrent.username", &form.username).await;
let _ = db::set_setting(&state.pool, "qbittorrent.password", &form.password).await;
let _ = db::set_setting(&state.pool, "qbittorrent.poll_interval_secs", &form.poll_interval_secs.to_string()).await;
let _ = db::set_setting(&state.pool, "qbittorrent.categories", &serde_json::to_string(&categories).unwrap_or_default()).await;
Redirect::to("/ui/settings?msg=qBittorrent+settings+saved")
}
fn generate_key() -> String {
use rand::Rng;
let bytes: [u8; 32] = rand::thread_rng().gen();