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
|
|
@ -121,6 +121,8 @@ struct daemon_state {
|
||||||
json_t *label_categories; /* array of {name, savePath} */
|
json_t *label_categories; /* array of {name, savePath} */
|
||||||
json_t *label_tags; /* array of tag name strings */
|
json_t *label_tags; /* array of tag name strings */
|
||||||
char taxonomy_file[PATH_MAX]; /* <state_dir>/labels.json */
|
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;
|
pthread_mutex_t torrent_lock;
|
||||||
torrent_task *torrents[MAX_TORRENTS];
|
torrent_task *torrents[MAX_TORRENTS];
|
||||||
size_t torrent_count;
|
size_t torrent_count;
|
||||||
|
|
@ -1401,6 +1403,70 @@ static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params,
|
||||||
return json_object();
|
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) --- */
|
/* --- data-overlap guard (block torrents that would write the same files) --- */
|
||||||
|
|
||||||
static uint8_t *slurp_file(const char *path, size_t *len) {
|
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, "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, "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, "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, "unload_script", rpc_unload_script, state) == NAUT_OK &&
|
||||||
naut_rpc_register(state->rpc, "shutdown", rpc_shutdown, 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,
|
if ((size_t)snprintf(state->taxonomy_file, sizeof state->taxonomy_file,
|
||||||
"%s/labels.json", dir) >= sizeof state->taxonomy_file)
|
"%s/labels.json", dir) >= sizeof state->taxonomy_file)
|
||||||
return false;
|
return false;
|
||||||
|
if ((size_t)snprintf(state->data_dir, sizeof state->data_dir, "%s", dir) >=
|
||||||
|
sizeof state->data_dir)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2727,6 +2798,7 @@ int main(int argc, char **argv) {
|
||||||
load_script_settings(&state); /* user-set values; schema comes from the script */
|
load_script_settings(&state); /* user-set values; schema comes from the script */
|
||||||
pthread_mutex_init(&state.taxonomy_lock, NULL);
|
pthread_mutex_init(&state.taxonomy_lock, NULL);
|
||||||
load_taxonomy(&state); /* persisted category + tag lists for the web UI */
|
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.events = naut_event_bus_create();
|
||||||
state.rpc = naut_rpc_registry_create();
|
state.rpc = naut_rpc_registry_create();
|
||||||
state.plugins = naut_plugin_manager_create(state.rpc, state.events);
|
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_categories);
|
||||||
json_decref(state.label_tags);
|
json_decref(state.label_tags);
|
||||||
pthread_mutex_destroy(&state.taxonomy_lock);
|
pthread_mutex_destroy(&state.taxonomy_lock);
|
||||||
|
pthread_mutex_destroy(&state.blob_lock);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "naut/naut_plugin.h"
|
#include "naut/naut_plugin.h"
|
||||||
|
#include "naut/http_client.h"
|
||||||
|
|
||||||
#include <jansson.h>
|
#include <jansson.h>
|
||||||
|
|
||||||
|
|
@ -6,6 +7,7 @@
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
|
#include <regex.h>
|
||||||
#include <netinet/in.h>
|
#include <netinet/in.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
|
@ -89,6 +91,17 @@ typedef struct {
|
||||||
json_t *tags; /* array of tag name strings */
|
json_t *tags; /* array of tag name strings */
|
||||||
json_t *assignments; /* object keyed by stringified torrent id */
|
json_t *assignments; /* object keyed by stringified torrent id */
|
||||||
|
|
||||||
|
/* RSS: feeds + auto-download rules, polled by a background thread and
|
||||||
|
* 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_session sessions[MAX_SESSIONS];
|
||||||
} webui_state;
|
} webui_state;
|
||||||
|
|
||||||
|
|
@ -195,6 +208,38 @@ static void strip_query(char *path) {
|
||||||
if (hash) *hash = 0;
|
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
|
/* Constant-time equality so credential checks don't leak length/content via
|
||||||
* timing. Returns true when both NUL-terminated strings match exactly. */
|
* timing. Returns true when both NUL-terminated strings match exactly. */
|
||||||
static bool constant_time_equal(const char *a, const char *b) {
|
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_decref(torrents);
|
||||||
json_object_set_new(json, "trackers", trackers ? trackers : json_array());
|
json_object_set_new(json, "trackers", trackers ? trackers : json_array());
|
||||||
json_object_set_new(json, "preferences", preferences);
|
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);
|
http_json(fd, 200, json);
|
||||||
json_decref(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);
|
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 */
|
/* POST /api/categories and /api/categories/delete */
|
||||||
static void api_categories(int fd, const char *body, size_t len, bool remove) {
|
static void api_categories(int fd, const char *body, size_t len, bool remove) {
|
||||||
json_t *req = read_body_json(body, len);
|
json_t *req = read_body_json(body, len);
|
||||||
|
|
@ -1847,6 +2518,10 @@ static void serve_cached_torrents(int fd) {
|
||||||
static void handle_api(int fd, const char *method, char *path,
|
static void handle_api(int fd, const char *method, char *path,
|
||||||
const char *headers, const char *headers_end,
|
const char *headers, const char *headers_end,
|
||||||
const char *body, size_t body_len) {
|
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);
|
strip_query(path);
|
||||||
if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) {
|
if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) {
|
||||||
json_t *json = json_pack("{s:b,s:s,s:b}",
|
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);
|
api_delete(fd, body, body_len);
|
||||||
} else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) {
|
} else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) {
|
||||||
api_action(fd, body, body_len);
|
api_action(fd, body, body_len);
|
||||||
} else if (strncmp(path, "/api/rss", 8) == 0 && strcmp(method, "GET") == 0) {
|
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) {
|
||||||
json_t *json = json_array();
|
api_rss_list(fd);
|
||||||
http_json(fd, 200, json);
|
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) {
|
||||||
json_decref(json);
|
api_rss_feed(fd, body, body_len, false);
|
||||||
} else if (strncmp(path, "/api/search", 11) == 0 && strcmp(method, "GET") == 0) {
|
} else if (strcmp(path, "/api/rss/delete") == 0 && strcmp(method, "POST") == 0) {
|
||||||
json_t *json = json_array();
|
api_rss_feed(fd, body, body_len, true);
|
||||||
http_json(fd, 200, json);
|
} else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "GET") == 0) {
|
||||||
json_decref(json);
|
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 {
|
} else {
|
||||||
http_text(fd, 404, "Not Found", "not found");
|
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();
|
g_webui.assignments = json_object();
|
||||||
if (!g_webui.categories || !g_webui.tags || !g_webui.assignments)
|
if (!g_webui.categories || !g_webui.tags || !g_webui.assignments)
|
||||||
goto fail_store;
|
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();
|
init_auth();
|
||||||
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
|
||||||
"webui");
|
"webui");
|
||||||
|
|
@ -2208,6 +2909,9 @@ naut_err naut_plugin_register(const naut_host_api *host) {
|
||||||
error = start_server();
|
error = start_server();
|
||||||
if (error != NAUT_OK) goto fail_store;
|
if (error != NAUT_OK) goto fail_store;
|
||||||
webui_load_taxonomy(); /* restore category + tag lists from the daemon */
|
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;
|
return NAUT_OK;
|
||||||
|
|
||||||
fail_store:
|
fail_store:
|
||||||
|
|
@ -2247,6 +2951,13 @@ naut_err naut_plugin_shutdown(void) {
|
||||||
if (g_webui.sampler_started)
|
if (g_webui.sampler_started)
|
||||||
pthread_join(g_webui.sampler, NULL);
|
pthread_join(g_webui.sampler, NULL);
|
||||||
g_webui.sampler_started = false;
|
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);
|
pthread_mutex_lock(&g_webui.conn_lock);
|
||||||
while (g_webui.active_connections > 0)
|
while (g_webui.active_connections > 0)
|
||||||
pthread_cond_wait(&g_webui.conn_cond, &g_webui.conn_lock);
|
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.tags = NULL;
|
||||||
g_webui.assignments = NULL;
|
g_webui.assignments = NULL;
|
||||||
|
|
||||||
|
json_decref(g_webui.rss_feeds);
|
||||||
|
json_decref(g_webui.rss_rules);
|
||||||
|
json_decref(g_webui.indexers);
|
||||||
|
g_webui.rss_feeds = NULL;
|
||||||
|
g_webui.rss_rules = NULL;
|
||||||
|
g_webui.indexers = NULL;
|
||||||
|
pthread_cond_destroy(&g_webui.rss_cond);
|
||||||
|
pthread_mutex_destroy(&g_webui.rss_lock);
|
||||||
|
|
||||||
pthread_mutex_destroy(&g_webui.meta_lock);
|
pthread_mutex_destroy(&g_webui.meta_lock);
|
||||||
pthread_mutex_destroy(&g_webui.speed_lock);
|
pthread_mutex_destroy(&g_webui.speed_lock);
|
||||||
pthread_cond_destroy(&g_webui.snap_cond);
|
pthread_cond_destroy(&g_webui.snap_cond);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue