webui: normalize the RSS schema (articles + rule_feeds tables)
Replace the JSON-blob columns (feeds.articles, rules.affected_feeds) with proper relational tables: - articles(feed,key,…,is_read,grabbed) with UNIQUE(feed,key) and indexes, so dedup and grabbed become INSERT OR IGNORE / WHERE-key queries and a poll inserts only new rows instead of rewriting the whole feed blob. - rule_feeds(rule,feed) join table for a rule's feed scope. The webui RSS engine now operates on rows via a row-level store API (feed/article/rule/indexer upsert/list/etc.) instead of holding the feeds/rules/indexers in memory and saving whole lists; the in-memory copies and rss_save/rss_load are gone. The poller, the auto-download rules, force-run, manual download, refresh and search all read/write the DB directly. A one-time migration upgrades an existing webui.db in place (moving the old JSON blobs into the new tables, preserving article read/grabbed flags and rule scoping, then dropping the legacy columns). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
91d4b99aee
commit
a0265cc1ea
3 changed files with 700 additions and 354 deletions
|
|
@ -95,12 +95,10 @@ typedef struct {
|
|||
json_t *tags; /* array of tag name strings */
|
||||
json_t *assignments; /* object keyed by stringified torrent id */
|
||||
|
||||
/* RSS: feeds + auto-download rules, polled by a background thread and
|
||||
* persisted via the daemon blob store. Search indexers live here too. */
|
||||
/* RSS feeds, articles, auto-download rules and Torznab indexers all live in
|
||||
* the webui database (g_webui.store). A background thread polls feeds; this
|
||||
* lock/cond only guards the poller's wake-up, not any data. */
|
||||
pthread_mutex_t rss_lock;
|
||||
json_t *rss_feeds; /* array of {name,url,lastUpdate,articles:[...]} */
|
||||
json_t *rss_rules; /* array of rule objects */
|
||||
json_t *indexers; /* array of {name,url,apikey,enabled} (Torznab) */
|
||||
pthread_t rss_thread;
|
||||
bool rss_thread_started;
|
||||
pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */
|
||||
|
|
@ -1362,15 +1360,7 @@ static void api_meta(int fd) {
|
|||
json_object_set_new(json, "preferences", preferences);
|
||||
/* searchPlugins mirrors the configured Torznab indexers for the Search tab. */
|
||||
json_t *plugins = json_array();
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
size_t ii; json_t *ix;
|
||||
json_array_foreach(g_webui.indexers, ii, ix)
|
||||
json_array_append_new(plugins, json_pack("{s:s,s:s,s:s,s:b}",
|
||||
"name", json_string_or(ix, "name", ""),
|
||||
"url", json_string_or(ix, "url", ""),
|
||||
"apikey", json_string_or(ix, "apikey", ""),
|
||||
"enabled", json_boolean_value(json_object_get(ix, "enabled"))));
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
if (g_webui.store) webui_store_indexer_list(g_webui.store, plugins);
|
||||
json_object_set_new(json, "searchPlugins", plugins);
|
||||
http_json(fd, 200, json);
|
||||
json_decref(json);
|
||||
|
|
@ -1950,32 +1940,6 @@ static char *base64_encode(const unsigned char *in, size_t len) {
|
|||
|
||||
/* --- RSS persistence (via the daemon blob store) -------------------------- */
|
||||
|
||||
/* Persist feeds/rules/indexers to the web-UI's own database. */
|
||||
static void rss_save(void) {
|
||||
if (!g_webui.store) return;
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
json_t *feeds = json_deep_copy(g_webui.rss_feeds);
|
||||
json_t *rules = json_deep_copy(g_webui.rss_rules);
|
||||
json_t *idx = json_deep_copy(g_webui.indexers);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
if (feeds) { webui_store_save_feeds(g_webui.store, feeds); json_decref(feeds); }
|
||||
if (rules) { webui_store_save_rules(g_webui.store, rules); json_decref(rules); }
|
||||
if (idx) { webui_store_save_indexers(g_webui.store, idx); json_decref(idx); }
|
||||
}
|
||||
|
||||
static void rss_load(void) {
|
||||
if (!g_webui.store) return;
|
||||
json_t *feeds = json_array(), *rules = json_array(), *idx = json_array();
|
||||
bool of = webui_store_load_feeds(g_webui.store, feeds);
|
||||
bool orr = webui_store_load_rules(g_webui.store, rules);
|
||||
bool oi = webui_store_load_indexers(g_webui.store, idx);
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
if (of) { json_decref(g_webui.rss_feeds); g_webui.rss_feeds = feeds; } else json_decref(feeds);
|
||||
if (orr) { json_decref(g_webui.rss_rules); g_webui.rss_rules = rules; } else json_decref(rules);
|
||||
if (oi) { json_decref(g_webui.indexers); g_webui.indexers = idx; } else json_decref(idx);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
}
|
||||
|
||||
/* --- auto-download: hand a matched article to the daemon ------------------ */
|
||||
|
||||
/* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and
|
||||
|
|
@ -2061,20 +2025,9 @@ 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. */
|
||||
/* Mark every article with this key as grabbed, so a rule re-run skips it. */
|
||||
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);
|
||||
if (g_webui.store) webui_store_article_mark_grabbed(g_webui.store, key);
|
||||
}
|
||||
|
||||
/* Download an article and, on success, flag it grabbed by key. */
|
||||
|
|
@ -2086,40 +2039,40 @@ static bool rss_grab_article(const char *key, const char *title,
|
|||
return ok;
|
||||
}
|
||||
|
||||
/* Run every rule against a freshly-seen article; download the first match. */
|
||||
/* Run the auto-download rules against one freshly-seen article; download the
|
||||
* first enabled rule that matches. */
|
||||
static void rss_run_rules(const char *feed_name, const char *key,
|
||||
const char *title, const char *magnet,
|
||||
const char *torrent_url) {
|
||||
if (!g_webui.store) return;
|
||||
json_t *rules = json_array();
|
||||
if (!webui_store_rule_list(g_webui.store, rules)) { json_decref(rules); return; }
|
||||
char cat[128] = {0}, path[1024] = {0}, rule_name[128] = {0};
|
||||
bool paused = false, fire = false;
|
||||
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);
|
||||
json_array_foreach(g_webui.rss_rules, i, rule) {
|
||||
json_array_foreach(rules, i, rule) {
|
||||
if (rule_matches(rule, feed_name, title)) {
|
||||
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
|
||||
snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", ""));
|
||||
snprintf(rule_name, sizeof rule_name, "%s", json_string_or(rule, "name", ""));
|
||||
paused = json_boolean_value(json_object_get(rule, "addPaused"));
|
||||
json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL)));
|
||||
fire = rule;
|
||||
fire = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
json_decref(rules);
|
||||
if (!fire) return;
|
||||
webui_store_rule_set_match(g_webui.store, rule_name, (long)time(NULL));
|
||||
if (rss_grab_article(key, title, magnet, torrent_url, cat, path, paused))
|
||||
log_msg(2, "rss: auto-downloaded a match");
|
||||
}
|
||||
|
||||
/* Parse a feed body into article objects and merge new ones into `feed`.
|
||||
* Newly-seen articles are appended to `out_new` (as {title,magnet,torrentUrl})
|
||||
* so the caller can fire auto-download rules AFTER releasing rss_lock — running
|
||||
* them here would re-enter the lock (and do network I/O while holding it).
|
||||
* Returns the number of newly-seen articles. */
|
||||
static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new) {
|
||||
json_t *articles = json_object_get(feed, "articles");
|
||||
if (!json_is_array(articles)) {
|
||||
articles = json_array();
|
||||
json_object_set_new(feed, "articles", articles);
|
||||
}
|
||||
/* Parse a feed body, inserting newly-seen articles into the store. Each new
|
||||
* article is appended to out_new ({key,title,magnet,torrentUrl}) so the caller
|
||||
* can fire rules afterward. Returns the number newly inserted. */
|
||||
static int rss_ingest(const char *feed_name, const char *xml, size_t len,
|
||||
json_t *out_new) {
|
||||
if (!g_webui.store) return 0;
|
||||
int added = 0;
|
||||
const char *p = xml, *end = xml + len;
|
||||
for (;;) {
|
||||
|
|
@ -2154,80 +2107,65 @@ static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new
|
|||
|
||||
const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link);
|
||||
if (title[0] && key && *key) {
|
||||
/* dedupe against existing articles by their key */
|
||||
bool seen = false; size_t ai; json_t *a;
|
||||
json_array_foreach(articles, ai, a) {
|
||||
if (strcmp(json_string_or(a, "key", ""), key) == 0) { seen = true; break; }
|
||||
}
|
||||
if (!seen) {
|
||||
json_t *art = json_pack(
|
||||
"{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}",
|
||||
"title", title, "key", key,
|
||||
"magnet", magnet, "torrentUrl", dl_url, "link", link,
|
||||
"size", (json_int_t)strtoll(lenstr, NULL, 10),
|
||||
"pubDate", pub, "isRead", 0, "grabbed", 0);
|
||||
json_array_insert_new(articles, 0, art);
|
||||
json_t *art = json_pack("{s:s,s:s,s:s,s:s,s:s,s:I,s:s}",
|
||||
"key", key, "title", title, "magnet", magnet, "torrentUrl", dl_url,
|
||||
"link", link, "size", (json_int_t)strtoll(lenstr, NULL, 10),
|
||||
"pubDate", pub);
|
||||
int rc = art ? webui_store_article_add(g_webui.store, feed_name, art) : -1;
|
||||
json_decref(art);
|
||||
if (rc == 1) {
|
||||
added++;
|
||||
if (out_new)
|
||||
json_array_append_new(out_new, json_pack(
|
||||
"{s:s,s:s,s:s,s:s}", "title", title, "key", key,
|
||||
"magnet", magnet, "torrentUrl", dl_url));
|
||||
json_array_append_new(out_new, json_pack("{s:s,s:s,s:s,s:s}",
|
||||
"key", key, "title", title, "magnet", magnet,
|
||||
"torrentUrl", dl_url));
|
||||
}
|
||||
}
|
||||
p = close + strlen(close_tag);
|
||||
}
|
||||
/* trim to the newest RSS_MAX_ARTICLES */
|
||||
while (json_array_size(articles) > RSS_MAX_ARTICLES)
|
||||
json_array_remove(articles, json_array_size(articles) - 1);
|
||||
json_object_set_new(feed, "lastUpdate", json_integer((json_int_t)time(NULL)));
|
||||
webui_store_article_trim(g_webui.store, feed_name, RSS_MAX_ARTICLES);
|
||||
webui_store_feed_set_updated(g_webui.store, feed_name, (long)time(NULL));
|
||||
return added;
|
||||
}
|
||||
|
||||
/* Poll one feed (network I/O done without rss_lock held). */
|
||||
static void rss_poll_feed_by_index(size_t idx) {
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
json_t *feed = json_array_get(g_webui.rss_feeds, idx);
|
||||
char url[1024] = {0};
|
||||
if (feed) snprintf(url, sizeof url, "%s", json_string_or(feed, "url", ""));
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
if (!url[0]) return;
|
||||
|
||||
/* Poll one feed by name+url (network I/O done without any lock held). */
|
||||
static void rss_poll_one(const char *name, const char *url) {
|
||||
if (!name || !*name || !url || !*url) return;
|
||||
naut_http_response r;
|
||||
if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) {
|
||||
naut_http_response_free(&r);
|
||||
return;
|
||||
}
|
||||
char feed_name[256] = {0};
|
||||
json_t *new_articles = json_array();
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
feed = json_array_get(g_webui.rss_feeds, idx); /* re-fetch under lock */
|
||||
int added = feed ? rss_ingest(feed, r.body, r.body_len, new_articles) : 0;
|
||||
if (feed) snprintf(feed_name, sizeof feed_name, "%s", json_string_or(feed, "name", ""));
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
rss_ingest(name, r.body, r.body_len, new_articles);
|
||||
naut_http_response_free(&r);
|
||||
|
||||
/* fire auto-download rules now that rss_lock is released */
|
||||
/* fire auto-download rules for the newly-seen articles */
|
||||
size_t i; json_t *a;
|
||||
json_array_foreach(new_articles, i, a)
|
||||
rss_run_rules(feed_name, json_string_or(a, "key", ""),
|
||||
rss_run_rules(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);
|
||||
if (added > 0) rss_save();
|
||||
}
|
||||
|
||||
static void rss_poll_all(void) {
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
size_t n = json_array_size(g_webui.rss_feeds);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++)
|
||||
rss_poll_feed_by_index(i);
|
||||
if (!g_webui.store) return;
|
||||
json_t *targets = json_array();
|
||||
webui_store_feed_targets(g_webui.store, targets);
|
||||
size_t i; json_t *t;
|
||||
json_array_foreach(targets, i, t) {
|
||||
if (atomic_load(&g_webui.stopping)) break;
|
||||
char name[256], url[1024];
|
||||
snprintf(name, sizeof name, "%s", json_string_or(t, "name", ""));
|
||||
snprintf(url, sizeof url, "%s", json_string_or(t, "url", ""));
|
||||
rss_poll_one(name, url);
|
||||
}
|
||||
json_decref(targets);
|
||||
}
|
||||
|
||||
static void *rss_thread_fn(void *arg) {
|
||||
(void)arg;
|
||||
rss_load();
|
||||
while (!atomic_load(&g_webui.stopping)) {
|
||||
rss_poll_all();
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
|
|
@ -2243,15 +2181,21 @@ static void *rss_thread_fn(void *arg) {
|
|||
return NULL;
|
||||
}
|
||||
|
||||
static void rss_signal_wake(void) {
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
g_webui.rss_wake = true;
|
||||
pthread_cond_signal(&g_webui.rss_cond);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
}
|
||||
|
||||
/* --- RSS HTTP API --------------------------------------------------------- */
|
||||
|
||||
/* GET /api/rss → array of feeds (with their articles). */
|
||||
static void api_rss_list(int fd) {
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
json_t *reply = json_deep_copy(g_webui.rss_feeds);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
http_json(fd, 200, reply ? reply : json_array());
|
||||
json_decref(reply);
|
||||
json_t *feeds = json_array();
|
||||
if (g_webui.store) webui_store_feed_list(g_webui.store, feeds);
|
||||
http_json(fd, 200, feeds);
|
||||
json_decref(feeds);
|
||||
}
|
||||
|
||||
/* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */
|
||||
|
|
@ -2259,62 +2203,33 @@ static void api_rss_feed(int fd, const char *body, size_t len, bool remove) {
|
|||
json_t *req = read_body_json(body, len);
|
||||
const char *name = json_string_value(json_object_get(req, "name"));
|
||||
const char *url = json_string_value(json_object_get(req, "url"));
|
||||
bool changed = false;
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
if (remove && name) {
|
||||
size_t i; json_t *f;
|
||||
json_array_foreach(g_webui.rss_feeds, i, f)
|
||||
if (strcmp(json_string_or(f, "name", ""), name) == 0) {
|
||||
json_array_remove(g_webui.rss_feeds, i); changed = true; break;
|
||||
}
|
||||
} else if (name && *name && url && *url) {
|
||||
/* upsert by name */
|
||||
size_t i; json_t *f; bool found = false;
|
||||
json_array_foreach(g_webui.rss_feeds, i, f)
|
||||
if (strcmp(json_string_or(f, "name", ""), name) == 0) {
|
||||
json_object_set_new(f, "url", json_string(url)); found = true; break;
|
||||
}
|
||||
if (!found)
|
||||
json_array_append_new(g_webui.rss_feeds, json_pack(
|
||||
"{s:s,s:s,s:i,s:[]}", "name", name, "url", url,
|
||||
"lastUpdate", 0, "articles"));
|
||||
changed = true;
|
||||
bool added = false;
|
||||
if (g_webui.store && name && *name) {
|
||||
if (remove) webui_store_feed_remove(g_webui.store, name);
|
||||
else if (url && *url) added = webui_store_feed_upsert(g_webui.store, name, url);
|
||||
}
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
json_decref(req);
|
||||
if (changed) {
|
||||
rss_save();
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
g_webui.rss_wake = true;
|
||||
pthread_cond_signal(&g_webui.rss_cond); /* re-poll the new feed now */
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
}
|
||||
if (added) rss_signal_wake(); /* re-poll the new feed now */
|
||||
api_rss_list(fd);
|
||||
}
|
||||
|
||||
/* GET /api/rss/rules → array of rules. */
|
||||
static void api_rss_rules_list(int fd) {
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
json_t *reply = json_deep_copy(g_webui.rss_rules);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
http_json(fd, 200, reply ? reply : json_array());
|
||||
json_decref(reply);
|
||||
json_t *rules = json_array();
|
||||
if (g_webui.store) webui_store_rule_list(g_webui.store, rules);
|
||||
http_json(fd, 200, rules);
|
||||
json_decref(rules);
|
||||
}
|
||||
|
||||
/* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */
|
||||
static void api_rss_rule(int fd, const char *body, size_t len, bool remove) {
|
||||
json_t *req = read_body_json(body, len);
|
||||
const char *name = json_string_value(json_object_get(req, "name"));
|
||||
bool changed = false;
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
if (name && *name) {
|
||||
size_t i; json_t *r; int at = -1;
|
||||
json_array_foreach(g_webui.rss_rules, i, r)
|
||||
if (strcmp(json_string_or(r, "name", ""), name) == 0) { at = (int)i; break; }
|
||||
if (g_webui.store && name && *name) {
|
||||
if (remove) {
|
||||
if (at >= 0) { json_array_remove(g_webui.rss_rules, (size_t)at); changed = true; }
|
||||
webui_store_rule_remove(g_webui.store, name);
|
||||
} else {
|
||||
json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:O,s:i}",
|
||||
json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:i}",
|
||||
"name", name,
|
||||
"enabled", json_boolean_value(json_object_get(req, "enabled")),
|
||||
"useRegex", json_boolean_value(json_object_get(req, "useRegex")),
|
||||
|
|
@ -2323,88 +2238,66 @@ static void api_rss_rule(int fd, const char *body, size_t len, bool remove) {
|
|||
"mustNotContain", json_string_or(req, "mustNotContain", ""),
|
||||
"assignedCategory", json_string_or(req, "assignedCategory", ""),
|
||||
"savePath", json_string_or(req, "savePath", ""),
|
||||
"affectedFeeds", json_is_array(json_object_get(req, "affectedFeeds"))
|
||||
? json_object_get(req, "affectedFeeds") : json_array(),
|
||||
"lastMatch", 0);
|
||||
if (rule) {
|
||||
if (at >= 0) json_array_set_new(g_webui.rss_rules, (size_t)at, rule);
|
||||
else json_array_append_new(g_webui.rss_rules, rule);
|
||||
changed = true;
|
||||
json_t *af = json_object_get(req, "affectedFeeds");
|
||||
json_object_set_new(rule, "affectedFeeds",
|
||||
json_is_array(af) ? json_deep_copy(af) : json_array());
|
||||
webui_store_rule_upsert(g_webui.store, rule);
|
||||
json_decref(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
json_decref(req);
|
||||
if (changed) rss_save();
|
||||
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. */
|
||||
/* POST /api/rss/rules/run {name} — re-apply a rule to every stored article (not
|
||||
* just newly-seen ones), grabbing matches not yet grabbed. 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,s:s}",
|
||||
"key", json_string_or(a, "key", ""),
|
||||
"title", json_string_or(a, "title", ""),
|
||||
"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);
|
||||
const char *rname = json_string_value(json_object_get(req, "name"));
|
||||
char name[128] = {0};
|
||||
if (rname) snprintf(name, sizeof name, "%s", rname);
|
||||
json_decref(req);
|
||||
|
||||
json_t *rule = (g_webui.store && name[0]) ? webui_store_rule_get(g_webui.store, name) : NULL;
|
||||
if (!rule) { http_text(fd, 404, "Not Found", "no such rule"); return; }
|
||||
char cat[128], path[1024];
|
||||
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
|
||||
snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", ""));
|
||||
bool paused = json_boolean_value(json_object_get(rule, "addPaused"));
|
||||
json_object_set_new(rule, "enabled", json_true()); /* manual run */
|
||||
|
||||
json_t *cands = json_array();
|
||||
webui_store_articles_ungrabbed(g_webui.store, cands);
|
||||
json_t *todo = json_array();
|
||||
size_t i; json_t *a;
|
||||
json_array_foreach(cands, i, a)
|
||||
if (rule_matches(rule, json_string_or(a, "feed", ""), json_string_or(a, "title", "")))
|
||||
json_array_append(todo, a);
|
||||
json_decref(cands);
|
||||
json_decref(rule);
|
||||
|
||||
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, "title", ""),
|
||||
json_string_or(t, "magnet", ""),
|
||||
json_string_or(t, "torrentUrl", ""), cat, path, paused))
|
||||
json_array_foreach(todo, i, a)
|
||||
if (rss_grab_article(json_string_or(a, "key", ""), json_string_or(a, "title", ""),
|
||||
json_string_or(a, "magnet", ""), json_string_or(a, "torrentUrl", ""),
|
||||
cat, path, paused))
|
||||
grabbed++;
|
||||
size_t matched = json_array_size(todo);
|
||||
json_decref(todo);
|
||||
if (grabbed > 0) rss_save();
|
||||
if (matched) webui_store_rule_set_match(g_webui.store, name, (long)time(NULL));
|
||||
|
||||
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). */
|
||||
/* POST /api/rss/download {magnet|torrentUrl, title, key, category, savePath,
|
||||
* paused} — manually grab a torrent from a feed article or search result. */
|
||||
static void api_rss_download(int fd, const char *body, size_t len) {
|
||||
json_t *req = read_body_json(body, len);
|
||||
const char *magnet = json_string_or(req, "magnet", "");
|
||||
|
|
@ -2415,7 +2308,6 @@ static void api_rss_download(int fd, const char *body, size_t len) {
|
|||
const char *title = json_string_or(req, "title", "");
|
||||
bool paused = json_boolean_value(json_object_get(req, "paused"));
|
||||
bool ok = rss_grab_article(key, title, 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);
|
||||
|
|
@ -2426,27 +2318,24 @@ 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. */
|
||||
/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds). */
|
||||
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);
|
||||
char target[256] = {0};
|
||||
if (name) snprintf(target, sizeof target, "%s", name);
|
||||
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);
|
||||
if (g_webui.store) {
|
||||
json_t *targets = json_array();
|
||||
webui_store_feed_targets(g_webui.store, targets);
|
||||
size_t i; json_t *t;
|
||||
json_array_foreach(targets, i, t) {
|
||||
if (atomic_load(&g_webui.stopping)) break;
|
||||
const char *fn = json_string_or(t, "name", "");
|
||||
if (target[0] && strcmp(target, fn) != 0) continue;
|
||||
rss_poll_one(fn, json_string_or(t, "url", ""));
|
||||
}
|
||||
json_decref(targets);
|
||||
}
|
||||
api_rss_list(fd);
|
||||
}
|
||||
|
|
@ -2455,34 +2344,14 @@ static void api_rss_refresh(int fd, const char *body, size_t len) {
|
|||
static void api_indexer(int fd, const char *body, size_t len, bool remove) {
|
||||
json_t *req = read_body_json(body, len);
|
||||
const char *name = json_string_value(json_object_get(req, "name"));
|
||||
bool changed = false;
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
if (name && *name) {
|
||||
size_t i; json_t *ix; int at = -1;
|
||||
json_array_foreach(g_webui.indexers, i, ix)
|
||||
if (strcmp(json_string_or(ix, "name", ""), name) == 0) { at = (int)i; break; }
|
||||
if (remove) {
|
||||
if (at >= 0) { json_array_remove(g_webui.indexers, (size_t)at); changed = true; }
|
||||
} else {
|
||||
json_t *e = json_pack("{s:s,s:s,s:s,s:b}", "name", name,
|
||||
"url", json_string_or(req, "url", ""),
|
||||
"apikey", json_string_or(req, "apikey", ""),
|
||||
"enabled", json_object_get(req, "enabled")
|
||||
? json_boolean_value(json_object_get(req, "enabled")) : true);
|
||||
if (e) {
|
||||
if (at >= 0) json_array_set_new(g_webui.indexers, (size_t)at, e);
|
||||
else json_array_append_new(g_webui.indexers, e);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (g_webui.store && name && *name) {
|
||||
if (remove) webui_store_indexer_remove(g_webui.store, name);
|
||||
else webui_store_indexer_upsert(g_webui.store, req);
|
||||
}
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
json_decref(req);
|
||||
if (changed) rss_save();
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
json_t *reply = json_deep_copy(g_webui.indexers);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
http_json(fd, 200, reply ? reply : json_array());
|
||||
json_t *reply = json_array();
|
||||
if (g_webui.store) webui_store_indexer_list(g_webui.store, reply);
|
||||
http_json(fd, 200, reply);
|
||||
json_decref(reply);
|
||||
}
|
||||
|
||||
|
|
@ -2544,10 +2413,8 @@ static json_t *torznab_parse(const char *xml, size_t len, const char *engine) {
|
|||
/* GET /api/search?q=… queries every enabled Torznab indexer and merges rows. */
|
||||
static void api_search(int fd, const char *query) {
|
||||
json_t *results = json_array();
|
||||
/* snapshot the indexer list under the lock */
|
||||
pthread_mutex_lock(&g_webui.rss_lock);
|
||||
json_t *indexers = json_deep_copy(g_webui.indexers);
|
||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
||||
json_t *indexers = json_array();
|
||||
if (g_webui.store) webui_store_indexer_list(g_webui.store, indexers);
|
||||
|
||||
size_t i; json_t *ix;
|
||||
json_array_foreach(indexers, i, ix) {
|
||||
|
|
@ -3264,9 +3131,6 @@ naut_err naut_plugin_register(const naut_host_api *host) {
|
|||
goto fail_store;
|
||||
pthread_mutex_init(&g_webui.rss_lock, NULL);
|
||||
pthread_cond_init(&g_webui.rss_cond, NULL);
|
||||
g_webui.rss_feeds = json_array();
|
||||
g_webui.rss_rules = json_array();
|
||||
g_webui.indexers = json_array();
|
||||
init_auth();
|
||||
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
||||
"webui");
|
||||
|
|
@ -3339,13 +3203,6 @@ naut_err naut_plugin_shutdown(void) {
|
|||
g_webui.categories = NULL;
|
||||
g_webui.tags = NULL;
|
||||
g_webui.assignments = NULL;
|
||||
|
||||
json_decref(g_webui.rss_feeds);
|
||||
json_decref(g_webui.rss_rules);
|
||||
json_decref(g_webui.indexers);
|
||||
g_webui.rss_feeds = NULL;
|
||||
g_webui.rss_rules = NULL;
|
||||
g_webui.indexers = NULL;
|
||||
pthread_cond_destroy(&g_webui.rss_cond);
|
||||
pthread_mutex_destroy(&g_webui.rss_lock);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue