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 *tags; /* array of tag name strings */
|
||||||
json_t *assignments; /* object keyed by stringified torrent id */
|
json_t *assignments; /* object keyed by stringified torrent id */
|
||||||
|
|
||||||
/* RSS: feeds + auto-download rules, polled by a background thread and
|
/* RSS feeds, articles, auto-download rules and Torznab indexers all live in
|
||||||
* persisted via the daemon blob store. Search indexers live here too. */
|
* 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;
|
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;
|
pthread_t rss_thread;
|
||||||
bool rss_thread_started;
|
bool rss_thread_started;
|
||||||
pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */
|
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);
|
json_object_set_new(json, "preferences", preferences);
|
||||||
/* searchPlugins mirrors the configured Torznab indexers for the Search tab. */
|
/* searchPlugins mirrors the configured Torznab indexers for the Search tab. */
|
||||||
json_t *plugins = json_array();
|
json_t *plugins = json_array();
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (g_webui.store) webui_store_indexer_list(g_webui.store, plugins);
|
||||||
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);
|
|
||||||
json_object_set_new(json, "searchPlugins", plugins);
|
json_object_set_new(json, "searchPlugins", plugins);
|
||||||
http_json(fd, 200, json);
|
http_json(fd, 200, json);
|
||||||
json_decref(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) -------------------------- */
|
/* --- 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 ------------------ */
|
/* --- auto-download: hand a matched article to the daemon ------------------ */
|
||||||
|
|
||||||
/* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and
|
/* 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mark the article with this key as grabbed (across all feeds), so an
|
/* Mark every article with this key as grabbed, so a rule re-run skips it. */
|
||||||
* auto-download rule re-run won't fetch it again. */
|
|
||||||
static void rss_mark_grabbed(const char *key) {
|
static void rss_mark_grabbed(const char *key) {
|
||||||
if (!key || !*key) return;
|
if (g_webui.store) webui_store_article_mark_grabbed(g_webui.store, key);
|
||||||
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. */
|
/* 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;
|
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,
|
static void rss_run_rules(const char *feed_name, const char *key,
|
||||||
const char *title, const char *magnet,
|
const char *title, const char *magnet,
|
||||||
const char *torrent_url) {
|
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;
|
size_t i; json_t *rule;
|
||||||
json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false;
|
json_array_foreach(rules, i, rule) {
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
|
||||||
json_array_foreach(g_webui.rss_rules, i, rule) {
|
|
||||||
if (rule_matches(rule, feed_name, title)) {
|
if (rule_matches(rule, feed_name, title)) {
|
||||||
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
|
snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", ""));
|
||||||
snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", ""));
|
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"));
|
paused = json_boolean_value(json_object_get(rule, "addPaused"));
|
||||||
json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL)));
|
fire = true;
|
||||||
fire = rule;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
json_decref(rules);
|
||||||
if (!fire) return;
|
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))
|
if (rss_grab_article(key, title, magnet, torrent_url, cat, path, paused))
|
||||||
log_msg(2, "rss: auto-downloaded a match");
|
log_msg(2, "rss: auto-downloaded a match");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Parse a feed body into article objects and merge new ones into `feed`.
|
/* Parse a feed body, inserting newly-seen articles into the store. Each new
|
||||||
* Newly-seen articles are appended to `out_new` (as {title,magnet,torrentUrl})
|
* article is appended to out_new ({key,title,magnet,torrentUrl}) so the caller
|
||||||
* so the caller can fire auto-download rules AFTER releasing rss_lock — running
|
* can fire rules afterward. Returns the number newly inserted. */
|
||||||
* them here would re-enter the lock (and do network I/O while holding it).
|
static int rss_ingest(const char *feed_name, const char *xml, size_t len,
|
||||||
* Returns the number of newly-seen articles. */
|
json_t *out_new) {
|
||||||
static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new) {
|
if (!g_webui.store) return 0;
|
||||||
json_t *articles = json_object_get(feed, "articles");
|
|
||||||
if (!json_is_array(articles)) {
|
|
||||||
articles = json_array();
|
|
||||||
json_object_set_new(feed, "articles", articles);
|
|
||||||
}
|
|
||||||
int added = 0;
|
int added = 0;
|
||||||
const char *p = xml, *end = xml + len;
|
const char *p = xml, *end = xml + len;
|
||||||
for (;;) {
|
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);
|
const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link);
|
||||||
if (title[0] && key && *key) {
|
if (title[0] && key && *key) {
|
||||||
/* dedupe against existing articles by their key */
|
json_t *art = json_pack("{s:s,s:s,s:s,s:s,s:s,s:I,s:s}",
|
||||||
bool seen = false; size_t ai; json_t *a;
|
"key", key, "title", title, "magnet", magnet, "torrentUrl", dl_url,
|
||||||
json_array_foreach(articles, ai, a) {
|
"link", link, "size", (json_int_t)strtoll(lenstr, NULL, 10),
|
||||||
if (strcmp(json_string_or(a, "key", ""), key) == 0) { seen = true; break; }
|
"pubDate", pub);
|
||||||
}
|
int rc = art ? webui_store_article_add(g_webui.store, feed_name, art) : -1;
|
||||||
if (!seen) {
|
json_decref(art);
|
||||||
json_t *art = json_pack(
|
if (rc == 1) {
|
||||||
"{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);
|
|
||||||
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,s:s}",
|
||||||
"{s:s,s:s,s:s,s:s}", "title", title, "key", key,
|
"key", key, "title", title, "magnet", magnet,
|
||||||
"magnet", magnet, "torrentUrl", dl_url));
|
"torrentUrl", dl_url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p = close + strlen(close_tag);
|
p = close + strlen(close_tag);
|
||||||
}
|
}
|
||||||
/* trim to the newest RSS_MAX_ARTICLES */
|
webui_store_article_trim(g_webui.store, feed_name, RSS_MAX_ARTICLES);
|
||||||
while (json_array_size(articles) > RSS_MAX_ARTICLES)
|
webui_store_feed_set_updated(g_webui.store, feed_name, (long)time(NULL));
|
||||||
json_array_remove(articles, json_array_size(articles) - 1);
|
|
||||||
json_object_set_new(feed, "lastUpdate", json_integer((json_int_t)time(NULL)));
|
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Poll one feed (network I/O done without rss_lock held). */
|
/* Poll one feed by name+url (network I/O done without any lock held). */
|
||||||
static void rss_poll_feed_by_index(size_t idx) {
|
static void rss_poll_one(const char *name, const char *url) {
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (!name || !*name || !url || !*url) return;
|
||||||
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;
|
|
||||||
|
|
||||||
naut_http_response r;
|
naut_http_response r;
|
||||||
if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) {
|
if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) {
|
||||||
naut_http_response_free(&r);
|
naut_http_response_free(&r);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
char feed_name[256] = {0};
|
|
||||||
json_t *new_articles = json_array();
|
json_t *new_articles = json_array();
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
rss_ingest(name, r.body, r.body_len, new_articles);
|
||||||
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);
|
|
||||||
naut_http_response_free(&r);
|
naut_http_response_free(&r);
|
||||||
|
/* fire auto-download rules for the newly-seen articles */
|
||||||
/* 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, "key", ""),
|
rss_run_rules(name, json_string_or(a, "key", ""),
|
||||||
json_string_or(a, "title", ""),
|
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);
|
||||||
if (added > 0) rss_save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void rss_poll_all(void) {
|
static void rss_poll_all(void) {
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (!g_webui.store) return;
|
||||||
size_t n = json_array_size(g_webui.rss_feeds);
|
json_t *targets = json_array();
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
webui_store_feed_targets(g_webui.store, targets);
|
||||||
for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++)
|
size_t i; json_t *t;
|
||||||
rss_poll_feed_by_index(i);
|
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) {
|
static void *rss_thread_fn(void *arg) {
|
||||||
(void)arg;
|
(void)arg;
|
||||||
rss_load();
|
|
||||||
while (!atomic_load(&g_webui.stopping)) {
|
while (!atomic_load(&g_webui.stopping)) {
|
||||||
rss_poll_all();
|
rss_poll_all();
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
pthread_mutex_lock(&g_webui.rss_lock);
|
||||||
|
|
@ -2243,15 +2181,21 @@ static void *rss_thread_fn(void *arg) {
|
||||||
return NULL;
|
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 --------------------------------------------------------- */
|
/* --- RSS HTTP API --------------------------------------------------------- */
|
||||||
|
|
||||||
/* GET /api/rss → array of feeds (with their articles). */
|
/* GET /api/rss → array of feeds (with their articles). */
|
||||||
static void api_rss_list(int fd) {
|
static void api_rss_list(int fd) {
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
json_t *feeds = json_array();
|
||||||
json_t *reply = json_deep_copy(g_webui.rss_feeds);
|
if (g_webui.store) webui_store_feed_list(g_webui.store, feeds);
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
http_json(fd, 200, feeds);
|
||||||
http_json(fd, 200, reply ? reply : json_array());
|
json_decref(feeds);
|
||||||
json_decref(reply);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */
|
/* 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);
|
json_t *req = read_body_json(body, len);
|
||||||
const char *name = json_string_value(json_object_get(req, "name"));
|
const char *name = json_string_value(json_object_get(req, "name"));
|
||||||
const char *url = json_string_value(json_object_get(req, "url"));
|
const char *url = json_string_value(json_object_get(req, "url"));
|
||||||
bool changed = false;
|
bool added = false;
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (g_webui.store && name && *name) {
|
||||||
if (remove && name) {
|
if (remove) webui_store_feed_remove(g_webui.store, name);
|
||||||
size_t i; json_t *f;
|
else if (url && *url) added = webui_store_feed_upsert(g_webui.store, name, url);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
||||||
json_decref(req);
|
json_decref(req);
|
||||||
if (changed) {
|
if (added) rss_signal_wake(); /* re-poll the new feed now */
|
||||||
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);
|
|
||||||
}
|
|
||||||
api_rss_list(fd);
|
api_rss_list(fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GET /api/rss/rules → array of rules. */
|
/* GET /api/rss/rules → array of rules. */
|
||||||
static void api_rss_rules_list(int fd) {
|
static void api_rss_rules_list(int fd) {
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
json_t *rules = json_array();
|
||||||
json_t *reply = json_deep_copy(g_webui.rss_rules);
|
if (g_webui.store) webui_store_rule_list(g_webui.store, rules);
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
http_json(fd, 200, rules);
|
||||||
http_json(fd, 200, reply ? reply : json_array());
|
json_decref(rules);
|
||||||
json_decref(reply);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */
|
/* 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) {
|
static void api_rss_rule(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);
|
||||||
const char *name = json_string_value(json_object_get(req, "name"));
|
const char *name = json_string_value(json_object_get(req, "name"));
|
||||||
bool changed = false;
|
if (g_webui.store && name && *name) {
|
||||||
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 (remove) {
|
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 {
|
} 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,
|
"name", name,
|
||||||
"enabled", json_boolean_value(json_object_get(req, "enabled")),
|
"enabled", json_boolean_value(json_object_get(req, "enabled")),
|
||||||
"useRegex", json_boolean_value(json_object_get(req, "useRegex")),
|
"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", ""),
|
"mustNotContain", json_string_or(req, "mustNotContain", ""),
|
||||||
"assignedCategory", json_string_or(req, "assignedCategory", ""),
|
"assignedCategory", json_string_or(req, "assignedCategory", ""),
|
||||||
"savePath", json_string_or(req, "savePath", ""),
|
"savePath", json_string_or(req, "savePath", ""),
|
||||||
"affectedFeeds", json_is_array(json_object_get(req, "affectedFeeds"))
|
|
||||||
? json_object_get(req, "affectedFeeds") : json_array(),
|
|
||||||
"lastMatch", 0);
|
"lastMatch", 0);
|
||||||
if (rule) {
|
if (rule) {
|
||||||
if (at >= 0) json_array_set_new(g_webui.rss_rules, (size_t)at, rule);
|
json_t *af = json_object_get(req, "affectedFeeds");
|
||||||
else json_array_append_new(g_webui.rss_rules, rule);
|
json_object_set_new(rule, "affectedFeeds",
|
||||||
changed = true;
|
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);
|
json_decref(req);
|
||||||
if (changed) rss_save();
|
|
||||||
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
|
/* POST /api/rss/rules/run {name} — re-apply a rule to every stored article (not
|
||||||
* the feeds (not just newly-seen ones), downloading matches not yet grabbed.
|
* just newly-seen ones), grabbing matches not yet grabbed. Runs regardless of
|
||||||
* Used after editing a rule. Runs regardless of the rule's enabled flag. */
|
* the rule's enabled flag. */
|
||||||
static void api_rss_rule_run(int fd, const char *body, size_t len) {
|
static void api_rss_rule_run(int fd, const char *body, size_t len) {
|
||||||
json_t *req = read_body_json(body, len);
|
json_t *req = read_body_json(body, len);
|
||||||
const char *name = json_string_value(json_object_get(req, "name"));
|
const char *rname = json_string_value(json_object_get(req, "name"));
|
||||||
char cat[128] = {0}, path[1024] = {0}; bool paused = false;
|
char name[128] = {0};
|
||||||
json_t *todo = json_array(); /* {key,magnet,torrentUrl} to grab */
|
if (rname) snprintf(name, sizeof name, "%s", rname);
|
||||||
|
|
||||||
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);
|
|
||||||
json_decref(req);
|
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;
|
int grabbed = 0;
|
||||||
size_t j; json_t *t;
|
json_array_foreach(todo, i, a)
|
||||||
json_array_foreach(todo, j, t)
|
if (rss_grab_article(json_string_or(a, "key", ""), json_string_or(a, "title", ""),
|
||||||
if (rss_grab_article(json_string_or(t, "key", ""), json_string_or(t, "title", ""),
|
json_string_or(a, "magnet", ""), json_string_or(a, "torrentUrl", ""),
|
||||||
json_string_or(t, "magnet", ""),
|
cat, path, paused))
|
||||||
json_string_or(t, "torrentUrl", ""), cat, path, paused))
|
|
||||||
grabbed++;
|
grabbed++;
|
||||||
size_t matched = json_array_size(todo);
|
size_t matched = json_array_size(todo);
|
||||||
json_decref(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,
|
json_t *reply = json_pack("{s:b,s:i,s:i}", "ok", 1,
|
||||||
"matched", (int)matched, "grabbed", grabbed);
|
"matched", (int)matched, "grabbed", grabbed);
|
||||||
http_json(fd, 200, reply);
|
http_json(fd, 200, reply);
|
||||||
json_decref(reply);
|
json_decref(reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* POST /api/rss/download {magnet|torrentUrl, category, savePath, paused}
|
/* POST /api/rss/download {magnet|torrentUrl, title, key, category, savePath,
|
||||||
* Manually grab a torrent from a feed article or search result. Reuses the
|
* paused} — manually grab a torrent from a feed article or search result. */
|
||||||
* same add path as the auto-downloader (handles magnets and .torrent URLs). */
|
|
||||||
static void api_rss_download(int fd, const char *body, size_t len) {
|
static void api_rss_download(int fd, const char *body, size_t len) {
|
||||||
json_t *req = read_body_json(body, len);
|
json_t *req = read_body_json(body, len);
|
||||||
const char *magnet = json_string_or(req, "magnet", "");
|
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", "");
|
const char *title = json_string_or(req, "title", "");
|
||||||
bool paused = json_boolean_value(json_object_get(req, "paused"));
|
bool paused = json_boolean_value(json_object_get(req, "paused"));
|
||||||
bool ok = rss_grab_article(key, title, magnet, url, cat, path, 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);
|
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);
|
||||||
|
|
@ -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
|
/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds). */
|
||||||
* the network fetch synchronously so the response reflects fresh articles. */
|
|
||||||
static void api_rss_refresh(int fd, const char *body, size_t len) {
|
static void api_rss_refresh(int fd, const char *body, size_t len) {
|
||||||
json_t *req = read_body_json(body, len);
|
json_t *req = read_body_json(body, len);
|
||||||
const char *name = json_string_value(json_object_get(req, "name"));
|
const char *name = json_string_value(json_object_get(req, "name"));
|
||||||
/* find matching index(es) under the lock, then poll outside it */
|
char target[256] = {0};
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (name) snprintf(target, sizeof target, "%s", name);
|
||||||
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);
|
json_decref(req);
|
||||||
if (target >= 0) {
|
if (g_webui.store) {
|
||||||
rss_poll_feed_by_index((size_t)target);
|
json_t *targets = json_array();
|
||||||
} else if (!name || !*name) {
|
webui_store_feed_targets(g_webui.store, targets);
|
||||||
for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++)
|
size_t i; json_t *t;
|
||||||
rss_poll_feed_by_index(i);
|
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);
|
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) {
|
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);
|
||||||
const char *name = json_string_value(json_object_get(req, "name"));
|
const char *name = json_string_value(json_object_get(req, "name"));
|
||||||
bool changed = false;
|
if (g_webui.store && name && *name) {
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (remove) webui_store_indexer_remove(g_webui.store, name);
|
||||||
if (name && *name) {
|
else webui_store_indexer_upsert(g_webui.store, req);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
||||||
json_decref(req);
|
json_decref(req);
|
||||||
if (changed) rss_save();
|
json_t *reply = json_array();
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (g_webui.store) webui_store_indexer_list(g_webui.store, reply);
|
||||||
json_t *reply = json_deep_copy(g_webui.indexers);
|
http_json(fd, 200, reply);
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
||||||
http_json(fd, 200, reply ? reply : json_array());
|
|
||||||
json_decref(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. */
|
/* GET /api/search?q=… queries every enabled Torznab indexer and merges rows. */
|
||||||
static void api_search(int fd, const char *query) {
|
static void api_search(int fd, const char *query) {
|
||||||
json_t *results = json_array();
|
json_t *results = json_array();
|
||||||
/* snapshot the indexer list under the lock */
|
json_t *indexers = json_array();
|
||||||
pthread_mutex_lock(&g_webui.rss_lock);
|
if (g_webui.store) webui_store_indexer_list(g_webui.store, indexers);
|
||||||
json_t *indexers = json_deep_copy(g_webui.indexers);
|
|
||||||
pthread_mutex_unlock(&g_webui.rss_lock);
|
|
||||||
|
|
||||||
size_t i; json_t *ix;
|
size_t i; json_t *ix;
|
||||||
json_array_foreach(indexers, i, ix) {
|
json_array_foreach(indexers, i, ix) {
|
||||||
|
|
@ -3264,9 +3131,6 @@ naut_err naut_plugin_register(const naut_host_api *host) {
|
||||||
goto fail_store;
|
goto fail_store;
|
||||||
pthread_mutex_init(&g_webui.rss_lock, NULL);
|
pthread_mutex_init(&g_webui.rss_lock, NULL);
|
||||||
pthread_cond_init(&g_webui.rss_cond, 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();
|
init_auth();
|
||||||
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
||||||
"webui");
|
"webui");
|
||||||
|
|
@ -3339,13 +3203,6 @@ naut_err naut_plugin_shutdown(void) {
|
||||||
g_webui.categories = NULL;
|
g_webui.categories = NULL;
|
||||||
g_webui.tags = NULL;
|
g_webui.tags = NULL;
|
||||||
g_webui.assignments = 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_cond_destroy(&g_webui.rss_cond);
|
||||||
pthread_mutex_destroy(&g_webui.rss_lock);
|
pthread_mutex_destroy(&g_webui.rss_lock);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ struct webui_store {
|
||||||
pthread_mutex_t lock;
|
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 void to_hex(const unsigned char *in, size_t n, char *out) {
|
||||||
static const char hex[] = "0123456789abcdef";
|
static const char hex[] = "0123456789abcdef";
|
||||||
for (size_t i = 0; i < n; i++) {
|
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 ("
|
"CREATE TABLE IF NOT EXISTS feeds ("
|
||||||
" name TEXT PRIMARY KEY,"
|
" name TEXT PRIMARY KEY,"
|
||||||
" url TEXT NOT NULL,"
|
" url TEXT NOT NULL,"
|
||||||
" last_update INTEGER NOT NULL DEFAULT 0,"
|
" last_update INTEGER NOT NULL DEFAULT 0);"
|
||||||
" articles TEXT NOT NULL DEFAULT '[]');"
|
"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 ("
|
"CREATE TABLE IF NOT EXISTS rules ("
|
||||||
" name TEXT PRIMARY KEY,"
|
" name TEXT PRIMARY KEY,"
|
||||||
" enabled INTEGER NOT NULL DEFAULT 1,"
|
" 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 '',"
|
" must_not_contain TEXT NOT NULL DEFAULT '',"
|
||||||
" assigned_category TEXT NOT NULL DEFAULT '',"
|
" assigned_category TEXT NOT NULL DEFAULT '',"
|
||||||
" save_path TEXT NOT NULL DEFAULT '',"
|
" save_path TEXT NOT NULL DEFAULT '',"
|
||||||
" affected_feeds TEXT NOT NULL DEFAULT '[]',"
|
|
||||||
" last_match INTEGER NOT NULL DEFAULT 0);"
|
" 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 ("
|
"CREATE TABLE IF NOT EXISTS indexers ("
|
||||||
" name TEXT PRIMARY KEY,"
|
" name TEXT PRIMARY KEY,"
|
||||||
" url TEXT NOT NULL DEFAULT '',"
|
" url TEXT NOT NULL DEFAULT '',"
|
||||||
|
|
@ -105,6 +129,7 @@ webui_store *webui_store_open(const char *path) {
|
||||||
webui_store_close(s);
|
webui_store_close(s);
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
legacy_migrate(s); /* upgrade an older DB's RSS schema in place */
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,20 +414,14 @@ bool webui_store_load_tags(webui_store *s, json_t *out) {
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- RSS ------------------------------------------------------------------ */
|
/* --- RSS (fully relational) ----------------------------------------------- */
|
||||||
|
|
||||||
static int int_of(json_t *o, const char *k) {
|
static int int_of(json_t *o, const char *k) {
|
||||||
return json_boolean_value(json_object_get(o, k)) ? 1 : 0;
|
return json_boolean_value(json_object_get(o, k)) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bind a json array column as a compact JSON string (SQLite copies it). */
|
/* Parse a TEXT column holding a JSON array; returns a new array (never NULL).
|
||||||
static void bind_json_array(sqlite3_stmt *st, int col, json_t *arr) {
|
* Only used by the legacy-schema migration. */
|
||||||
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). */
|
|
||||||
static json_t *array_col(sqlite3_stmt *st, int col) {
|
static json_t *array_col(sqlite3_stmt *st, int col) {
|
||||||
const char *txt = (const char *)sqlite3_column_text(st, col);
|
const char *txt = (const char *)sqlite3_column_text(st, col);
|
||||||
if (txt) {
|
if (txt) {
|
||||||
|
|
@ -413,26 +432,132 @@ static json_t *array_col(sqlite3_stmt *st, int col) {
|
||||||
return json_array();
|
return json_array();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void bind_feed(sqlite3_stmt *st, json_t *f) {
|
/* ---- feeds ---- */
|
||||||
sqlite3_bind_text(st, 1, str_or(f, "name", ""), -1, SQLITE_TRANSIENT);
|
|
||||||
sqlite3_bind_text(st, 2, str_or(f, "url", ""), -1, SQLITE_TRANSIENT);
|
bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url) {
|
||||||
sqlite3_bind_int64(st, 3, (sqlite3_int64)json_integer_value(json_object_get(f, "lastUpdate")));
|
if (!s || !name || !*name || !url || !*url) return false;
|
||||||
bind_json_array(st, 4, json_object_get(f, "articles"));
|
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) {
|
bool webui_store_feed_remove(webui_store *s, const char *name) {
|
||||||
return replace_table(s, "DELETE FROM feeds;",
|
if (!s || !name) return false;
|
||||||
"INSERT OR REPLACE INTO feeds (name, url, last_update, articles) VALUES (?,?,?,?);",
|
pthread_mutex_lock(&s->lock);
|
||||||
feeds, bind_feed);
|
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;
|
if (!s || !json_is_array(out)) return false;
|
||||||
pthread_mutex_lock(&s->lock);
|
pthread_mutex_lock(&s->lock);
|
||||||
sqlite3_stmt *st = NULL;
|
sqlite3_stmt *st = NULL;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
if (sqlite3_prepare_v2(s->db,
|
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) {
|
-1, &st, NULL) == SQLITE_OK) {
|
||||||
ok = true;
|
ok = true;
|
||||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
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}",
|
json_array_append_new(out, json_pack("{s:s,s:s,s:I,s:o}",
|
||||||
"name", n ? n : "", "url", u ? u : "",
|
"name", n ? n : "", "url", u ? u : "",
|
||||||
"lastUpdate", (json_int_t)sqlite3_column_int64(st, 2),
|
"lastUpdate", (json_int_t)sqlite3_column_int64(st, 2),
|
||||||
"articles", array_col(st, 3)));
|
"articles", feed_articles_locked(s, n ? n : "")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sqlite3_finalize(st);
|
sqlite3_finalize(st);
|
||||||
|
|
@ -449,7 +574,98 @@ bool webui_store_load_feeds(webui_store *s, json_t *out) {
|
||||||
return ok;
|
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_text(st, 1, str_or(r, "name", ""), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_bind_int(st, 2, int_of(r, "enabled"));
|
sqlite3_bind_int(st, 2, int_of(r, "enabled"));
|
||||||
sqlite3_bind_int(st, 3, int_of(r, "useRegex"));
|
sqlite3_bind_int(st, 3, int_of(r, "useRegex"));
|
||||||
|
|
@ -458,67 +674,198 @@ 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, 6, str_or(r, "mustNotContain", ""), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_bind_text(st, 7, str_or(r, "assignedCategory", ""), -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);
|
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, 9, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch")));
|
||||||
sqlite3_bind_int64(st, 10, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool webui_store_save_rules(webui_store *s, json_t *rules) {
|
bool webui_store_rule_upsert(webui_store *s, json_t *r) {
|
||||||
return replace_table(s, "DELETE FROM rules;",
|
if (!s || !json_is_object(r)) return false;
|
||||||
"INSERT OR REPLACE INTO rules (name, enabled, use_regex, add_paused,"
|
const char *name = str_or(r, "name", "");
|
||||||
" must_contain, must_not_contain, assigned_category, save_path,"
|
if (!*name) return false;
|
||||||
" affected_feeds, last_match) VALUES (?,?,?,?,?,?,?,?,?,?);",
|
pthread_mutex_lock(&s->lock);
|
||||||
rules, bind_rule);
|
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_load_rules(webui_store *s, json_t *out) {
|
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 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);
|
||||||
|
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),
|
||||||
|
"addPaused", sqlite3_column_int(st, 3),
|
||||||
|
"mustContain", mc ? mc : "",
|
||||||
|
"mustNotContain", mn ? mn : "",
|
||||||
|
"assignedCategory", ac ? ac : "",
|
||||||
|
"savePath", sp ? sp : "",
|
||||||
|
"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;
|
if (!s || !json_is_array(out)) return false;
|
||||||
pthread_mutex_lock(&s->lock);
|
pthread_mutex_lock(&s->lock);
|
||||||
sqlite3_stmt *st = NULL;
|
sqlite3_stmt *st = NULL;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
if (sqlite3_prepare_v2(s->db,
|
char sql[256];
|
||||||
"SELECT name, enabled, use_regex, add_paused, must_contain,"
|
snprintf(sql, sizeof sql, "%s ORDER BY name;", RULE_COLS);
|
||||||
" must_not_contain, assigned_category, save_path, affected_feeds,"
|
if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) {
|
||||||
" last_match FROM rules ORDER BY name;", -1, &st, NULL) == SQLITE_OK) {
|
|
||||||
ok = true;
|
ok = true;
|
||||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
while (sqlite3_step(st) == SQLITE_ROW)
|
||||||
const char *n = (const char *)sqlite3_column_text(st, 0);
|
json_array_append_new(out, rule_row_to_json(st, s));
|
||||||
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}",
|
|
||||||
"name", n ? n : "",
|
|
||||||
"enabled", sqlite3_column_int(st, 1),
|
|
||||||
"useRegex", sqlite3_column_int(st, 2),
|
|
||||||
"addPaused", sqlite3_column_int(st, 3),
|
|
||||||
"mustContain", mc ? mc : "",
|
|
||||||
"mustNotContain", mn ? mn : "",
|
|
||||||
"assignedCategory", ac ? ac : "",
|
|
||||||
"savePath", sp ? sp : "",
|
|
||||||
"affectedFeeds", array_col(st, 8),
|
|
||||||
"lastMatch", (json_int_t)sqlite3_column_int64(st, 9)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sqlite3_finalize(st);
|
sqlite3_finalize(st);
|
||||||
pthread_mutex_unlock(&s->lock);
|
pthread_mutex_unlock(&s->lock);
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void bind_indexer(sqlite3_stmt *st, json_t *x) {
|
json_t *webui_store_rule_get(webui_store *s, const char *name) {
|
||||||
sqlite3_bind_text(st, 1, str_or(x, "name", ""), -1, SQLITE_TRANSIENT);
|
if (!s || !name) return NULL;
|
||||||
sqlite3_bind_text(st, 2, str_or(x, "url", ""), -1, SQLITE_TRANSIENT);
|
pthread_mutex_lock(&s->lock);
|
||||||
sqlite3_bind_text(st, 3, str_or(x, "apikey", ""), -1, SQLITE_TRANSIENT);
|
sqlite3_stmt *st = NULL;
|
||||||
sqlite3_bind_int(st, 4, int_of(x, "enabled"));
|
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_save_indexers(webui_store *s, json_t *indexers) {
|
bool webui_store_rule_set_match(webui_store *s, const char *name, long ts) {
|
||||||
return replace_table(s, "DELETE FROM indexers;",
|
if (!s || !name) return false;
|
||||||
"INSERT OR REPLACE INTO indexers (name, url, apikey, enabled) VALUES (?,?,?,?);",
|
pthread_mutex_lock(&s->lock);
|
||||||
indexers, bind_indexer);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool webui_store_load_indexers(webui_store *s, json_t *out) {
|
/* ---- 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, 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_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_indexer_list(webui_store *s, json_t *out) {
|
||||||
if (!s || !json_is_array(out)) return false;
|
if (!s || !json_is_array(out)) return false;
|
||||||
pthread_mutex_lock(&s->lock);
|
pthread_mutex_lock(&s->lock);
|
||||||
sqlite3_stmt *st = NULL;
|
sqlite3_stmt *st = NULL;
|
||||||
|
|
@ -540,3 +887,117 @@ bool webui_store_load_indexers(webui_store *s, json_t *out) {
|
||||||
pthread_mutex_unlock(&s->lock);
|
pthread_mutex_unlock(&s->lock);
|
||||||
return ok;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_save_tags(webui_store *s, json_t *tags);
|
||||||
bool webui_store_load_tags(webui_store *s, json_t *out);
|
bool webui_store_load_tags(webui_store *s, json_t *out);
|
||||||
|
|
||||||
/* --- RSS: feeds, auto-download rules, Torznab indexers (owned here) -------- *
|
/* --- RSS: feeds, articles, auto-download rules, Torznab indexers ----------- *
|
||||||
* save_* replace the whole list atomically; load_* append to the array `out`,
|
* Fully relational: articles live in their own table (deduped by feed+key,
|
||||||
* rebuilding the exact JSON shapes the web layer/UI use. */
|
* indexed), and a rule's feed scope lives in a rule_feeds join table. The web
|
||||||
bool webui_store_save_feeds(webui_store *s, json_t *feeds);
|
* layer operates on rows, not whole-list blobs. */
|
||||||
bool webui_store_load_feeds(webui_store *s, json_t *out);
|
|
||||||
bool webui_store_save_rules(webui_store *s, json_t *rules);
|
/* Feeds. upsert preserves an existing feed's lastUpdate (only the url changes);
|
||||||
bool webui_store_load_rules(webui_store *s, json_t *out);
|
* remove also drops the feed's articles. feed_list appends
|
||||||
bool webui_store_save_indexers(webui_store *s, json_t *indexers);
|
* {name,url,lastUpdate,articles:[...]} (newest article first). feed_targets
|
||||||
bool webui_store_load_indexers(webui_store *s, json_t *out);
|
* 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 */
|
#endif /* NAUT_WEBUI_STORE_H */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue