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:
parent
0393ed429b
commit
d6e0e51175
2 changed files with 802 additions and 9 deletions
|
|
@ -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, "&", 5) == 0) { dst[o++] = '&'; i += 4; continue; }
|
||||
if (i + 3 < n && strncmp(src + i, "<", 4) == 0) { dst[o++] = '<'; i += 3; continue; }
|
||||
if (i + 3 < n && strncmp(src + i, ">", 4) == 0) { dst[o++] = '>'; i += 3; continue; }
|
||||
if (i + 5 < n && strncmp(src + i, """, 6) == 0){ dst[o++] = '"'; i += 5; continue; }
|
||||
if (i + 5 < n && strncmp(src + i, "'", 6) == 0){ dst[o++] = '\''; i += 5; continue; }
|
||||
if (i + 4 < n && strncmp(src + i, "'", 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 & 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue