webui: normalize the RSS schema (articles + rule_feeds tables)

Replace the JSON-blob columns (feeds.articles, rules.affected_feeds)
with proper relational tables:

- articles(feed,key,…,is_read,grabbed) with UNIQUE(feed,key) and indexes,
  so dedup and grabbed become INSERT OR IGNORE / WHERE-key queries and a
  poll inserts only new rows instead of rewriting the whole feed blob.
- rule_feeds(rule,feed) join table for a rule's feed scope.

The webui RSS engine now operates on rows via a row-level store API
(feed/article/rule/indexer upsert/list/etc.) instead of holding the
feeds/rules/indexers in memory and saving whole lists; the in-memory
copies and rss_save/rss_load are gone. The poller, the auto-download
rules, force-run, manual download, refresh and search all read/write the
DB directly. A one-time migration upgrades an existing webui.db in place
(moving the old JSON blobs into the new tables, preserving article
read/grabbed flags and rule scoping, then dropping the legacy columns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-24 01:59:36 -04:00
parent 91d4b99aee
commit a0265cc1ea
3 changed files with 700 additions and 354 deletions

View file

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