webui: RSS auto-download + Torznab search backend

Add a real backend behind the RSS and Search tabs:

- nautd gains a generic web-UI blob store (set/get_webui_blob) so the
  web layer can persist RSS feeds, auto-download rules and indexer
  config under <state_dir>/webui_<key>.json.
- The webui plugin runs a background poller that fetches each feed over
  HTTP(S), parses RSS 2.0 / Atom items (title, link, enclosure, size,
  pubDate, magnet incl. torrent:magnetURI), dedupes, and stores articles.
- Auto-download rules (substring or POSIX regex, mustContain/
  mustNotContain, per-feed scope) fire on newly-seen items and add the
  torrent via the daemon — from a magnet, or by fetching a .torrent
  enclosure and uploading its bytes — applying category/save path/paused.
- Search queries every enabled Torznab indexer and merges results
  (name, size, seeders, leechers, magnet/.torrent), exposed as
  searchPlugins in /api/meta.

New endpoints: GET/POST /api/rss(+/delete), /api/rss/rules(+/delete),
/api/indexers(+/delete), GET /api/search?q=.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 00:56:04 -04:00
parent 0393ed429b
commit d6e0e51175
2 changed files with 802 additions and 9 deletions

View file

@ -121,6 +121,8 @@ struct daemon_state {
json_t *label_categories; /* array of {name, savePath} */
json_t *label_tags; /* array of tag name strings */
char taxonomy_file[PATH_MAX]; /* <state_dir>/labels.json */
char data_dir[PATH_MAX]; /* the resolved state dir (for blobs) */
pthread_mutex_t blob_lock; /* guards webui_<key>.json blob files */
pthread_mutex_t torrent_lock;
torrent_task *torrents[MAX_TORRENTS];
size_t torrent_count;
@ -1401,6 +1403,70 @@ static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params,
return json_object();
}
/* --- generic web-UI blob store (RSS feeds, indexer config, ...) ------------ *
* The web layer owns these schemas; the daemon only persists them, one JSON
* document per key, under <state_dir>/webui_<key>.json. Keys are sanitized to a
* safe filename charset so a key can never escape the state directory. */
static bool blob_path(daemon_state *state, const char *key, char *out, size_t n) {
if (!key || !*key || !state->data_dir[0]) return false;
char safe[64];
size_t j = 0;
for (size_t i = 0; key[i] && j + 1 < sizeof safe; i++) {
char c = key[i];
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-')
safe[j++] = c;
}
safe[j] = 0;
if (j == 0) return false;
return (size_t)snprintf(out, n, "%s/webui_%s.json", state->data_dir, safe) < n;
}
static json_t *rpc_get_webui_blob(void *opaque, const json_t *params,
naut_err *error) {
daemon_state *state = opaque;
const char *key = json_string_value(json_object_get(params, "key"));
char path[PATH_MAX];
if (!blob_path(state, key, path, sizeof path)) {
*error = NAUT_ERR_INVAL;
return NULL;
}
pthread_mutex_lock(&state->blob_lock);
json_error_t jerr;
json_t *value = json_load_file(path, 0, &jerr);
pthread_mutex_unlock(&state->blob_lock);
json_t *out = json_object();
if (!out) { json_decref(value); *error = NAUT_ERR_NOMEM; return NULL; }
json_object_set_new(out, "value", value ? value : json_null());
*error = NAUT_OK;
return out;
}
static json_t *rpc_set_webui_blob(void *opaque, const json_t *params,
naut_err *error) {
daemon_state *state = opaque;
const char *key = json_string_value(json_object_get(params, "key"));
json_t *value = json_object_get(params, "value");
char path[PATH_MAX];
if (!value || !blob_path(state, key, path, sizeof path)) {
*error = NAUT_ERR_INVAL;
return NULL;
}
*error = NAUT_OK;
if (!state->persist_enabled) return json_object();
pthread_mutex_lock(&state->blob_lock);
char tmp[PATH_MAX + 8];
snprintf(tmp, sizeof tmp, "%s.tmp", path);
if (json_dump_file(value, tmp, JSON_INDENT(2)) != 0 ||
rename(tmp, path) != 0) {
NAUT_WARN("persist: write %s failed", path);
unlink(tmp);
}
pthread_mutex_unlock(&state->blob_lock);
return json_object();
}
/* --- data-overlap guard (block torrents that would write the same files) --- */
static uint8_t *slurp_file(const char *path, size_t *len) {
@ -2329,6 +2395,8 @@ static bool register_commands(daemon_state *state) {
naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK &&
naut_rpc_register(state->rpc, "get_label_taxonomy", rpc_get_label_taxonomy, state) == NAUT_OK &&
naut_rpc_register(state->rpc, "set_label_taxonomy", rpc_set_label_taxonomy, state) == NAUT_OK &&
naut_rpc_register(state->rpc, "get_webui_blob", rpc_get_webui_blob, state) == NAUT_OK &&
naut_rpc_register(state->rpc, "set_webui_blob", rpc_set_webui_blob, state) == NAUT_OK &&
naut_rpc_register(state->rpc, "unload_script", rpc_unload_script, state) == NAUT_OK &&
naut_rpc_register(state->rpc, "shutdown", rpc_shutdown, state) == NAUT_OK;
}
@ -2620,6 +2688,9 @@ static bool resolve_state_dir(daemon_state *state, const char *override) {
if ((size_t)snprintf(state->taxonomy_file, sizeof state->taxonomy_file,
"%s/labels.json", dir) >= sizeof state->taxonomy_file)
return false;
if ((size_t)snprintf(state->data_dir, sizeof state->data_dir, "%s", dir) >=
sizeof state->data_dir)
return false;
return true;
}
@ -2727,6 +2798,7 @@ int main(int argc, char **argv) {
load_script_settings(&state); /* user-set values; schema comes from the script */
pthread_mutex_init(&state.taxonomy_lock, NULL);
load_taxonomy(&state); /* persisted category + tag lists for the web UI */
pthread_mutex_init(&state.blob_lock, NULL);
state.events = naut_event_bus_create();
state.rpc = naut_rpc_registry_create();
state.plugins = naut_plugin_manager_create(state.rpc, state.events);
@ -2812,5 +2884,6 @@ int main(int argc, char **argv) {
json_decref(state.label_categories);
json_decref(state.label_tags);
pthread_mutex_destroy(&state.taxonomy_lock);
pthread_mutex_destroy(&state.blob_lock);
return 0;
}

View file

@ -1,4 +1,5 @@
#include "naut/naut_plugin.h"
#include "naut/http_client.h"
#include <jansson.h>
@ -6,6 +7,7 @@
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <regex.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdbool.h>
@ -89,6 +91,17 @@ 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. */
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 */
bool rss_wake; /* set with rss_cond to force an early re-poll */
webui_session sessions[MAX_SESSIONS];
} webui_state;
@ -195,6 +208,38 @@ static void strip_query(char *path) {
if (hash) *hash = 0;
}
/* Percent-decode a URL query component in place-style into `out`. */
static void url_decode(char *out, size_t outsz, const char *in, size_t n) {
size_t o = 0;
for (size_t i = 0; i < n && o + 1 < outsz; i++) {
if (in[i] == '%' && i + 2 < n) {
char hex[3] = { in[i+1], in[i+2], 0 };
char *e; long v = strtol(hex, &e, 16);
if (e == hex + 2) { out[o++] = (char)v; i += 2; continue; }
}
out[o++] = (in[i] == '+') ? ' ' : in[i];
}
out[o] = 0;
}
/* Extract a query parameter from a "k=v&k2=v2" string (the part after '?'),
* URL-decoding the value into `out`. Returns true if the key was present. */
static bool query_get(const char *query, const char *key, char *out, size_t outsz) {
if (!query) { if (outsz) out[0] = 0; return false; }
size_t klen = strlen(key);
for (const char *p = query; p && *p; ) {
const char *amp = strchr(p, '&');
size_t seg = amp ? (size_t)(amp - p) : strlen(p);
if (seg > klen && p[klen] == '=' && strncmp(p, key, klen) == 0) {
url_decode(out, outsz, p + klen + 1, seg - klen - 1);
return true;
}
p = amp ? amp + 1 : NULL;
}
if (outsz) out[0] = 0;
return false;
}
/* Constant-time equality so credential checks don't leak length/content via
* timing. Returns true when both NUL-terminated strings match exactly. */
static bool constant_time_equal(const char *a, const char *b) {
@ -1270,7 +1315,18 @@ static void api_meta(int fd) {
json_decref(torrents);
json_object_set_new(json, "trackers", trackers ? trackers : json_array());
json_object_set_new(json, "preferences", preferences);
json_object_set_new(json, "searchPlugins", json_array());
/* 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);
json_object_set_new(json, "searchPlugins", plugins);
http_json(fd, 200, json);
json_decref(json);
}
@ -1716,6 +1772,621 @@ static void api_script(int fd, const char *method, const char *body, size_t len)
json_decref(result);
}
/* ======================= RSS + Torznab search engine ====================== *
* The web layer owns RSS feeds, auto-download rules and search indexers; the
* daemon just persists them (blob store) and adds the torrents we hand it. A
* background thread polls feeds, parses items, and fires matching rules. */
#define RSS_POLL_INTERVAL_SEC (15 * 60) /* re-poll each feed every 15 min */
#define RSS_MAX_ARTICLES 200 /* keep newest N per feed */
/* --- tiny XML helpers (scan, not a real parser; enough for RSS/Atom) ------ */
/* Decode the handful of XML entities feeds actually use, in place-ish. */
static void xml_unescape(char *dst, size_t dstsz, const char *src, size_t n) {
size_t o = 0;
for (size_t i = 0; i < n && o + 1 < dstsz; i++) {
if (src[i] == '&') {
if (i + 4 < n && strncmp(src + i, "&amp;", 5) == 0) { dst[o++] = '&'; i += 4; continue; }
if (i + 3 < n && strncmp(src + i, "&lt;", 4) == 0) { dst[o++] = '<'; i += 3; continue; }
if (i + 3 < n && strncmp(src + i, "&gt;", 4) == 0) { dst[o++] = '>'; i += 3; continue; }
if (i + 5 < n && strncmp(src + i, "&quot;", 6) == 0){ dst[o++] = '"'; i += 5; continue; }
if (i + 5 < n && strncmp(src + i, "&apos;", 6) == 0){ dst[o++] = '\''; i += 5; continue; }
if (i + 4 < n && strncmp(src + i, "&#39;", 5) == 0) { dst[o++] = '\''; i += 4; continue; }
if (i + 1 < n && src[i + 1] == '#') { /* numeric &#NN; */
int base = 10, k = i + 2;
if (k < (int)n && (src[k] == 'x' || src[k] == 'X')) { base = 16; k++; }
long code = strtol(src + k, NULL, base);
const char *semi = memchr(src + i, ';', n - i);
if (semi && code > 0 && code < 128) {
dst[o++] = (char)code;
i = (size_t)(semi - src);
continue;
}
}
}
dst[o++] = src[i];
}
dst[o] = 0;
}
/* Find <tag>...</tag> within [item, item+len) and write its decoded text to
* out. Handles a single CDATA section. Returns true if found. */
static bool xml_tag_text(const char *item, size_t len, const char *tag,
char *out, size_t outsz) {
char open[64];
int on = snprintf(open, sizeof open, "<%s", tag);
if (on < 0 || (size_t)on >= sizeof open) return false;
const char *p = item, *end = item + len;
while (p < end) {
const char *o = memmem(p, (size_t)(end - p), open, (size_t)on);
if (!o) return false;
const char *after = o + on;
if (after < end && *after != '>' && *after != ' ' &&
*after != '\t' && *after != '/' && *after != ':') { p = after; continue; }
const char *gt = memchr(o, '>', (size_t)(end - o));
if (!gt) return false;
const char *content = gt + 1;
char close[64];
snprintf(close, sizeof close, "</%s>", tag);
const char *c = memmem(content, (size_t)(end - content), close, strlen(close));
if (!c) return false;
const char *s = content; size_t slen = (size_t)(c - content);
if (slen >= 12 && strncmp(s, "<![CDATA[", 9) == 0) {
s += 9; slen -= 9;
const char *cd = memmem(s, slen, "]]>", 3);
if (cd) slen = (size_t)(cd - s);
}
while (slen && (*s == ' ' || *s == '\n' || *s == '\r' || *s == '\t')) { s++; slen--; }
while (slen && (s[slen-1]==' '||s[slen-1]=='\n'||s[slen-1]=='\r'||s[slen-1]=='\t')) slen--;
xml_unescape(out, outsz, s, slen);
return true;
}
return false;
}
/* Pull attribute value attr="..." from the first <tag ...> element in range. */
static bool xml_attr(const char *item, size_t len, const char *tag,
const char *attr, char *out, size_t outsz) {
char open[64];
int on = snprintf(open, sizeof open, "<%s", tag);
if (on < 0 || (size_t)on >= sizeof open) return false;
const char *o = memmem(item, len, open, (size_t)on);
if (!o) return false;
const char *gt = memchr(o, '>', (size_t)(item + len - o));
if (!gt) return false;
char needle[64];
int nn = snprintf(needle, sizeof needle, "%s=\"", attr);
if (nn < 0 || (size_t)nn >= sizeof needle) return false;
const char *a = memmem(o, (size_t)(gt - o), needle, (size_t)nn);
if (!a) return false;
a += nn;
const char *q = memchr(a, '"', (size_t)(gt - a));
if (!q) return false;
xml_unescape(out, outsz, a, (size_t)(q - a));
return true;
}
/* Locate a magnet: URI anywhere inside the item element. */
static bool find_magnet(const char *item, size_t len, char *out, size_t outsz) {
const char *m = memmem(item, len, "magnet:?", 8);
if (!m) return false;
size_t i = 0;
while (m < item + len && *m && *m != '<' && *m != '"' && *m != '\'' &&
*m != ' ' && *m != '\n' && *m != '\r' && *m != '\t' && i + 1 < outsz)
out[i++] = *m++;
out[i] = 0;
/* decode &amp; that often appears in magnet query separators */
char tmp[2048];
xml_unescape(tmp, sizeof tmp, out, strlen(out));
snprintf(out, outsz, "%s", tmp);
return i > 8;
}
/* --- base64 (for fetching .torrent enclosures and handing bytes to add) --- */
static char *base64_encode(const unsigned char *in, size_t len) {
static const char tbl[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char *out = malloc((len + 2) / 3 * 4 + 1);
if (!out) return NULL;
size_t o = 0;
for (size_t i = 0; i < len; i += 3) {
unsigned v = in[i] << 16;
if (i + 1 < len) v |= in[i+1] << 8;
if (i + 2 < len) v |= in[i+2];
out[o++] = tbl[(v >> 18) & 63];
out[o++] = tbl[(v >> 12) & 63];
out[o++] = (i + 1 < len) ? tbl[(v >> 6) & 63] : '=';
out[o++] = (i + 2 < len) ? tbl[v & 63] : '=';
}
out[o] = 0;
return out;
}
/* --- RSS persistence (via the daemon blob store) -------------------------- */
static void rss_save(void) {
pthread_mutex_lock(&g_webui.rss_lock);
json_t *doc = json_pack("{s:O,s:O,s:O}",
"feeds", g_webui.rss_feeds ? g_webui.rss_feeds : json_array(),
"rules", g_webui.rss_rules ? g_webui.rss_rules : json_array(),
"indexers", g_webui.indexers ? g_webui.indexers : json_array());
pthread_mutex_unlock(&g_webui.rss_lock);
if (!doc) return;
json_t *params = json_pack("{s:s,s:o}", "key", "rss", "value", doc);
if (!params) { json_decref(doc); return; }
json_t *reply = rpc_call_json("set_webui_blob", params);
json_decref(params);
if (reply) json_decref(reply);
}
static void rss_load(void) {
json_t *params = json_pack("{s:s}", "key", "rss");
json_t *reply = rpc_call_json("get_webui_blob", params);
json_decref(params);
json_t *value = reply ? json_object_get(reply, "value") : NULL;
pthread_mutex_lock(&g_webui.rss_lock);
if (json_is_object(value)) {
json_t *feeds = json_object_get(value, "feeds");
json_t *rules = json_object_get(value, "rules");
json_t *idx = json_object_get(value, "indexers");
if (json_is_array(feeds)) { json_decref(g_webui.rss_feeds); g_webui.rss_feeds = json_deep_copy(feeds); }
if (json_is_array(rules)) { json_decref(g_webui.rss_rules); g_webui.rss_rules = json_deep_copy(rules); }
if (json_is_array(idx)) { json_decref(g_webui.indexers); g_webui.indexers = json_deep_copy(idx); }
}
pthread_mutex_unlock(&g_webui.rss_lock);
if (reply) json_decref(reply);
}
/* --- auto-download: hand a matched article to the daemon ------------------ */
/* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and
* uploading its bytes. Applies category/save path/paused, mirrors the label. */
static bool rss_download(const char *magnet, const char *torrent_url,
const char *category, const char *save_path,
bool paused) {
json_t *params = json_object();
if (!params) return false;
json_object_set_new(params, "output",
json_string(save_path && *save_path ? save_path : "."));
if (paused) json_object_set_new(params, "paused", json_true());
if (category && *category) json_object_set_new(params, "category", json_string(category));
char *fetched = NULL;
if (magnet && *magnet) {
json_object_set_new(params, "source", json_string(magnet));
} else if (torrent_url && *torrent_url) {
naut_http_response r;
if (naut_http_get(torrent_url, &r) != NAUT_OK || r.status / 100 != 2) {
naut_http_response_free(&r);
json_decref(params);
return false;
}
fetched = base64_encode((const unsigned char *)r.body, r.body_len);
naut_http_response_free(&r);
if (!fetched) { json_decref(params); return false; }
json_object_set_new(params, "data", json_string(fetched));
} else {
json_decref(params);
return false;
}
naut_err err = NAUT_OK;
json_t *result = rpc_call_json_err("add_torrent", params, &err);
json_decref(params);
free(fetched);
if (!result) return false;
uint64_t id = json_u64(result, "torrent_id");
if (id && category && *category) store_set_category(id, category);
json_decref(result);
publish_snapshot();
return true;
}
/* Does `article` satisfy `rule`? Substring or POSIX regex on the title. */
static bool rule_matches(json_t *rule, const char *feed_name, const char *title) {
if (!json_boolean_value(json_object_get(rule, "enabled"))) return false;
/* affectedFeeds: empty array means "all feeds". */
json_t *feeds = json_object_get(rule, "affectedFeeds");
if (json_is_array(feeds) && json_array_size(feeds) > 0) {
bool listed = false; size_t i; json_t *v;
json_array_foreach(feeds, i, v)
if (strcmp(json_string_value(v) ? json_string_value(v) : "", feed_name) == 0) { listed = true; break; }
if (!listed) return false;
}
const char *must = json_string_or(rule, "mustContain", "");
const char *mustnot = json_string_or(rule, "mustNotContain", "");
bool regex = json_boolean_value(json_object_get(rule, "useRegex"));
if (regex) {
if (*must) {
regex_t re;
if (regcomp(&re, must, REG_EXTENDED | REG_ICASE | REG_NOSUB) != 0) return false;
int m = regexec(&re, title, 0, NULL, 0);
regfree(&re);
if (m != 0) return false;
}
if (*mustnot) {
regex_t re;
if (regcomp(&re, mustnot, REG_EXTENDED | REG_ICASE | REG_NOSUB) == 0) {
int m = regexec(&re, title, 0, NULL, 0);
regfree(&re);
if (m == 0) return false;
}
}
} else {
if (*must && !strcasestr(title, must)) return false;
if (*mustnot && strcasestr(title, mustnot)) return false;
}
return true;
}
/* Run every rule against a freshly-seen article; download the first match. */
static void rss_run_rules(const char *feed_name, const char *title,
const char *magnet, const char *torrent_url) {
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) {
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", ""));
paused = json_boolean_value(json_object_get(rule, "addPaused"));
json_object_set_new(rule, "lastMatch", json_integer((json_int_t)time(NULL)));
fire = rule;
break;
}
}
pthread_mutex_unlock(&g_webui.rss_lock);
if (!fire) return;
if (rss_download(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);
}
int added = 0;
const char *p = xml, *end = xml + len;
for (;;) {
const char *open = memmem(p, (size_t)(end - p), "<item", 5);
const char *close_tag = "</item>";
if (!open) { open = memmem(p, (size_t)(end - p), "<entry", 6); /* Atom */
close_tag = "</entry>"; }
if (!open) break;
const char *close = memmem(open, (size_t)(end - open), close_tag, strlen(close_tag));
if (!close) break;
size_t ilen = (size_t)(close - open);
char title[512] = {0}, link[1024] = {0}, magnet[2048] = {0};
char enclosure[1024] = {0}, lenstr[64] = {0}, pub[128] = {0};
xml_tag_text(open, ilen, "title", title, sizeof title);
xml_tag_text(open, ilen, "link", link, sizeof link);
xml_tag_text(open, ilen, "pubDate", pub, sizeof pub);
if (!pub[0]) xml_tag_text(open, ilen, "published", pub, sizeof pub);
find_magnet(open, ilen, magnet, sizeof magnet);
xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure);
if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr))
xml_tag_text(open, ilen, "contentLength", lenstr, sizeof lenstr);
if (!magnet[0] && strncmp(link, "magnet:", 7) == 0)
snprintf(magnet, sizeof magnet, "%s", link);
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:I,s:s,s:b}",
"title", title, "key", key,
"magnet", magnet, "torrentUrl", enclosure,
"size", (json_int_t)strtoll(lenstr, NULL, 10),
"pubDate", pub, "isRead", 0);
json_array_insert_new(articles, 0, art);
added++;
if (out_new)
json_array_append_new(out_new, json_pack(
"{s:s,s:s,s:s}", "title", title,
"magnet", magnet, "torrentUrl", enclosure));
}
}
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)));
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;
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);
naut_http_response_free(&r);
/* fire auto-download rules now that rss_lock is released */
size_t i; json_t *a;
json_array_foreach(new_articles, i, a)
rss_run_rules(feed_name, 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);
}
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);
g_webui.rss_wake = false;
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += RSS_POLL_INTERVAL_SEC;
while (!atomic_load(&g_webui.stopping) && !g_webui.rss_wake)
if (pthread_cond_timedwait(&g_webui.rss_cond, &g_webui.rss_lock, &ts) == ETIMEDOUT)
break;
pthread_mutex_unlock(&g_webui.rss_lock);
}
return NULL;
}
/* --- 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);
}
/* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */
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;
}
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);
}
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);
}
/* 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 (remove) {
if (at >= 0) { json_array_remove(g_webui.rss_rules, (size_t)at); changed = true; }
} 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}",
"name", name,
"enabled", json_boolean_value(json_object_get(req, "enabled")),
"useRegex", json_boolean_value(json_object_get(req, "useRegex")),
"addPaused", json_boolean_value(json_object_get(req, "addPaused")),
"mustContain", json_string_or(req, "mustContain", ""),
"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;
}
}
}
pthread_mutex_unlock(&g_webui.rss_lock);
json_decref(req);
if (changed) rss_save();
api_rss_rules_list(fd);
}
/* POST /api/indexers upserts a Torznab indexer; .../delete removes one. */
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;
}
}
}
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_decref(reply);
}
/* ----------------------------- Torznab search ----------------------------- */
/* Parse Torznab/newznab XML results into the UI's row schema. */
static json_t *torznab_parse(const char *xml, size_t len, const char *engine) {
json_t *rows = json_array();
const char *p = xml, *end = xml + len;
for (;;) {
const char *open = memmem(p, (size_t)(end - p), "<item", 5);
if (!open) break;
const char *close = memmem(open, (size_t)(end - open), "</item>", 7);
if (!close) break;
size_t ilen = (size_t)(close - open);
char title[512] = {0}, magnet[2048] = {0}, enclosure[1024] = {0};
char pub[128] = {0}, lenstr[64] = {0};
xml_tag_text(open, ilen, "title", title, sizeof title);
xml_tag_text(open, ilen, "pubDate", pub, sizeof pub);
find_magnet(open, ilen, magnet, sizeof magnet);
xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure);
if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr))
xml_tag_text(open, ilen, "size", lenstr, sizeof lenstr);
/* Torznab seeders/peers live in <torznab:attr name="seeders" value=.. /> */
long seeds = 0, leech = 0;
const char *ap = open;
while (ap < close) {
const char *attr = memmem(ap, (size_t)(close - ap), "name=\"", 6);
if (!attr) break;
char an[32] = {0}, av[32] = {0};
const char *aq = attr + 6;
const char *aqe = memchr(aq, '"', (size_t)(close - aq));
if (!aqe) break;
snprintf(an, sizeof an, "%.*s", (int)(aqe - aq) < 31 ? (int)(aqe - aq) : 31, aq);
const char *valk = memmem(aqe, (size_t)(close - aqe), "value=\"", 7);
const char *gt = (const char *)memchr(aqe, '>', (size_t)(close - aqe));
if (valk && (!gt || valk < gt)) {
const char *vs = valk + 7;
const char *ve = memchr(vs, '"', (size_t)(close - vs));
if (ve) snprintf(av, sizeof av, "%.*s", (int)(ve - vs) < 31 ? (int)(ve - vs) : 31, vs);
}
if (strcmp(an, "seeders") == 0) seeds = strtol(av, NULL, 10);
else if (strcmp(an, "peers") == 0 || strcmp(an, "leechers") == 0) leech = strtol(av, NULL, 10);
ap = aqe + 1;
}
if (title[0]) {
json_array_append_new(rows, json_pack(
"{s:s,s:I,s:i,s:i,s:s,s:s,s:s,s:s}",
"name", title, "size", (json_int_t)strtoll(lenstr, NULL, 10),
"seeds", (int)seeds, "leeches", (int)leech,
"engine", engine, "pubDate", pub,
"magnet", magnet, "torrentUrl", enclosure));
}
p = close + 7;
}
return rows;
}
/* 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);
size_t i; json_t *ix;
json_array_foreach(indexers, i, ix) {
if (!json_boolean_value(json_object_get(ix, "enabled"))) continue;
const char *base = json_string_or(ix, "url", "");
const char *key = json_string_or(ix, "apikey", "");
const char *engine = json_string_or(ix, "name", "indexer");
if (!*base) continue;
char url[2048];
snprintf(url, sizeof url, "%s%st=search&q=%s%s%s",
base, strchr(base, '?') ? "&" : "?",
query ? query : "",
*key ? "&apikey=" : "", key);
naut_http_response r;
if (naut_http_get(url, &r) == NAUT_OK && r.status / 100 == 2 && r.body) {
json_t *rows = torznab_parse(r.body, r.body_len, engine);
size_t j; json_t *row;
json_array_foreach(rows, j, row) json_array_append(results, row);
json_decref(rows);
}
naut_http_response_free(&r);
}
json_decref(indexers);
http_json(fd, 200, results);
json_decref(results);
}
/* POST /api/categories and /api/categories/delete */
static void api_categories(int fd, const char *body, size_t len, bool remove) {
json_t *req = read_body_json(body, len);
@ -1847,6 +2518,10 @@ static void serve_cached_torrents(int fd) {
static void handle_api(int fd, const char *method, char *path,
const char *headers, const char *headers_end,
const char *body, size_t body_len) {
/* Snapshot the query string before strip_query() truncates it. */
char query_str[1024] = {0};
const char *qmark = strchr(path, '?');
if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1);
strip_query(path);
if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) {
json_t *json = json_pack("{s:b,s:s,s:b}",
@ -1942,14 +2617,35 @@ static void handle_api(int fd, const char *method, char *path,
api_delete(fd, body, body_len);
} else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) {
api_action(fd, body, body_len);
} else if (strncmp(path, "/api/rss", 8) == 0 && strcmp(method, "GET") == 0) {
json_t *json = json_array();
http_json(fd, 200, json);
json_decref(json);
} else if (strncmp(path, "/api/search", 11) == 0 && strcmp(method, "GET") == 0) {
json_t *json = json_array();
http_json(fd, 200, json);
json_decref(json);
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) {
api_rss_list(fd);
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) {
api_rss_feed(fd, body, body_len, false);
} else if (strcmp(path, "/api/rss/delete") == 0 && strcmp(method, "POST") == 0) {
api_rss_feed(fd, body, body_len, true);
} else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "GET") == 0) {
api_rss_rules_list(fd);
} else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "POST") == 0) {
api_rss_rule(fd, body, body_len, false);
} else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) {
api_rss_rule(fd, body, body_len, true);
} else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) {
api_indexer(fd, body, body_len, false);
} else if (strcmp(path, "/api/indexers/delete") == 0 && strcmp(method, "POST") == 0) {
api_indexer(fd, body, body_len, true);
} else if (strcmp(path, "/api/search") == 0 && strcmp(method, "GET") == 0) {
char q[512] = {0};
query_get(query_str, "q", q, sizeof q);
/* re-encode spaces for the upstream query (decode happened above) */
char enc[1024]; size_t eo = 0;
for (size_t i = 0; q[i] && eo + 4 < sizeof enc; i++) {
unsigned char c = (unsigned char)q[i];
if ((c >= 'a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||
c=='-'||c=='_'||c=='.'||c=='~') enc[eo++] = (char)c;
else eo += (size_t)snprintf(enc + eo, sizeof enc - eo, "%%%02X", c);
}
enc[eo] = 0;
api_search(fd, enc);
} else {
http_text(fd, 404, "Not Found", "not found");
}
@ -2201,6 +2897,11 @@ naut_err naut_plugin_register(const naut_host_api *host) {
g_webui.assignments = json_object();
if (!g_webui.categories || !g_webui.tags || !g_webui.assignments)
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");
@ -2208,6 +2909,9 @@ naut_err naut_plugin_register(const naut_host_api *host) {
error = start_server();
if (error != NAUT_OK) goto fail_store;
webui_load_taxonomy(); /* restore category + tag lists from the daemon */
/* RSS poller: loads feeds/rules from the blob store and polls in the bg. */
if (pthread_create(&g_webui.rss_thread, NULL, rss_thread_fn, NULL) == 0)
g_webui.rss_thread_started = true;
return NAUT_OK;
fail_store:
@ -2247,6 +2951,13 @@ naut_err naut_plugin_shutdown(void) {
if (g_webui.sampler_started)
pthread_join(g_webui.sampler, NULL);
g_webui.sampler_started = false;
if (g_webui.rss_thread_started) {
pthread_mutex_lock(&g_webui.rss_lock);
pthread_cond_signal(&g_webui.rss_cond); /* wake the poller to exit */
pthread_mutex_unlock(&g_webui.rss_lock);
pthread_join(g_webui.rss_thread, NULL);
g_webui.rss_thread_started = false;
}
pthread_mutex_lock(&g_webui.conn_lock);
while (g_webui.active_connections > 0)
pthread_cond_wait(&g_webui.conn_cond, &g_webui.conn_lock);
@ -2264,6 +2975,15 @@ naut_err naut_plugin_shutdown(void) {
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);
pthread_mutex_destroy(&g_webui.meta_lock);
pthread_mutex_destroy(&g_webui.speed_lock);
pthread_cond_destroy(&g_webui.snap_cond);