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:
ookami125 2026-06-24 01:59:36 -04:00
parent 91d4b99aee
commit a0265cc1ea
3 changed files with 700 additions and 354 deletions

View file

@ -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;
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);
}
} 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;
}
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);

View file

@ -20,6 +20,11 @@ struct webui_store {
pthread_mutex_t lock;
};
/* One-time upgrade of a pre-normalization DB (feeds.articles / rules.affected_feeds
* JSON columns) to the relational articles + rule_feeds tables. Defined at the
* end of the file so it can use the row helpers. */
static void legacy_migrate(webui_store *s);
static void to_hex(const unsigned char *in, size_t n, char *out) {
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < n; i++) {
@ -81,8 +86,24 @@ webui_store *webui_store_open(const char *path) {
"CREATE TABLE IF NOT EXISTS feeds ("
" name TEXT PRIMARY KEY,"
" url TEXT NOT NULL,"
" last_update INTEGER NOT NULL DEFAULT 0,"
" articles TEXT NOT NULL DEFAULT '[]');"
" last_update INTEGER NOT NULL DEFAULT 0);"
"CREATE TABLE IF NOT EXISTS articles ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" feed TEXT NOT NULL,"
" key TEXT NOT NULL,"
" title TEXT NOT NULL DEFAULT '',"
" magnet TEXT NOT NULL DEFAULT '',"
" torrent_url TEXT NOT NULL DEFAULT '',"
" link TEXT NOT NULL DEFAULT '',"
" size INTEGER NOT NULL DEFAULT 0,"
" pub_date TEXT NOT NULL DEFAULT '',"
" is_read INTEGER NOT NULL DEFAULT 0,"
" grabbed INTEGER NOT NULL DEFAULT 0,"
" seen_at INTEGER NOT NULL DEFAULT 0,"
" UNIQUE(feed, key));"
"CREATE INDEX IF NOT EXISTS articles_feed ON articles(feed);"
"CREATE INDEX IF NOT EXISTS articles_key ON articles(key);"
"CREATE INDEX IF NOT EXISTS articles_grabbed ON articles(grabbed);"
"CREATE TABLE IF NOT EXISTS rules ("
" name TEXT PRIMARY KEY,"
" enabled INTEGER NOT NULL DEFAULT 1,"
@ -92,8 +113,11 @@ webui_store *webui_store_open(const char *path) {
" must_not_contain TEXT NOT NULL DEFAULT '',"
" assigned_category TEXT NOT NULL DEFAULT '',"
" save_path TEXT NOT NULL DEFAULT '',"
" affected_feeds TEXT NOT NULL DEFAULT '[]',"
" last_match INTEGER NOT NULL DEFAULT 0);"
"CREATE TABLE IF NOT EXISTS rule_feeds ("
" rule TEXT NOT NULL,"
" feed TEXT NOT NULL,"
" PRIMARY KEY(rule, feed));"
"CREATE TABLE IF NOT EXISTS indexers ("
" name TEXT PRIMARY KEY,"
" url TEXT NOT NULL DEFAULT '',"
@ -105,6 +129,7 @@ webui_store *webui_store_open(const char *path) {
webui_store_close(s);
return NULL;
}
legacy_migrate(s); /* upgrade an older DB's RSS schema in place */
return s;
}
@ -389,20 +414,14 @@ bool webui_store_load_tags(webui_store *s, json_t *out) {
return ok;
}
/* --- RSS ------------------------------------------------------------------ */
/* --- RSS (fully relational) ----------------------------------------------- */
static int int_of(json_t *o, const char *k) {
return json_boolean_value(json_object_get(o, k)) ? 1 : 0;
}
/* Bind a json array column as a compact JSON string (SQLite copies it). */
static void bind_json_array(sqlite3_stmt *st, int col, json_t *arr) {
char *s = json_dumps(json_is_array(arr) ? arr : json_array(), JSON_COMPACT);
sqlite3_bind_text(st, col, s ? s : "[]", -1, SQLITE_TRANSIENT);
free(s);
}
/* Parse a TEXT column holding a JSON array; returns a new array (never NULL). */
/* Parse a TEXT column holding a JSON array; returns a new array (never NULL).
* Only used by the legacy-schema migration. */
static json_t *array_col(sqlite3_stmt *st, int col) {
const char *txt = (const char *)sqlite3_column_text(st, col);
if (txt) {
@ -413,26 +432,132 @@ static json_t *array_col(sqlite3_stmt *st, int col) {
return json_array();
}
static void bind_feed(sqlite3_stmt *st, json_t *f) {
sqlite3_bind_text(st, 1, str_or(f, "name", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, str_or(f, "url", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 3, (sqlite3_int64)json_integer_value(json_object_get(f, "lastUpdate")));
bind_json_array(st, 4, json_object_get(f, "articles"));
/* ---- feeds ---- */
bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url) {
if (!s || !name || !*name || !url || !*url) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"INSERT INTO feeds (name, url, last_update) VALUES (?,?,0)"
" ON CONFLICT(name) DO UPDATE SET url=excluded.url;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
sqlite3_bind_text(st, 2, url, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_save_feeds(webui_store *s, json_t *feeds) {
return replace_table(s, "DELETE FROM feeds;",
"INSERT OR REPLACE INTO feeds (name, url, last_update, articles) VALUES (?,?,?,?);",
feeds, bind_feed);
bool webui_store_feed_remove(webui_store *s, const char *name) {
if (!s || !name) return false;
pthread_mutex_lock(&s->lock);
bool ok = false;
sqlite3_stmt *st = NULL;
sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL);
if (sqlite3_prepare_v2(s->db, "DELETE FROM articles WHERE feed=?;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
sqlite3_step(st);
}
sqlite3_finalize(st); st = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0;
}
sqlite3_finalize(st);
sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_load_feeds(webui_store *s, json_t *out) {
bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts) {
if (!s || !name) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "UPDATE feeds SET last_update=? WHERE name=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)ts);
sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_feed_exists(webui_store *s, const char *name) {
if (!s || !name) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool found = false;
if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
found = sqlite3_step(st) == SQLITE_ROW;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return found;
}
bool webui_store_feed_targets(webui_store *s, json_t *out) {
if (!s || !json_is_array(out)) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "SELECT name, url FROM feeds ORDER BY name;",
-1, &st, NULL) == SQLITE_OK) {
ok = true;
while (sqlite3_step(st) == SQLITE_ROW) {
const char *n = (const char *)sqlite3_column_text(st, 0);
const char *u = (const char *)sqlite3_column_text(st, 1);
json_array_append_new(out, json_pack("{s:s,s:s}",
"name", n ? n : "", "url", u ? u : ""));
}
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
/* Build the article array for one feed (newest first). Caller holds the lock. */
static json_t *feed_articles_locked(webui_store *s, const char *feed) {
json_t *arr = json_array();
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed"
" FROM articles WHERE feed=? ORDER BY id DESC;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC);
while (sqlite3_step(st) == SQLITE_ROW) {
const char *k = (const char *)sqlite3_column_text(st, 0);
const char *t = (const char *)sqlite3_column_text(st, 1);
const char *m = (const char *)sqlite3_column_text(st, 2);
const char *tu = (const char *)sqlite3_column_text(st, 3);
const char *ln = (const char *)sqlite3_column_text(st, 4);
const char *pd = (const char *)sqlite3_column_text(st, 6);
json_array_append_new(arr, json_pack(
"{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}",
"key", k ? k : "", "title", t ? t : "", "magnet", m ? m : "",
"torrentUrl", tu ? tu : "", "link", ln ? ln : "",
"size", (json_int_t)sqlite3_column_int64(st, 5),
"pubDate", pd ? pd : "", "isRead", sqlite3_column_int(st, 7),
"grabbed", sqlite3_column_int(st, 8)));
}
}
sqlite3_finalize(st);
return arr;
}
bool webui_store_feed_list(webui_store *s, json_t *out) {
if (!s || !json_is_array(out)) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"SELECT name, url, last_update, articles FROM feeds ORDER BY name;",
"SELECT name, url, last_update FROM feeds ORDER BY name;",
-1, &st, NULL) == SQLITE_OK) {
ok = true;
while (sqlite3_step(st) == SQLITE_ROW) {
@ -441,7 +566,7 @@ bool webui_store_load_feeds(webui_store *s, json_t *out) {
json_array_append_new(out, json_pack("{s:s,s:s,s:I,s:o}",
"name", n ? n : "", "url", u ? u : "",
"lastUpdate", (json_int_t)sqlite3_column_int64(st, 2),
"articles", array_col(st, 3)));
"articles", feed_articles_locked(s, n ? n : "")));
}
}
sqlite3_finalize(st);
@ -449,7 +574,98 @@ bool webui_store_load_feeds(webui_store *s, json_t *out) {
return ok;
}
static void bind_rule(sqlite3_stmt *st, json_t *r) {
/* ---- articles ---- */
int webui_store_article_add(webui_store *s, const char *feed, json_t *a) {
if (!s || !feed || !json_is_object(a)) return -1;
const char *key = str_or(a, "key", "");
if (!*key) return -1;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
int rc = -1;
if (sqlite3_prepare_v2(s->db,
"INSERT OR IGNORE INTO articles"
" (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)"
" VALUES (?,?,?,?,?,?,?,?,0,0,?);", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC);
sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size")));
sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 9, (sqlite3_int64)time(NULL));
if (sqlite3_step(st) == SQLITE_DONE) rc = sqlite3_changes(s->db) > 0 ? 1 : 0;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return rc;
}
bool webui_store_article_trim(webui_store *s, const char *feed, int keep) {
if (!s || !feed || keep < 0) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"DELETE FROM articles WHERE feed=? AND id NOT IN"
" (SELECT id FROM articles WHERE feed=? ORDER BY id DESC LIMIT ?);",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC);
sqlite3_bind_text(st, 2, feed, -1, SQLITE_STATIC);
sqlite3_bind_int(st, 3, keep);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_article_mark_grabbed(webui_store *s, const char *key) {
if (!s || !key || !*key) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "UPDATE articles SET grabbed=1 WHERE key=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_articles_ungrabbed(webui_store *s, json_t *out) {
if (!s || !json_is_array(out)) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"SELECT feed,key,title,magnet,torrent_url FROM articles"
" WHERE grabbed=0 AND (magnet<>'' OR torrent_url<>'') ORDER BY id DESC;",
-1, &st, NULL) == SQLITE_OK) {
ok = true;
while (sqlite3_step(st) == SQLITE_ROW) {
const char *f = (const char *)sqlite3_column_text(st, 0);
const char *k = (const char *)sqlite3_column_text(st, 1);
const char *t = (const char *)sqlite3_column_text(st, 2);
const char *m = (const char *)sqlite3_column_text(st, 3);
const char *u = (const char *)sqlite3_column_text(st, 4);
json_array_append_new(out, json_pack("{s:s,s:s,s:s,s:s,s:s}",
"feed", f ? f : "", "key", k ? k : "", "title", t ? t : "",
"magnet", m ? m : "", "torrentUrl", u ? u : ""));
}
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
/* ---- rules ---- */
static void bind_rule_row(sqlite3_stmt *st, json_t *r) {
sqlite3_bind_text(st, 1, str_or(r, "name", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(st, 2, int_of(r, "enabled"));
sqlite3_bind_int(st, 3, int_of(r, "useRegex"));
@ -458,36 +674,95 @@ static void bind_rule(sqlite3_stmt *st, json_t *r) {
sqlite3_bind_text(st, 6, str_or(r, "mustNotContain", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 7, str_or(r, "assignedCategory", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 8, str_or(r, "savePath", ""), -1, SQLITE_TRANSIENT);
bind_json_array(st, 9, json_object_get(r, "affectedFeeds"));
sqlite3_bind_int64(st, 10, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch")));
sqlite3_bind_int64(st, 9, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch")));
}
bool webui_store_save_rules(webui_store *s, json_t *rules) {
return replace_table(s, "DELETE FROM rules;",
"INSERT OR REPLACE INTO rules (name, enabled, use_regex, add_paused,"
" must_contain, must_not_contain, assigned_category, save_path,"
" affected_feeds, last_match) VALUES (?,?,?,?,?,?,?,?,?,?);",
rules, bind_rule);
}
bool webui_store_load_rules(webui_store *s, json_t *out) {
if (!s || !json_is_array(out)) return false;
bool webui_store_rule_upsert(webui_store *s, json_t *r) {
if (!s || !json_is_object(r)) return false;
const char *name = str_or(r, "name", "");
if (!*name) return false;
pthread_mutex_lock(&s->lock);
bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK;
sqlite3_stmt *st = NULL;
if (ok && sqlite3_prepare_v2(s->db,
"INSERT OR REPLACE INTO rules (name,enabled,use_regex,add_paused,"
"must_contain,must_not_contain,assigned_category,save_path,last_match)"
" VALUES (?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) {
bind_rule_row(st, r);
ok = sqlite3_step(st) == SQLITE_DONE;
} else ok = false;
sqlite3_finalize(st); st = NULL;
if (ok && sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
} else ok = false;
sqlite3_finalize(st); st = NULL;
json_t *feeds = json_object_get(r, "affectedFeeds");
if (ok && json_is_array(feeds) && sqlite3_prepare_v2(s->db,
"INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);",
-1, &st, NULL) == SQLITE_OK) {
size_t i; json_t *v;
json_array_foreach(feeds, i, v) {
const char *fn = json_string_value(v);
if (!fn || !*fn) continue;
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; }
sqlite3_reset(st);
}
}
sqlite3_finalize(st);
sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_rule_remove(webui_store *s, const char *name) {
if (!s || !name) return false;
pthread_mutex_lock(&s->lock);
sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
sqlite3_step(st);
}
sqlite3_finalize(st); st = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM rules WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0;
}
sqlite3_finalize(st);
sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL);
pthread_mutex_unlock(&s->lock);
return ok;
}
/* Build the affectedFeeds array for one rule. Caller holds the lock. */
static json_t *rule_feeds_locked(webui_store *s, const char *rule) {
json_t *arr = json_array();
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT name, enabled, use_regex, add_paused, must_contain,"
" must_not_contain, assigned_category, save_path, affected_feeds,"
" last_match FROM rules ORDER BY name;", -1, &st, NULL) == SQLITE_OK) {
ok = true;
"SELECT feed FROM rule_feeds WHERE rule=? ORDER BY feed;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, rule, -1, SQLITE_STATIC);
while (sqlite3_step(st) == SQLITE_ROW) {
const char *f = (const char *)sqlite3_column_text(st, 0);
json_array_append_new(arr, json_string(f ? f : ""));
}
}
sqlite3_finalize(st);
return arr;
}
static json_t *rule_row_to_json(sqlite3_stmt *st, webui_store *s) {
const char *n = (const char *)sqlite3_column_text(st, 0);
const char *mc = (const char *)sqlite3_column_text(st, 4);
const char *mn = (const char *)sqlite3_column_text(st, 5);
const char *ac = (const char *)sqlite3_column_text(st, 6);
const char *sp = (const char *)sqlite3_column_text(st, 7);
json_array_append_new(out, json_pack(
"{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:o,s:I}",
return json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:o,s:I}",
"name", n ? n : "",
"enabled", sqlite3_column_int(st, 1),
"useRegex", sqlite3_column_int(st, 2),
@ -496,29 +771,101 @@ bool webui_store_load_rules(webui_store *s, json_t *out) {
"mustNotContain", mn ? mn : "",
"assignedCategory", ac ? ac : "",
"savePath", sp ? sp : "",
"affectedFeeds", array_col(st, 8),
"lastMatch", (json_int_t)sqlite3_column_int64(st, 9)));
}
"affectedFeeds", rule_feeds_locked(s, n ? n : ""),
"lastMatch", (json_int_t)sqlite3_column_int64(st, 8));
}
static const char RULE_COLS[] =
"SELECT name,enabled,use_regex,add_paused,must_contain,must_not_contain,"
"assigned_category,save_path,last_match FROM rules";
bool webui_store_rule_list(webui_store *s, json_t *out) {
if (!s || !json_is_array(out)) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
char sql[256];
snprintf(sql, sizeof sql, "%s ORDER BY name;", RULE_COLS);
if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) {
ok = true;
while (sqlite3_step(st) == SQLITE_ROW)
json_array_append_new(out, rule_row_to_json(st, s));
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
static void bind_indexer(sqlite3_stmt *st, json_t *x) {
sqlite3_bind_text(st, 1, str_or(x, "name", ""), -1, SQLITE_TRANSIENT);
json_t *webui_store_rule_get(webui_store *s, const char *name) {
if (!s || !name) return NULL;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
json_t *out = NULL;
char sql[256];
snprintf(sql, sizeof sql, "%s WHERE name=?;", RULE_COLS);
if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) out = rule_row_to_json(st, s);
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return out;
}
bool webui_store_rule_set_match(webui_store *s, const char *name, long ts) {
if (!s || !name) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "UPDATE rules SET last_match=? WHERE name=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)ts);
sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
/* ---- indexers ---- */
bool webui_store_indexer_upsert(webui_store *s, json_t *x) {
if (!s || !json_is_object(x)) return false;
const char *name = str_or(x, "name", "");
if (!*name) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"INSERT OR REPLACE INTO indexers (name,url,apikey,enabled) VALUES (?,?,?,?);",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, str_or(x, "url", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, str_or(x, "apikey", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(st, 4, int_of(x, "enabled"));
sqlite3_bind_int(st, 4, json_object_get(x, "enabled") ? int_of(x, "enabled") : 1);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_save_indexers(webui_store *s, json_t *indexers) {
return replace_table(s, "DELETE FROM indexers;",
"INSERT OR REPLACE INTO indexers (name, url, apikey, enabled) VALUES (?,?,?,?);",
indexers, bind_indexer);
bool webui_store_indexer_remove(webui_store *s, const char *name) {
if (!s || !name) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "DELETE FROM indexers WHERE name=?;", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_load_indexers(webui_store *s, json_t *out) {
bool webui_store_indexer_list(webui_store *s, json_t *out) {
if (!s || !json_is_array(out)) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
@ -540,3 +887,117 @@ bool webui_store_load_indexers(webui_store *s, json_t *out) {
pthread_mutex_unlock(&s->lock);
return ok;
}
/* --- legacy schema migration ---------------------------------------------- */
static bool table_has_column(sqlite3 *db, const char *table, const char *col) {
char sql[128];
snprintf(sql, sizeof sql, "PRAGMA table_info(%s);", table);
sqlite3_stmt *st = NULL;
bool found = false;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) {
while (sqlite3_step(st) == SQLITE_ROW) {
const char *n = (const char *)sqlite3_column_text(st, 1); /* 1 = name */
if (n && strcmp(n, col) == 0) { found = true; break; }
}
}
sqlite3_finalize(st);
return found;
}
static void legacy_migrate(webui_store *s) {
/* The tell-tale of the old schema: feeds carried an inline articles blob. */
if (!table_has_column(s->db, "feeds", "articles")) return;
/* Snapshot the legacy blobs first, then finalize before mutating. */
json_t *feed_arts = json_object(); /* feed name -> articles array */
json_t *rule_feeds = json_object(); /* rule name -> affectedFeeds array */
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(s->db, "SELECT name, articles FROM feeds;", -1, &st, NULL) == SQLITE_OK)
while (sqlite3_step(st) == SQLITE_ROW) {
const char *f = (const char *)sqlite3_column_text(st, 0);
json_object_set_new(feed_arts, f ? f : "", array_col(st, 1));
}
sqlite3_finalize(st); st = NULL;
if (table_has_column(s->db, "rules", "affected_feeds") &&
sqlite3_prepare_v2(s->db, "SELECT name, affected_feeds FROM rules;", -1, &st, NULL) == SQLITE_OK)
while (sqlite3_step(st) == SQLITE_ROW) {
const char *n = (const char *)sqlite3_column_text(st, 0);
json_object_set_new(rule_feeds, n ? n : "", array_col(st, 1));
}
sqlite3_finalize(st); st = NULL;
sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL);
/* Articles: insert oldest-first so autoincrement id tracks recency (the
* legacy array is newest-first). Preserve is_read / grabbed flags. */
if (sqlite3_prepare_v2(s->db,
"INSERT OR IGNORE INTO articles"
" (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) {
const char *feed; json_t *arts;
json_object_foreach(feed_arts, feed, arts) {
if (!json_is_array(arts)) continue;
for (long i = (long)json_array_size(arts) - 1; i >= 0; i--) {
json_t *a = json_array_get(arts, (size_t)i);
const char *key = str_or(a, "key", "");
if (!*key) continue;
sqlite3_bind_text(st, 1, feed, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size")));
sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(st, 9, int_of(a, "isRead"));
sqlite3_bind_int(st, 10, int_of(a, "grabbed"));
sqlite3_bind_int64(st, 11, (sqlite3_int64)time(NULL));
sqlite3_step(st);
sqlite3_reset(st);
}
}
}
sqlite3_finalize(st); st = NULL;
if (sqlite3_prepare_v2(s->db,
"INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);",
-1, &st, NULL) == SQLITE_OK) {
const char *rule; json_t *feeds;
json_object_foreach(rule_feeds, rule, feeds) {
if (!json_is_array(feeds)) continue;
size_t i; json_t *v;
json_array_foreach(feeds, i, v) {
const char *fn = json_string_value(v);
if (!fn || !*fn) continue;
sqlite3_bind_text(st, 1, rule, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT);
sqlite3_step(st);
sqlite3_reset(st);
}
}
}
sqlite3_finalize(st); st = NULL;
/* Drop the legacy JSON columns by rebuilding feeds + rules. */
sqlite3_exec(s->db,
"CREATE TABLE feeds_new (name TEXT PRIMARY KEY, url TEXT NOT NULL,"
" last_update INTEGER NOT NULL DEFAULT 0);"
"INSERT INTO feeds_new (name,url,last_update) SELECT name,url,last_update FROM feeds;"
"DROP TABLE feeds;"
"ALTER TABLE feeds_new RENAME TO feeds;"
"CREATE TABLE rules_new (name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1,"
" use_regex INTEGER NOT NULL DEFAULT 0, add_paused INTEGER NOT NULL DEFAULT 0,"
" must_contain TEXT NOT NULL DEFAULT '', must_not_contain TEXT NOT NULL DEFAULT '',"
" assigned_category TEXT NOT NULL DEFAULT '', save_path TEXT NOT NULL DEFAULT '',"
" last_match INTEGER NOT NULL DEFAULT 0);"
"INSERT INTO rules_new SELECT name,enabled,use_regex,add_paused,must_contain,"
"must_not_contain,assigned_category,save_path,last_match FROM rules;"
"DROP TABLE rules;"
"ALTER TABLE rules_new RENAME TO rules;",
NULL, NULL, NULL);
sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL);
json_decref(feed_arts);
json_decref(rule_feeds);
}

View file

@ -52,14 +52,42 @@ bool webui_store_load_categories(webui_store *s, json_t *out);
bool webui_store_save_tags(webui_store *s, json_t *tags);
bool webui_store_load_tags(webui_store *s, json_t *out);
/* --- RSS: feeds, auto-download rules, Torznab indexers (owned here) -------- *
* save_* replace the whole list atomically; load_* append to the array `out`,
* rebuilding the exact JSON shapes the web layer/UI use. */
bool webui_store_save_feeds(webui_store *s, json_t *feeds);
bool webui_store_load_feeds(webui_store *s, json_t *out);
bool webui_store_save_rules(webui_store *s, json_t *rules);
bool webui_store_load_rules(webui_store *s, json_t *out);
bool webui_store_save_indexers(webui_store *s, json_t *indexers);
bool webui_store_load_indexers(webui_store *s, json_t *out);
/* --- RSS: feeds, articles, auto-download rules, Torznab indexers ----------- *
* Fully relational: articles live in their own table (deduped by feed+key,
* indexed), and a rule's feed scope lives in a rule_feeds join table. The web
* layer operates on rows, not whole-list blobs. */
/* Feeds. upsert preserves an existing feed's lastUpdate (only the url changes);
* remove also drops the feed's articles. feed_list appends
* {name,url,lastUpdate,articles:[...]} (newest article first). feed_targets
* appends lightweight {name,url} objects for the poller. */
bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url);
bool webui_store_feed_remove(webui_store *s, const char *name);
bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts);
bool webui_store_feed_list(webui_store *s, json_t *out);
bool webui_store_feed_targets(webui_store *s, json_t *out);
bool webui_store_feed_exists(webui_store *s, const char *name);
/* Articles. add inserts unless (feed,key) already exists: returns 1 if newly
* inserted, 0 if a duplicate, -1 on error. trim keeps the newest `keep` for a
* feed. mark_grabbed flags every article with this key. ungrabbed appends
* {feed,key,title,magnet,torrentUrl} for not-yet-grabbed articles. */
int webui_store_article_add(webui_store *s, const char *feed, json_t *article);
bool webui_store_article_trim(webui_store *s, const char *feed, int keep);
bool webui_store_article_mark_grabbed(webui_store *s, const char *key);
bool webui_store_articles_ungrabbed(webui_store *s, json_t *out);
/* Rules. upsert replaces the rule row and its feed scope; list/get assemble the
* rule with its affectedFeeds array. */
bool webui_store_rule_upsert(webui_store *s, json_t *rule);
bool webui_store_rule_remove(webui_store *s, const char *name);
bool webui_store_rule_list(webui_store *s, json_t *out);
json_t *webui_store_rule_get(webui_store *s, const char *name);
bool webui_store_rule_set_match(webui_store *s, const char *name, long ts);
/* Torznab indexers. */
bool webui_store_indexer_upsert(webui_store *s, json_t *indexer);
bool webui_store_indexer_remove(webui_store *s, const char *name);
bool webui_store_indexer_list(webui_store *s, json_t *out);
#endif /* NAUT_WEBUI_STORE_H */