Step 5: quota and age retention

Oldest-first reaper with the original's 50 MB headroom pad, plus a
reconcile pass for files deleted by hand and pruning of stale entries.

The Python meant to reap only read, unflagged episodes but a missing
import and a typo made that filter throw on every candidate. Requiring
read=1 would be equally dead headless, so flagged is the keep-forever
marker and read only decides ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-09 20:41:46 +00:00
parent a8e1f474a6
commit 21d32104fe
4 changed files with 345 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ mod config;
mod db;
mod download;
mod feed;
mod retention;
use anyhow::Result;
use clap::{Parser, Subcommand};
@@ -22,6 +23,12 @@ struct Cli {
enum Command {
/// Show configured feeds and their state
List,
/// Delete old or over-quota downloads
Reap {
/// Show what would go, delete nothing
#[arg(long)]
dry_run: bool,
},
/// Scan feeds for new entries
Fetch {
/// Only this feed id
@@ -41,7 +48,15 @@ async fn main() -> Result<()> {
match cli.command {
Command::List => list(&cfg, &db, &config_path),
Command::Fetch { feed, force } => fetch(&cfg, &db, feed.as_deref(), force).await,
Command::Fetch { feed, force } => {
// Make room before pulling more down, as the original did per download.
report_reap(&retention::run(&cfg, &db, false)?, false);
fetch(&cfg, &db, feed.as_deref(), force).await
}
Command::Reap { dry_run } => {
report_reap(&retention::run(&cfg, &db, dry_run)?, true);
Ok(())
}
}
}
@@ -254,6 +269,24 @@ async fn fetch_one(
Ok(path)
}
fn report_reap(r: &retention::Report, verbose: bool) {
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
println!("reap {} ({:.1} MB)", c.path, c.bytes.max(0) as f64 / 1_048_576.0);
}
if r.reconciled > 0 {
println!("{} row(s) pointed at files that were already gone", r.reconciled);
}
let total = r.aged_out.len() + r.over_quota.len();
if total > 0 || verbose {
println!(
"reaped {total} file(s), {:.1} MB, {} stale entr{} pruned",
r.bytes_freed as f64 / 1_048_576.0,
r.entries_pruned,
if r.entries_pruned == 1 { "y" } else { "ies" }
);
}
}
fn ago(t: Option<i64>) -> String {
let Some(t) = t else { return "never".into() };
format!("{} ago", duration((db::now() - t).max(0) as u64))