diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index 9d51e95..2f4a2fd 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -95,12 +95,10 @@ typedef struct { json_t *tags; /* array of tag name strings */ json_t *assignments; /* object keyed by stringified torrent id */ - /* RSS: feeds + auto-download rules, polled by a background thread and - * persisted via the daemon blob store. Search indexers live here too. */ + /* RSS feeds, articles, auto-download rules and Torznab indexers all live in + * the webui database (g_webui.store). A background thread polls feeds; this + * lock/cond only guards the poller's wake-up, not any data. */ pthread_mutex_t rss_lock; - json_t *rss_feeds; /* array of {name,url,lastUpdate,articles:[...]} */ - json_t *rss_rules; /* array of rule objects */ - json_t *indexers; /* array of {name,url,apikey,enabled} (Torznab) */ pthread_t rss_thread; bool rss_thread_started; pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */ @@ -1362,15 +1360,7 @@ static void api_meta(int fd) { json_object_set_new(json, "preferences", preferences); /* searchPlugins mirrors the configured Torznab indexers for the Search tab. */ json_t *plugins = json_array(); - pthread_mutex_lock(&g_webui.rss_lock); - size_t ii; json_t *ix; - json_array_foreach(g_webui.indexers, ii, ix) - json_array_append_new(plugins, json_pack("{s:s,s:s,s:s,s:b}", - "name", json_string_or(ix, "name", ""), - "url", json_string_or(ix, "url", ""), - "apikey", json_string_or(ix, "apikey", ""), - "enabled", json_boolean_value(json_object_get(ix, "enabled")))); - pthread_mutex_unlock(&g_webui.rss_lock); + if (g_webui.store) webui_store_indexer_list(g_webui.store, plugins); json_object_set_new(json, "searchPlugins", plugins); http_json(fd, 200, json); json_decref(json); @@ -1950,32 +1940,6 @@ static char *base64_encode(const unsigned char *in, size_t len) { /* --- RSS persistence (via the daemon blob store) -------------------------- */ -/* Persist feeds/rules/indexers to the web-UI's own database. */ -static void rss_save(void) { - if (!g_webui.store) return; - pthread_mutex_lock(&g_webui.rss_lock); - json_t *feeds = json_deep_copy(g_webui.rss_feeds); - json_t *rules = json_deep_copy(g_webui.rss_rules); - json_t *idx = json_deep_copy(g_webui.indexers); - pthread_mutex_unlock(&g_webui.rss_lock); - if (feeds) { webui_store_save_feeds(g_webui.store, feeds); json_decref(feeds); } - if (rules) { webui_store_save_rules(g_webui.store, rules); json_decref(rules); } - if (idx) { webui_store_save_indexers(g_webui.store, idx); json_decref(idx); } -} - -static void rss_load(void) { - if (!g_webui.store) return; - json_t *feeds = json_array(), *rules = json_array(), *idx = json_array(); - bool of = webui_store_load_feeds(g_webui.store, feeds); - bool orr = webui_store_load_rules(g_webui.store, rules); - bool oi = webui_store_load_indexers(g_webui.store, idx); - pthread_mutex_lock(&g_webui.rss_lock); - if (of) { json_decref(g_webui.rss_feeds); g_webui.rss_feeds = feeds; } else json_decref(feeds); - if (orr) { json_decref(g_webui.rss_rules); g_webui.rss_rules = rules; } else json_decref(rules); - if (oi) { json_decref(g_webui.indexers); g_webui.indexers = idx; } else json_decref(idx); - pthread_mutex_unlock(&g_webui.rss_lock); -} - /* --- auto-download: hand a matched article to the daemon ------------------ */ /* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and @@ -2061,20 +2025,9 @@ static bool rule_matches(json_t *rule, const char *feed_name, const char *title) return true; } -/* Mark the article with this key as grabbed (across all feeds), so an - * auto-download rule re-run won't fetch it again. */ +/* Mark every article with this key as grabbed, so a rule re-run skips it. */ static void rss_mark_grabbed(const char *key) { - if (!key || !*key) return; - pthread_mutex_lock(&g_webui.rss_lock); - size_t fi; json_t *feed; - json_array_foreach(g_webui.rss_feeds, fi, feed) { - json_t *articles = json_object_get(feed, "articles"); - size_t ai; json_t *a; - json_array_foreach(articles, ai, a) - if (strcmp(json_string_or(a, "key", ""), key) == 0) - json_object_set_new(a, "grabbed", json_true()); - } - pthread_mutex_unlock(&g_webui.rss_lock); + if (g_webui.store) webui_store_article_mark_grabbed(g_webui.store, key); } /* Download an article and, on success, flag it grabbed by key. */ @@ -2086,40 +2039,40 @@ static bool rss_grab_article(const char *key, const char *title, return ok; } -/* Run every rule against a freshly-seen article; download the first match. */ +/* Run the auto-download rules against one freshly-seen article; download the + * first enabled rule that matches. */ static void rss_run_rules(const char *feed_name, const char *key, const char *title, const char *magnet, const char *torrent_url) { + if (!g_webui.store) return; + json_t *rules = json_array(); + if (!webui_store_rule_list(g_webui.store, rules)) { json_decref(rules); return; } + char cat[128] = {0}, path[1024] = {0}, rule_name[128] = {0}; + bool paused = false, fire = false; size_t i; json_t *rule; - json_t *fire = NULL; char cat[128] = {0}, path[1024] = {0}; bool paused = false; - pthread_mutex_lock(&g_webui.rss_lock); - json_array_foreach(g_webui.rss_rules, i, rule) { + json_array_foreach(rules, i, rule) { if (rule_matches(rule, feed_name, title)) { snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); + snprintf(rule_name, sizeof rule_name, "%s", json_string_or(rule, "name", "")); paused = json_boolean_value(json_object_get(rule, "addPaused")); - json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL))); - fire = rule; + fire = true; break; } } - pthread_mutex_unlock(&g_webui.rss_lock); + json_decref(rules); if (!fire) return; + webui_store_rule_set_match(g_webui.store, rule_name, (long)time(NULL)); if (rss_grab_article(key, title, magnet, torrent_url, cat, path, paused)) log_msg(2, "rss: auto-downloaded a match"); } -/* Parse a feed body into article objects and merge new ones into `feed`. - * Newly-seen articles are appended to `out_new` (as {title,magnet,torrentUrl}) - * so the caller can fire auto-download rules AFTER releasing rss_lock — running - * them here would re-enter the lock (and do network I/O while holding it). - * Returns the number of newly-seen articles. */ -static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new) { - json_t *articles = json_object_get(feed, "articles"); - if (!json_is_array(articles)) { - articles = json_array(); - json_object_set_new(feed, "articles", articles); - } +/* Parse a feed body, inserting newly-seen articles into the store. Each new + * article is appended to out_new ({key,title,magnet,torrentUrl}) so the caller + * can fire rules afterward. Returns the number newly inserted. */ +static int rss_ingest(const char *feed_name, const char *xml, size_t len, + json_t *out_new) { + if (!g_webui.store) return 0; int added = 0; const char *p = xml, *end = xml + len; for (;;) { @@ -2154,80 +2107,65 @@ static int rss_ingest(json_t *feed, const char *xml, size_t len, json_t *out_new const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link); if (title[0] && key && *key) { - /* dedupe against existing articles by their key */ - bool seen = false; size_t ai; json_t *a; - json_array_foreach(articles, ai, a) { - if (strcmp(json_string_or(a, "key", ""), key) == 0) { seen = true; break; } - } - if (!seen) { - json_t *art = json_pack( - "{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}", - "title", title, "key", key, - "magnet", magnet, "torrentUrl", dl_url, "link", link, - "size", (json_int_t)strtoll(lenstr, NULL, 10), - "pubDate", pub, "isRead", 0, "grabbed", 0); - json_array_insert_new(articles, 0, art); + json_t *art = json_pack("{s:s,s:s,s:s,s:s,s:s,s:I,s:s}", + "key", key, "title", title, "magnet", magnet, "torrentUrl", dl_url, + "link", link, "size", (json_int_t)strtoll(lenstr, NULL, 10), + "pubDate", pub); + int rc = art ? webui_store_article_add(g_webui.store, feed_name, art) : -1; + json_decref(art); + if (rc == 1) { added++; if (out_new) - json_array_append_new(out_new, json_pack( - "{s:s,s:s,s:s,s:s}", "title", title, "key", key, - "magnet", magnet, "torrentUrl", dl_url)); + json_array_append_new(out_new, json_pack("{s:s,s:s,s:s,s:s}", + "key", key, "title", title, "magnet", magnet, + "torrentUrl", dl_url)); } } p = close + strlen(close_tag); } - /* trim to the newest RSS_MAX_ARTICLES */ - while (json_array_size(articles) > RSS_MAX_ARTICLES) - json_array_remove(articles, json_array_size(articles) - 1); - json_object_set_new(feed, "lastUpdate", json_integer((json_int_t)time(NULL))); + webui_store_article_trim(g_webui.store, feed_name, RSS_MAX_ARTICLES); + webui_store_feed_set_updated(g_webui.store, feed_name, (long)time(NULL)); return added; } -/* Poll one feed (network I/O done without rss_lock held). */ -static void rss_poll_feed_by_index(size_t idx) { - pthread_mutex_lock(&g_webui.rss_lock); - json_t *feed = json_array_get(g_webui.rss_feeds, idx); - char url[1024] = {0}; - if (feed) snprintf(url, sizeof url, "%s", json_string_or(feed, "url", "")); - pthread_mutex_unlock(&g_webui.rss_lock); - if (!url[0]) return; - +/* Poll one feed by name+url (network I/O done without any lock held). */ +static void rss_poll_one(const char *name, const char *url) { + if (!name || !*name || !url || !*url) return; naut_http_response r; if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) { naut_http_response_free(&r); return; } - char feed_name[256] = {0}; json_t *new_articles = json_array(); - pthread_mutex_lock(&g_webui.rss_lock); - feed = json_array_get(g_webui.rss_feeds, idx); /* re-fetch under lock */ - int added = feed ? rss_ingest(feed, r.body, r.body_len, new_articles) : 0; - if (feed) snprintf(feed_name, sizeof feed_name, "%s", json_string_or(feed, "name", "")); - pthread_mutex_unlock(&g_webui.rss_lock); + rss_ingest(name, r.body, r.body_len, new_articles); naut_http_response_free(&r); - - /* fire auto-download rules now that rss_lock is released */ + /* fire auto-download rules for the newly-seen articles */ size_t i; json_t *a; json_array_foreach(new_articles, i, a) - rss_run_rules(feed_name, json_string_or(a, "key", ""), + rss_run_rules(name, json_string_or(a, "key", ""), json_string_or(a, "title", ""), json_string_or(a, "magnet", ""), json_string_or(a, "torrentUrl", "")); json_decref(new_articles); - if (added > 0) rss_save(); } static void rss_poll_all(void) { - pthread_mutex_lock(&g_webui.rss_lock); - size_t n = json_array_size(g_webui.rss_feeds); - pthread_mutex_unlock(&g_webui.rss_lock); - for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++) - rss_poll_feed_by_index(i); + if (!g_webui.store) return; + json_t *targets = json_array(); + webui_store_feed_targets(g_webui.store, targets); + size_t i; json_t *t; + json_array_foreach(targets, i, t) { + if (atomic_load(&g_webui.stopping)) break; + char name[256], url[1024]; + snprintf(name, sizeof name, "%s", json_string_or(t, "name", "")); + snprintf(url, sizeof url, "%s", json_string_or(t, "url", "")); + rss_poll_one(name, url); + } + json_decref(targets); } static void *rss_thread_fn(void *arg) { (void)arg; - rss_load(); while (!atomic_load(&g_webui.stopping)) { rss_poll_all(); pthread_mutex_lock(&g_webui.rss_lock); @@ -2243,15 +2181,21 @@ static void *rss_thread_fn(void *arg) { return NULL; } +static void rss_signal_wake(void) { + pthread_mutex_lock(&g_webui.rss_lock); + g_webui.rss_wake = true; + pthread_cond_signal(&g_webui.rss_cond); + pthread_mutex_unlock(&g_webui.rss_lock); +} + /* --- RSS HTTP API --------------------------------------------------------- */ /* GET /api/rss → array of feeds (with their articles). */ static void api_rss_list(int fd) { - pthread_mutex_lock(&g_webui.rss_lock); - json_t *reply = json_deep_copy(g_webui.rss_feeds); - pthread_mutex_unlock(&g_webui.rss_lock); - http_json(fd, 200, reply ? reply : json_array()); - json_decref(reply); + json_t *feeds = json_array(); + if (g_webui.store) webui_store_feed_list(g_webui.store, feeds); + http_json(fd, 200, feeds); + json_decref(feeds); } /* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */ @@ -2259,62 +2203,33 @@ static void api_rss_feed(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); const char *url = json_string_value(json_object_get(req, "url")); - bool changed = false; - pthread_mutex_lock(&g_webui.rss_lock); - if (remove && name) { - size_t i; json_t *f; - json_array_foreach(g_webui.rss_feeds, i, f) - if (strcmp(json_string_or(f, "name", ""), name) == 0) { - json_array_remove(g_webui.rss_feeds, i); changed = true; break; - } - } else if (name && *name && url && *url) { - /* upsert by name */ - size_t i; json_t *f; bool found = false; - json_array_foreach(g_webui.rss_feeds, i, f) - if (strcmp(json_string_or(f, "name", ""), name) == 0) { - json_object_set_new(f, "url", json_string(url)); found = true; break; - } - if (!found) - json_array_append_new(g_webui.rss_feeds, json_pack( - "{s:s,s:s,s:i,s:[]}", "name", name, "url", url, - "lastUpdate", 0, "articles")); - changed = true; + bool added = false; + if (g_webui.store && name && *name) { + if (remove) webui_store_feed_remove(g_webui.store, name); + else if (url && *url) added = webui_store_feed_upsert(g_webui.store, name, url); } - pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - if (changed) { - rss_save(); - pthread_mutex_lock(&g_webui.rss_lock); - g_webui.rss_wake = true; - pthread_cond_signal(&g_webui.rss_cond); /* re-poll the new feed now */ - pthread_mutex_unlock(&g_webui.rss_lock); - } + if (added) rss_signal_wake(); /* re-poll the new feed now */ api_rss_list(fd); } /* GET /api/rss/rules → array of rules. */ static void api_rss_rules_list(int fd) { - pthread_mutex_lock(&g_webui.rss_lock); - json_t *reply = json_deep_copy(g_webui.rss_rules); - pthread_mutex_unlock(&g_webui.rss_lock); - http_json(fd, 200, reply ? reply : json_array()); - json_decref(reply); + json_t *rules = json_array(); + if (g_webui.store) webui_store_rule_list(g_webui.store, rules); + http_json(fd, 200, rules); + json_decref(rules); } /* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */ static void api_rss_rule(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); - bool changed = false; - pthread_mutex_lock(&g_webui.rss_lock); - if (name && *name) { - size_t i; json_t *r; int at = -1; - json_array_foreach(g_webui.rss_rules, i, r) - if (strcmp(json_string_or(r, "name", ""), name) == 0) { at = (int)i; break; } + if (g_webui.store && name && *name) { if (remove) { - if (at >= 0) { json_array_remove(g_webui.rss_rules, (size_t)at); changed = true; } + webui_store_rule_remove(g_webui.store, name); } else { - json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:O,s:i}", + json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:i}", "name", name, "enabled", json_boolean_value(json_object_get(req, "enabled")), "useRegex", json_boolean_value(json_object_get(req, "useRegex")), @@ -2323,88 +2238,66 @@ static void api_rss_rule(int fd, const char *body, size_t len, bool remove) { "mustNotContain", json_string_or(req, "mustNotContain", ""), "assignedCategory", json_string_or(req, "assignedCategory", ""), "savePath", json_string_or(req, "savePath", ""), - "affectedFeeds", json_is_array(json_object_get(req, "affectedFeeds")) - ? json_object_get(req, "affectedFeeds") : json_array(), "lastMatch", 0); if (rule) { - if (at >= 0) json_array_set_new(g_webui.rss_rules, (size_t)at, rule); - else json_array_append_new(g_webui.rss_rules, rule); - changed = true; + json_t *af = json_object_get(req, "affectedFeeds"); + json_object_set_new(rule, "affectedFeeds", + json_is_array(af) ? json_deep_copy(af) : json_array()); + webui_store_rule_upsert(g_webui.store, rule); + json_decref(rule); } } } - pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - if (changed) rss_save(); api_rss_rules_list(fd); } -/* POST /api/rss/rules/run {name} — re-apply a rule to every article already in - * the feeds (not just newly-seen ones), downloading matches not yet grabbed. - * Used after editing a rule. Runs regardless of the rule's enabled flag. */ +/* POST /api/rss/rules/run {name} — re-apply a rule to every stored article (not + * just newly-seen ones), grabbing matches not yet grabbed. Runs regardless of + * the rule's enabled flag. */ static void api_rss_rule_run(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); - const char *name = json_string_value(json_object_get(req, "name")); - char cat[128] = {0}, path[1024] = {0}; bool paused = false; - json_t *todo = json_array(); /* {key,magnet,torrentUrl} to grab */ - - pthread_mutex_lock(&g_webui.rss_lock); - json_t *rule = NULL; size_t i; json_t *r; - if (name) json_array_foreach(g_webui.rss_rules, i, r) - if (strcmp(json_string_or(r, "name", ""), name) == 0) { rule = r; break; } - if (rule) { - snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); - snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); - paused = json_boolean_value(json_object_get(rule, "addPaused")); - /* match regardless of the enabled flag (explicit manual run) */ - json_t *probe = json_deep_copy(rule); - json_object_set_new(probe, "enabled", json_true()); - size_t fi; json_t *feed; - json_array_foreach(g_webui.rss_feeds, fi, feed) { - const char *fname = json_string_or(feed, "name", ""); - json_t *articles = json_object_get(feed, "articles"); - size_t ai; json_t *a; - json_array_foreach(articles, ai, a) { - if (json_boolean_value(json_object_get(a, "grabbed"))) continue; - const char *mag = json_string_or(a, "magnet", ""); - const char *url = json_string_or(a, "torrentUrl", ""); - if (!*mag && !*url) continue; - if (rule_matches(probe, fname, json_string_or(a, "title", ""))) - json_array_append_new(todo, json_pack("{s:s,s:s,s:s,s:s}", - "key", json_string_or(a, "key", ""), - "title", json_string_or(a, "title", ""), - "magnet", mag, "torrentUrl", url)); - } - } - json_decref(probe); - if (json_array_size(todo)) - json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL))); - } - bool found = rule != NULL; - pthread_mutex_unlock(&g_webui.rss_lock); + const char *rname = json_string_value(json_object_get(req, "name")); + char name[128] = {0}; + if (rname) snprintf(name, sizeof name, "%s", rname); json_decref(req); + json_t *rule = (g_webui.store && name[0]) ? webui_store_rule_get(g_webui.store, name) : NULL; + if (!rule) { http_text(fd, 404, "Not Found", "no such rule"); return; } + char cat[128], path[1024]; + snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); + snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); + bool paused = json_boolean_value(json_object_get(rule, "addPaused")); + json_object_set_new(rule, "enabled", json_true()); /* manual run */ + + json_t *cands = json_array(); + webui_store_articles_ungrabbed(g_webui.store, cands); + json_t *todo = json_array(); + size_t i; json_t *a; + json_array_foreach(cands, i, a) + if (rule_matches(rule, json_string_or(a, "feed", ""), json_string_or(a, "title", ""))) + json_array_append(todo, a); + json_decref(cands); + json_decref(rule); + int grabbed = 0; - size_t j; json_t *t; - json_array_foreach(todo, j, t) - if (rss_grab_article(json_string_or(t, "key", ""), json_string_or(t, "title", ""), - json_string_or(t, "magnet", ""), - json_string_or(t, "torrentUrl", ""), cat, path, paused)) + json_array_foreach(todo, i, a) + if (rss_grab_article(json_string_or(a, "key", ""), json_string_or(a, "title", ""), + json_string_or(a, "magnet", ""), json_string_or(a, "torrentUrl", ""), + cat, path, paused)) grabbed++; size_t matched = json_array_size(todo); json_decref(todo); - if (grabbed > 0) rss_save(); + if (matched) webui_store_rule_set_match(g_webui.store, name, (long)time(NULL)); - if (!found) { http_text(fd, 404, "Not Found", "no such rule"); return; } json_t *reply = json_pack("{s:b,s:i,s:i}", "ok", 1, "matched", (int)matched, "grabbed", grabbed); http_json(fd, 200, reply); json_decref(reply); } -/* POST /api/rss/download {magnet|torrentUrl, category, savePath, paused} - * Manually grab a torrent from a feed article or search result. Reuses the - * same add path as the auto-downloader (handles magnets and .torrent URLs). */ +/* POST /api/rss/download {magnet|torrentUrl, title, key, category, savePath, + * paused} — manually grab a torrent from a feed article or search result. */ static void api_rss_download(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); const char *magnet = json_string_or(req, "magnet", ""); @@ -2415,7 +2308,6 @@ static void api_rss_download(int fd, const char *body, size_t len) { const char *title = json_string_or(req, "title", ""); bool paused = json_boolean_value(json_object_get(req, "paused")); bool ok = rss_grab_article(key, title, magnet, url, cat, path, paused); - if (ok && *key) rss_save(); /* persist the grabbed flag */ json_decref(req); if (ok) { json_t *reply = json_pack("{s:b}", "ok", 1); @@ -2426,27 +2318,24 @@ static void api_rss_download(int fd, const char *body, size_t len) { } } -/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds), running - * the network fetch synchronously so the response reflects fresh articles. */ +/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds). */ static void api_rss_refresh(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); - /* find matching index(es) under the lock, then poll outside it */ - pthread_mutex_lock(&g_webui.rss_lock); - size_t n = json_array_size(g_webui.rss_feeds); - int target = -1; - if (name && *name) { - size_t i; json_t *fd_j; - json_array_foreach(g_webui.rss_feeds, i, fd_j) - if (strcmp(json_string_or(fd_j, "name", ""), name) == 0) { target = (int)i; break; } - } - pthread_mutex_unlock(&g_webui.rss_lock); + char target[256] = {0}; + if (name) snprintf(target, sizeof target, "%s", name); json_decref(req); - if (target >= 0) { - rss_poll_feed_by_index((size_t)target); - } else if (!name || !*name) { - for (size_t i = 0; i < n && !atomic_load(&g_webui.stopping); i++) - rss_poll_feed_by_index(i); + if (g_webui.store) { + json_t *targets = json_array(); + webui_store_feed_targets(g_webui.store, targets); + size_t i; json_t *t; + json_array_foreach(targets, i, t) { + if (atomic_load(&g_webui.stopping)) break; + const char *fn = json_string_or(t, "name", ""); + if (target[0] && strcmp(target, fn) != 0) continue; + rss_poll_one(fn, json_string_or(t, "url", "")); + } + json_decref(targets); } api_rss_list(fd); } @@ -2455,34 +2344,14 @@ static void api_rss_refresh(int fd, const char *body, size_t len) { static void api_indexer(int fd, const char *body, size_t len, bool remove) { json_t *req = read_body_json(body, len); const char *name = json_string_value(json_object_get(req, "name")); - bool changed = false; - pthread_mutex_lock(&g_webui.rss_lock); - if (name && *name) { - size_t i; json_t *ix; int at = -1; - json_array_foreach(g_webui.indexers, i, ix) - if (strcmp(json_string_or(ix, "name", ""), name) == 0) { at = (int)i; break; } - if (remove) { - if (at >= 0) { json_array_remove(g_webui.indexers, (size_t)at); changed = true; } - } else { - json_t *e = json_pack("{s:s,s:s,s:s,s:b}", "name", name, - "url", json_string_or(req, "url", ""), - "apikey", json_string_or(req, "apikey", ""), - "enabled", json_object_get(req, "enabled") - ? json_boolean_value(json_object_get(req, "enabled")) : true); - if (e) { - if (at >= 0) json_array_set_new(g_webui.indexers, (size_t)at, e); - else json_array_append_new(g_webui.indexers, e); - changed = true; - } - } + if (g_webui.store && name && *name) { + if (remove) webui_store_indexer_remove(g_webui.store, name); + else webui_store_indexer_upsert(g_webui.store, req); } - pthread_mutex_unlock(&g_webui.rss_lock); json_decref(req); - if (changed) rss_save(); - pthread_mutex_lock(&g_webui.rss_lock); - json_t *reply = json_deep_copy(g_webui.indexers); - pthread_mutex_unlock(&g_webui.rss_lock); - http_json(fd, 200, reply ? reply : json_array()); + json_t *reply = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, reply); + http_json(fd, 200, reply); json_decref(reply); } @@ -2544,10 +2413,8 @@ static json_t *torznab_parse(const char *xml, size_t len, const char *engine) { /* GET /api/search?q=… queries every enabled Torznab indexer and merges rows. */ static void api_search(int fd, const char *query) { json_t *results = json_array(); - /* snapshot the indexer list under the lock */ - pthread_mutex_lock(&g_webui.rss_lock); - json_t *indexers = json_deep_copy(g_webui.indexers); - pthread_mutex_unlock(&g_webui.rss_lock); + json_t *indexers = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, indexers); size_t i; json_t *ix; json_array_foreach(indexers, i, ix) { @@ -3264,9 +3131,6 @@ naut_err naut_plugin_register(const naut_host_api *host) { goto fail_store; pthread_mutex_init(&g_webui.rss_lock, NULL); pthread_cond_init(&g_webui.rss_cond, NULL); - g_webui.rss_feeds = json_array(); - g_webui.rss_rules = json_array(); - g_webui.indexers = json_array(); init_auth(); error = g_webui.host.set_plugin_name(g_webui.host.host_context, "webui"); @@ -3339,13 +3203,6 @@ naut_err naut_plugin_shutdown(void) { g_webui.categories = NULL; g_webui.tags = NULL; g_webui.assignments = NULL; - - json_decref(g_webui.rss_feeds); - json_decref(g_webui.rss_rules); - json_decref(g_webui.indexers); - g_webui.rss_feeds = NULL; - g_webui.rss_rules = NULL; - g_webui.indexers = NULL; pthread_cond_destroy(&g_webui.rss_cond); pthread_mutex_destroy(&g_webui.rss_lock); diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c index a388cbf..2e42cbd 100644 --- a/plugins/webui/webui_store.c +++ b/plugins/webui/webui_store.c @@ -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,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, 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_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_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; pthread_mutex_lock(&s->lock); sqlite3_stmt *st = NULL; bool ok = false; - 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) { + 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) { - 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}", - "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))); - } + 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); - 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")); +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_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_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; } -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; 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); +} diff --git a/plugins/webui/webui_store.h b/plugins/webui/webui_store.h index fc5c9c6..4207823 100644 --- a/plugins/webui/webui_store.h +++ b/plugins/webui/webui_store.h @@ -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 */