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:
parent
223354eff9
commit
0249ce330d
2 changed files with 130 additions and 9 deletions
|
|
@ -9,4 +9,7 @@
|
||||||
- ✅ Pausing a torrent will go back into Downloading and Seeding.
|
- ✅ Pausing a torrent will go back into Downloading and Seeding.
|
||||||
- ✅ A torrents data could overlap with another existing torrent. This should be blocked to avoid
|
- ✅ 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.
|
- ✅ A paused torrent should still do a full piece check.
|
||||||
- ✅ Something appears to have broken the peers info tab, nothing shows up.
|
- ✅ 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.
|
||||||
|
|
@ -2019,9 +2019,35 @@ static bool rule_matches(json_t *rule, const char *feed_name, const char *title)
|
||||||
return true;
|
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. */
|
/* Run every rule against a freshly-seen article; download the first match. */
|
||||||
static void rss_run_rules(const char *feed_name, const char *title,
|
static void rss_run_rules(const char *feed_name, const char *key,
|
||||||
const char *magnet, const char *torrent_url) {
|
const char *title, const char *magnet,
|
||||||
|
const char *torrent_url) {
|
||||||
size_t i; json_t *rule;
|
size_t i; json_t *rule;
|
||||||
json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false;
|
json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false;
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
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);
|
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||||
if (!fire) return;
|
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");
|
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) {
|
if (!seen) {
|
||||||
json_t *art = json_pack(
|
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,
|
"title", title, "key", key,
|
||||||
"magnet", magnet, "torrentUrl", enclosure,
|
"magnet", magnet, "torrentUrl", enclosure,
|
||||||
"size", (json_int_t)strtoll(lenstr, NULL, 10),
|
"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);
|
json_array_insert_new(articles, 0, art);
|
||||||
added++;
|
added++;
|
||||||
if (out_new)
|
if (out_new)
|
||||||
json_array_append_new(out_new, json_pack(
|
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));
|
"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 */
|
/* fire auto-download rules now that rss_lock is released */
|
||||||
size_t i; json_t *a;
|
size_t i; json_t *a;
|
||||||
json_array_foreach(new_articles, i, 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, "magnet", ""),
|
||||||
json_string_or(a, "torrentUrl", ""));
|
json_string_or(a, "torrentUrl", ""));
|
||||||
json_decref(new_articles);
|
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);
|
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}
|
/* POST /api/rss/download {magnet|torrentUrl, category, savePath, paused}
|
||||||
* Manually grab a torrent from a feed article or search result. Reuses the
|
* 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). */
|
* 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 *url = json_string_or(req, "torrentUrl", "");
|
||||||
const char *cat = json_string_or(req, "category", "");
|
const char *cat = json_string_or(req, "category", "");
|
||||||
const char *path = json_string_or(req, "savePath", "");
|
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 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);
|
json_decref(req);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
json_t *reply = json_pack("{s:b}", "ok", 1);
|
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. */
|
/* POST /api/indexers upserts a Torznab indexer; .../delete removes one. */
|
||||||
static void api_indexer(int fd, const char *body, size_t len, bool remove) {
|
static void api_indexer(int fd, const char *body, size_t len, bool remove) {
|
||||||
json_t *req = read_body_json(body, len);
|
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);
|
api_rss_rule(fd, body, body_len, false);
|
||||||
} else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) {
|
} else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) {
|
||||||
api_rss_rule(fd, body, body_len, true);
|
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) {
|
} else if (strcmp(path, "/api/rss/download") == 0 && strcmp(method, "POST") == 0) {
|
||||||
api_rss_download(fd, body, body_len);
|
api_rss_download(fd, body, body_len);
|
||||||
} else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) {
|
} else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue