webui: RSS manual download, repull, and rule re-run (#13–#15)

- Articles now carry a `grabbed` flag; manually downloading one (or an
  auto/forced rule grab) marks it, persisted across restarts, so the UI
  shows it as added and re-runs skip it.
- POST /api/rss/refresh {name?} synchronously re-polls one feed (or all)
  for an on-demand repull.
- POST /api/rss/rules/run {name} re-applies a rule to every existing
  article (not just newly-seen ones), grabbing ungrabbed matches —
  useful after editing a rule. Returns matched/grabbed counts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 01:45:49 -04:00
parent 223354eff9
commit 0249ce330d
2 changed files with 130 additions and 9 deletions

View file

@ -10,3 +10,6 @@
- ✅ A torrents data could overlap with another existing torrent. This should be blocked to avoid
- ✅ A paused torrent should still do a full piece check.
- ✅ Something appears to have broken the peers info tab, nothing shows up.
- ✅ RSS should have a manual download button
- ✅ RSS should have a manual repull
- ✅ I should be able to force re-run a rule for cases where it was modified.

View file

@ -2019,9 +2019,35 @@ static bool rule_matches(json_t *rule, const char *feed_name, const char *title)
return true;
}
/* Mark the article with this key as grabbed (across all feeds), so an
* auto-download rule re-run won't fetch it again. */
static void rss_mark_grabbed(const char *key) {
if (!key || !*key) return;
pthread_mutex_lock(&g_webui.rss_lock);
size_t fi; json_t *feed;
json_array_foreach(g_webui.rss_feeds, fi, feed) {
json_t *articles = json_object_get(feed, "articles");
size_t ai; json_t *a;
json_array_foreach(articles, ai, a)
if (strcmp(json_string_or(a, "key", ""), key) == 0)
json_object_set_new(a, "grabbed", json_true());
}
pthread_mutex_unlock(&g_webui.rss_lock);
}
/* Download an article and, on success, flag it grabbed by key. */
static bool rss_grab_article(const char *key, const char *magnet,
const char *torrent_url, const char *cat,
const char *path, bool paused) {
bool ok = rss_download(magnet, torrent_url, cat, path, paused);
if (ok) rss_mark_grabbed(key);
return ok;
}
/* Run every rule against a freshly-seen article; download the first match. */
static void rss_run_rules(const char *feed_name, const char *title,
const char *magnet, const char *torrent_url) {
static void rss_run_rules(const char *feed_name, const char *key,
const char *title, const char *magnet,
const char *torrent_url) {
size_t i; json_t *rule;
json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false;
pthread_mutex_lock(&g_webui.rss_lock);
@ -2037,7 +2063,7 @@ static void rss_run_rules(const char *feed_name, const char *title,
}
pthread_mutex_unlock(&g_webui.rss_lock);
if (!fire) return;
if (rss_download(magnet, torrent_url, cat, path, paused))
if (rss_grab_article(key, magnet, torrent_url, cat, path, paused))
log_msg(2, "rss: auto-downloaded a match");
}
@ -2086,16 +2112,16 @@ static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new
}
if (!seen) {
json_t *art = json_pack(
"{s:s,s:s,s:s,s:s,s:I,s:s,s:b}",
"{s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}",
"title", title, "key", key,
"magnet", magnet, "torrentUrl", enclosure,
"size", (json_int_t)strtoll(lenstr, NULL, 10),
"pubDate", pub, "isRead", 0);
"pubDate", pub, "isRead", 0, "grabbed", 0);
json_array_insert_new(articles, 0, art);
added++;
if (out_new)
json_array_append_new(out_new, json_pack(
"{s:s,s:s,s:s}", "title", title,
"{s:s,s:s,s:s,s:s}", "title", title, "key", key,
"magnet", magnet, "torrentUrl", enclosure));
}
}
@ -2134,7 +2160,8 @@ static void rss_poll_feed_by_index(size_t idx) {
/* fire auto-download rules now that rss_lock is released */
size_t i; json_t *a;
json_array_foreach(new_articles, i, a)
rss_run_rules(feed_name, json_string_or(a, "title", ""),
rss_run_rules(feed_name, json_string_or(a, "key", ""),
json_string_or(a, "title", ""),
json_string_or(a, "magnet", ""),
json_string_or(a, "torrentUrl", ""));
json_decref(new_articles);
@ -2263,6 +2290,66 @@ static void api_rss_rule(int fd, const char *body, size_t len, bool remove) {
api_rss_rules_list(fd);
}
/* POST /api/rss/rules/run {name} — re-apply a rule to every article already in
* the feeds (not just newly-seen ones), downloading matches not yet grabbed.
* Used after editing a rule. Runs regardless of the rule's enabled flag. */
static void api_rss_rule_run(int fd, const char *body, size_t len) {
json_t *req = read_body_json(body, len);
const char *name = json_string_value(json_object_get(req, "name"));
char cat[128] = {0}, path[1024] = {0}; bool paused = false;
json_t *todo = json_array(); /* {key,magnet,torrentUrl} to grab */
pthread_mutex_lock(&g_webui.rss_lock);
json_t *rule = NULL; size_t i; json_t *r;
if (name) json_array_foreach(g_webui.rss_rules, i, r)
if (strcmp(json_string_or(r, "name", ""), name) == 0) { rule = r; break; }
if (rule) {
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", ""));
paused = json_boolean_value(json_object_get(rule, "addPaused"));
/* match regardless of the enabled flag (explicit manual run) */
json_t *probe = json_deep_copy(rule);
json_object_set_new(probe, "enabled", json_true());
size_t fi; json_t *feed;
json_array_foreach(g_webui.rss_feeds, fi, feed) {
const char *fname = json_string_or(feed, "name", "");
json_t *articles = json_object_get(feed, "articles");
size_t ai; json_t *a;
json_array_foreach(articles, ai, a) {
if (json_boolean_value(json_object_get(a, "grabbed"))) continue;
const char *mag = json_string_or(a, "magnet", "");
const char *url = json_string_or(a, "torrentUrl", "");
if (!*mag && !*url) continue;
if (rule_matches(probe, fname, json_string_or(a, "title", "")))
json_array_append_new(todo, json_pack("{s:s,s:s,s:s}",
"key", json_string_or(a, "key", ""), "magnet", mag, "torrentUrl", url));
}
}
json_decref(probe);
if (json_array_size(todo))
json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL)));
}
bool found = rule != NULL;
pthread_mutex_unlock(&g_webui.rss_lock);
json_decref(req);
int grabbed = 0;
size_t j; json_t *t;
json_array_foreach(todo, j, t)
if (rss_grab_article(json_string_or(t, "key", ""), json_string_or(t, "magnet", ""),
json_string_or(t, "torrentUrl", ""), cat, path, paused))
grabbed++;
size_t matched = json_array_size(todo);
json_decref(todo);
if (grabbed > 0) rss_save();
if (!found) { http_text(fd, 404, "Not Found", "no such rule"); return; }
json_t *reply = json_pack("{s:b,s:i,s:i}", "ok", 1,
"matched", (int)matched, "grabbed", grabbed);
http_json(fd, 200, reply);
json_decref(reply);
}
/* POST /api/rss/download {magnet|torrentUrl, category, savePath, paused}
* Manually grab a torrent from a feed article or search result. Reuses the
* same add path as the auto-downloader (handles magnets and .torrent URLs). */
@ -2272,8 +2359,10 @@ static void api_rss_download(int fd, const char *body, size_t len) {
const char *url = json_string_or(req, "torrentUrl", "");
const char *cat = json_string_or(req, "category", "");
const char *path = json_string_or(req, "savePath", "");
const char *key = json_string_or(req, "key", "");
bool paused = json_boolean_value(json_object_get(req, "paused"));
bool ok = rss_download(magnet, url, cat, path, paused);
bool ok = rss_grab_article(key, magnet, url, cat, path, paused);
if (ok && *key) rss_save(); /* persist the grabbed flag */
json_decref(req);
if (ok) {
json_t *reply = json_pack("{s:b}", "ok", 1);
@ -2284,6 +2373,31 @@ static void api_rss_download(int fd, const char *body, size_t len) {
}
}
/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds), running
* the network fetch synchronously so the response reflects fresh articles. */
static void api_rss_refresh(int fd, const char *body, size_t len) {
json_t *req = read_body_json(body, len);
const char *name = json_string_value(json_object_get(req, "name"));
/* find matching index(es) under the lock, then poll outside it */
pthread_mutex_lock(&g_webui.rss_lock);
size_t n = json_array_size(g_webui.rss_feeds);
int target = -1;
if (name && *name) {
size_t i; json_t *fd_j;
json_array_foreach(g_webui.rss_feeds, i, fd_j)
if (strcmp(json_string_or(fd_j, "name", ""), name) == 0) { target = (int)i; break; }
}
pthread_mutex_unlock(&g_webui.rss_lock);
json_decref(req);
if (target >= 0) {
rss_poll_feed_by_index((size_t)target);
} else if (!name || !*name) {
for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++)
rss_poll_feed_by_index(i);
}
api_rss_list(fd);
}
/* POST /api/indexers upserts a Torznab indexer; .../delete removes one. */
static void api_indexer(int fd, const char *body, size_t len, bool remove) {
json_t *req = read_body_json(body, len);
@ -2650,6 +2764,10 @@ static void handle_api(int fd, const char *method, char *path,
api_rss_rule(fd, body, body_len, false);
} else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) {
api_rss_rule(fd, body, body_len, true);
} else if (strcmp(path, "/api/rss/rules/run") == 0 && strcmp(method, "POST") == 0) {
api_rss_rule_run(fd, body, body_len);
} else if (strcmp(path, "/api/rss/refresh") == 0 && strcmp(method, "POST") == 0) {
api_rss_refresh(fd, body, body_len);
} else if (strcmp(path, "/api/rss/download") == 0 && strcmp(method, "POST") == 0) {
api_rss_download(fd, body, body_len);
} else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) {