Run torrents off the command worker
A torrent ran inline on the single sequential worker, so it blocked every feed scan, HTTP download and status command behind it -- for up to stall_mins waiting on metadata, and for up to seed_time_mins seeding after finishing. A live daemon was wedged with 47 pending torrents and would not answer a status command for 15s. spawn_torrent detaches the job behind a 2-permit semaphore and marks the row 'downloading' so a rescan cannot queue it twice. One-shot CLI runs stay inline, or the process would exit mid-download. Torrents themselves verified working: a 755 MB Debian netinst downloaded to completion against a real swarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
100
src/main.rs
100
src/main.rs
@@ -86,6 +86,13 @@ pub struct Ctx {
|
||||
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
||||
/// rude to do for a config that has never seen a torrent.
|
||||
pub torrents: tokio::sync::OnceCell<torrent::Torrents>,
|
||||
/// Caps how many torrents run at once when they are detached.
|
||||
pub torrent_slots: Arc<tokio::sync::Semaphore>,
|
||||
/// Run torrents off the command worker. A torrent takes minutes to fetch metadata and
|
||||
/// then seeds for up to an hour, and the worker is sequential -- inline, one torrent
|
||||
/// stops every feed scan, every HTTP download and every status command behind it.
|
||||
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
||||
pub detach_torrents: bool,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
@@ -145,30 +152,34 @@ async fn main() -> Result<()> {
|
||||
return ipc::proxy(&cfg.general.socket, cmd).await;
|
||||
}
|
||||
|
||||
let ctx = Ctx {
|
||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||
let (events, _) = broadcast::channel(1024);
|
||||
let ctx = Arc::new(Ctx {
|
||||
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
||||
db,
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?,
|
||||
out: Emitter::terminal(),
|
||||
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
|
||||
torrents: tokio::sync::OnceCell::new(),
|
||||
};
|
||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||
detach_torrents: is_daemon,
|
||||
});
|
||||
|
||||
match cli.command {
|
||||
Command::List => list(&ctx, &config_path),
|
||||
Command::Daemon { web } => daemon(ctx, config_path, web).await,
|
||||
Command::Daemon { web } => daemon(ctx, config_path, web, events).await,
|
||||
Command::Add { url, folder, keywords } => {
|
||||
add(ctx, &config_path, &url, folder, keywords).await
|
||||
add(&ctx, &config_path, &url, folder, keywords).await
|
||||
}
|
||||
Command::Rm { feed } => rm(ctx, &config_path, &feed),
|
||||
Command::Import { file } => import(ctx, &config_path, &file).await,
|
||||
Command::Rm { feed } => rm(&ctx, &config_path, &feed),
|
||||
Command::Import { file } => import(&ctx, &config_path, &file).await,
|
||||
Command::Export { file } => export(&ctx, &file),
|
||||
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
|
||||
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||
match cmd {
|
||||
Cmd::Fetch { feed, force } => {
|
||||
// Make room before pulling more down, as the original did per download.
|
||||
@@ -185,15 +196,18 @@ async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn daemon(ctx: Ctx, config_path: PathBuf, web_addr: Option<String>) -> Result<()> {
|
||||
async fn daemon(
|
||||
ctx: Arc<Ctx>,
|
||||
config_path: PathBuf,
|
||||
web_addr: Option<String>,
|
||||
events: broadcast::Sender<Event>,
|
||||
) -> Result<()> {
|
||||
let socket = ctx.cfg().general.socket.clone();
|
||||
if ipc::daemon_is_live(&socket).await {
|
||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||
}
|
||||
|
||||
let (events, _) = broadcast::channel(1024);
|
||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
||||
let ctx = Arc::new(Ctx { out: Emitter::socket(events.clone(), false), ..ctx });
|
||||
|
||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd));
|
||||
@@ -327,7 +341,7 @@ async fn shutdown() {
|
||||
|
||||
/// Subscribes to one feed, naming it from its own title.
|
||||
async fn add(
|
||||
ctx: Ctx,
|
||||
ctx: &Ctx,
|
||||
config_path: &std::path::Path,
|
||||
url: &str,
|
||||
folder: Option<String>,
|
||||
@@ -337,7 +351,7 @@ async fn add(
|
||||
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == url) {
|
||||
anyhow::bail!("already subscribed as {id:?}");
|
||||
}
|
||||
let id = add_one(&ctx, &mut cfg, url, folder, keywords).await?;
|
||||
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
||||
cfg.save(config_path)?;
|
||||
println!("added {id}");
|
||||
Ok(())
|
||||
@@ -389,7 +403,7 @@ fn url_stem(url: &str) -> String {
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
fn rm(ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||
fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||
let mut cfg = (*ctx.cfg()).clone();
|
||||
if cfg.feeds.remove(feed).is_none() {
|
||||
anyhow::bail!("no feed with id {feed:?}");
|
||||
@@ -400,7 +414,7 @@ fn rm(ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
||||
async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
||||
let mut cfg = (*ctx.cfg()).clone();
|
||||
let text = std::fs::read_to_string(file)
|
||||
.with_context(|| format!("reading {}", file.display()))?;
|
||||
@@ -510,7 +524,7 @@ fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
|
||||
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
if let Some(id) = only
|
||||
&& !cfg.feeds.contains_key(id)
|
||||
@@ -586,7 +600,7 @@ struct Scan {
|
||||
|
||||
/// Ok(None) means 304.
|
||||
async fn scan_one(
|
||||
ctx: &Ctx,
|
||||
ctx: &Arc<Ctx>,
|
||||
id: &str,
|
||||
feed_cfg: &config::Feed,
|
||||
state: &db::HttpState,
|
||||
@@ -652,6 +666,13 @@ async fn scan_one(
|
||||
scan.torrents += 1;
|
||||
continue;
|
||||
}
|
||||
if ctx.detach_torrents {
|
||||
// 'downloading' keeps the next scan from queueing it a second time.
|
||||
ctx.db.mark_enclosure(&item.url, "downloading", None)?;
|
||||
spawn_torrent(ctx, id.to_string(), item.id, item.url.clone(), dest_dir.clone());
|
||||
scan.torrents += 1;
|
||||
continue;
|
||||
}
|
||||
match torrent_one(ctx, id, item.id, &item.url, &dest_dir).await {
|
||||
Ok((path, bytes)) => {
|
||||
ctx.db.mark_downloaded(&item.url, &path, bytes)?;
|
||||
@@ -729,7 +750,7 @@ fn reject(feed_cfg: &config::Feed, entry: &feed::Entry, url: &str) -> Option<&'s
|
||||
}
|
||||
|
||||
async fn fetch_one(
|
||||
ctx: &Ctx,
|
||||
ctx: &Arc<Ctx>,
|
||||
feed_id: &str,
|
||||
enclosure: i64,
|
||||
feed_cfg: &config::Feed,
|
||||
@@ -774,9 +795,43 @@ async fn fetch_one(
|
||||
Ok((path, got.bytes))
|
||||
}
|
||||
|
||||
/// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving
|
||||
/// while it fetches metadata, transfers and then seeds.
|
||||
fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, dest_dir: PathBuf) {
|
||||
let ctx = ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Held for the whole job, so a feed full of torrents cannot open hundreds at once.
|
||||
let _permit = match ctx.torrent_slots.clone().acquire_owned().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let outcome = torrent_one(&ctx, &feed_id, enclosure, &url, &dest_dir).await;
|
||||
let db = &ctx.db;
|
||||
match outcome {
|
||||
Ok((path, bytes)) => {
|
||||
if let Err(e) = db.mark_downloaded(&url, &path, bytes) {
|
||||
tracing::warn!(error = ?e, "could not record the finished torrent");
|
||||
}
|
||||
ctx.out.emit(Event::DownloadDone {
|
||||
feed: feed_id,
|
||||
enclosure,
|
||||
url,
|
||||
path: path.display().to_string(),
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
let _ = db.mark_enclosure(&url, "error", Some(&msg));
|
||||
ctx.out.emit(Event::DownloadError { feed: feed_id, enclosure, url, msg });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Downloads one specific enclosure immediately, whatever the per-scan cap says and
|
||||
/// wherever it sits in the queue.
|
||||
async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
||||
let cfg = ctx.cfg();
|
||||
let enc = ctx
|
||||
.db
|
||||
@@ -796,6 +851,11 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
|
||||
ctx.out.emit(Event::FeedStart { feed: enc.feed_id.clone() });
|
||||
let is_torrent = download::looks_like_torrent(&enc.url, enc.mime.as_deref());
|
||||
if is_torrent && cfg.torrent.enabled && ctx.detach_torrents {
|
||||
ctx.db.mark_enclosure(&enc.url, "downloading", None)?;
|
||||
spawn_torrent(ctx, enc.feed_id.clone(), enc.id, enc.url.clone(), dest_dir);
|
||||
return Ok(());
|
||||
}
|
||||
let result = if is_torrent {
|
||||
if !cfg.torrent.enabled {
|
||||
Err(anyhow::anyhow!("torrents are disabled"))
|
||||
@@ -836,7 +896,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
||||
/// Torrent progress is reported the same way an HTTP download's is, throttled to whole
|
||||
/// percents so a UI is not flooded.
|
||||
async fn torrent_one(
|
||||
ctx: &Ctx,
|
||||
ctx: &Arc<Ctx>,
|
||||
feed_id: &str,
|
||||
enclosure: i64,
|
||||
url: &str,
|
||||
|
||||
Reference in New Issue
Block a user