diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index d8d4f32..9e87c6d 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -81,6 +81,14 @@ typedef struct { pthread_mutex_t speed_lock; speed_slot speeds[SPEED_SLOTS]; + /* Categories and tags are pure UI organization the engine knows nothing + * about, so the web layer owns them (in memory, like qBittorrent's own + * Web API does). assignments maps "" -> {category, tags:[...]}. */ + pthread_mutex_t meta_lock; + json_t *categories; /* array of {name, savePath} */ + json_t *tags; /* array of tag name strings */ + json_t *assignments; /* object keyed by stringified torrent id */ + webui_session sessions[MAX_SESSIONS]; } webui_state; @@ -580,6 +588,158 @@ static json_int_t compute_eta(uint64_t done, uint64_t total, double dlspeed) { return ETA_INFINITY; } +/* ---- category / tag store (web-layer owned, guarded by meta_lock) ---- */ + +static int find_category(const char *name) { + size_t index; + json_t *value; + json_array_foreach(g_webui.categories, index, value) + if (strcmp(json_string_or(value, "name", ""), name) == 0) + return (int)index; + return -1; +} + +static int find_tag(const char *name) { + size_t index; + json_t *value; + json_array_foreach(g_webui.tags, index, value) + if (strcmp(json_string_value(value), name) == 0) return (int)index; + return -1; +} + +static void store_add_category(const char *name, const char *save_path) { + pthread_mutex_lock(&g_webui.meta_lock); + if (find_category(name) < 0) + json_array_append_new(g_webui.categories, json_pack( + "{s:s,s:s}", "name", name, "savePath", save_path ? save_path : "")); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_remove_category(const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + int index = find_category(name); + if (index >= 0) json_array_remove(g_webui.categories, (size_t)index); + /* drop the category from any torrent that had it */ + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) + if (strcmp(json_string_or(entry, "category", ""), name) == 0) + json_object_set_new(entry, "category", json_string("")); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_add_tag(const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + if (find_tag(name) < 0) + json_array_append_new(g_webui.tags, json_string(name)); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_remove_tag(const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + int index = find_tag(name); + if (index >= 0) json_array_remove(g_webui.tags, (size_t)index); + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) { + json_t *tags = json_object_get(entry, "tags"); + size_t i = 0; + while (i < json_array_size(tags)) { + if (strcmp(json_string_value(json_array_get(tags, i)), name) == 0) + json_array_remove(tags, i); + else + i++; + } + } + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static json_t *assignment_locked(uint64_t id, bool create) { + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + json_t *entry = json_object_get(g_webui.assignments, key); + if (!entry && create) { + entry = json_pack("{s:s,s:o}", "category", "", "tags", json_array()); + json_object_set_new(g_webui.assignments, key, entry); + } + return entry; +} + +static void store_set_category(uint64_t id, const char *category) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, true); + if (entry) + json_object_set_new(entry, "category", + json_string(category ? category : "")); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_update_tags(uint64_t id, json_t *tags, bool add) { + if (!json_is_array(tags)) return; + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, true); + json_t *have = entry ? json_object_get(entry, "tags") : NULL; + if (have) { + size_t index; + json_t *value; + json_array_foreach(tags, index, value) { + const char *name = json_string_value(value); + if (!name) continue; + size_t pos = 0; + bool present = false; + for (; pos < json_array_size(have); pos++) + if (strcmp(json_string_value(json_array_get(have, pos)), + name) == 0) { present = true; break; } + if (add && !present) + json_array_append_new(have, json_string(name)); + else if (!add && present) + json_array_remove(have, pos); + } + } + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_set_name(uint64_t id, const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, true); + if (entry) json_object_set_new(entry, "name", json_string(name)); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static bool store_get_name(uint64_t id, char *out, size_t out_size) { + bool found = false; + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, false); + const char *name = entry ? json_string_value(json_object_get(entry, "name")) + : NULL; + if (name && *name) { + snprintf(out, out_size, "%s", name); + found = true; + } + pthread_mutex_unlock(&g_webui.meta_lock); + return found; +} + +static void store_forget(uint64_t id) { + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + pthread_mutex_lock(&g_webui.meta_lock); + json_object_del(g_webui.assignments, key); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +/* Fill in category + tags for a torrent from the assignment store. */ +static void apply_assignment(json_t *out, uint64_t id) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, false); + const char *category = entry ? json_string_or(entry, "category", "") : ""; + json_t *tags = entry ? json_object_get(entry, "tags") : NULL; + json_object_set_new(out, "category", json_string(category)); + json_object_set_new(out, "tags", + tags ? json_deep_copy(tags) : json_array()); + pthread_mutex_unlock(&g_webui.meta_lock); +} + static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { uint64_t id = json_u64(torrent, "torrent_id"); uint64_t done = 0, total = 0; @@ -591,6 +751,13 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { snprintf(hash, sizeof hash, "%llu", (unsigned long long)id); char *name = torrent_name(torrent); if (!name) return NULL; + /* Prefer the display name the UI captured at add time over the daemon's + * temp upload path. */ + char override[256]; + if (store_get_name(id, override, sizeof override)) { + char *better = strdup(override); + if (better) { free(name); name = better; } + } const char *state = ui_state(json_string_value(json_object_get(torrent, "state")), progress); @@ -618,40 +785,57 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { hosts = tracker_hosts(trackers); } - json_t *out = json_pack( - "{s:s,s:s,s:I,s:f,s:I,s:i,s:I,s:i,s:i,s:i,s:f,s:s,s:o,s:s," - "s:I,s:I,s:I,s:I,s:f,s:i,s:o,s:b,s:b,s:b,s:I,s:I,s:s,s:s}", - "hash", hash, - "name", name, - "size", (json_int_t)total, - "progress", progress, - "dlspeed", (json_int_t)dlspeed, - "upspeed", 0, - "eta", compute_eta(done, total, dlspeed), - "seeds", (int)json_u64(torrent, "peers"), - "seedsTotal", (int)json_u64(torrent, "peers_discovered"), - "peers", (int)json_u64(torrent, "peers_connecting"), - "peersTotal", (int)json_u64(torrent, "peers_discovered"), - "ratio", 0.0, - "category", "", - "tags", json_array(), - "savePath", json_string_or(torrent, "output", ""), - "addedOn", (json_int_t)0, - "completionOn", progress >= 1.0 ? (json_int_t)0 : (json_int_t)-1, - "lastActivity", (json_int_t)0, - "downloaded", (json_int_t)done, - "uploaded", (json_int_t)0, - "availability", 1.0, - "priority", 1, - "trackerHosts", hosts ? hosts : json_array(), - "seqDl", false, - "superSeeding", false, - "forceStart", false, - "timeActive", (json_int_t)json_u64(torrent, "elapsed_seconds"), - "pieceSize", pieces ? (json_int_t)(total / pieces) : (json_int_t)0, - "state", state, - "contentPath", json_string_or(torrent, "output", "")); - if (out && detail) { + uint64_t discovered = json_u64(torrent, "peers_discovered"); + const char *output = json_string_or(torrent, "output", ""); + /* Built field-by-field on purpose: a single 30-key json_pack drifts out of + * sync with its argument list silently and then crashes on a type mismatch. */ + json_t *out = json_object(); + if (!out) { + free(name); + if (detail) { + json_decref(trackers); + json_decref(files); + json_decref(peers_list); + json_decref(hosts); + } + return NULL; + } + json_object_set_new(out, "hash", json_string(hash)); + json_object_set_new(out, "name", json_string(name)); + json_object_set_new(out, "size", json_integer((json_int_t)total)); + json_object_set_new(out, "progress", json_real(progress)); + json_object_set_new(out, "dlspeed", json_integer((json_int_t)dlspeed)); + json_object_set_new(out, "upspeed", json_integer(0)); + json_object_set_new(out, "eta", json_integer(compute_eta(done, total, dlspeed))); + json_object_set_new(out, "seeds", + json_integer((json_int_t)json_u64(torrent, "peers"))); + json_object_set_new(out, "seedsTotal", json_integer((json_int_t)discovered)); + json_object_set_new(out, "peers", + json_integer((json_int_t)json_u64(torrent, "peers_connecting"))); + json_object_set_new(out, "peersTotal", json_integer((json_int_t)discovered)); + json_object_set_new(out, "ratio", json_real(0.0)); + json_object_set_new(out, "savePath", json_string(output)); + json_object_set_new(out, "addedOn", json_integer(0)); + json_object_set_new(out, "completionOn", + json_integer(progress >= 1.0 ? 0 : -1)); + json_object_set_new(out, "lastActivity", json_integer(0)); + json_object_set_new(out, "downloaded", json_integer((json_int_t)done)); + json_object_set_new(out, "uploaded", json_integer(0)); + json_object_set_new(out, "availability", json_real(1.0)); + json_object_set_new(out, "priority", json_integer(1)); + json_object_set_new(out, "trackerHosts", hosts ? hosts : json_array()); + json_object_set_new(out, "seqDl", json_false()); + json_object_set_new(out, "superSeeding", json_false()); + json_object_set_new(out, "forceStart", json_false()); + json_object_set_new(out, "timeActive", + json_integer((json_int_t)json_u64(torrent, "elapsed_seconds"))); + json_object_set_new(out, "pieceSize", + json_integer(pieces ? (json_int_t)(total / pieces) : 0)); + json_object_set_new(out, "state", json_string(state)); + json_object_set_new(out, "contentPath", json_string(output)); + /* category + tags come from the web-layer assignment store */ + apply_assignment(out, id); + if (detail) { json_object_set_new(out, "comment", json_string("")); json_object_set_new(out, "createdBy", json_string("Naut")); json_object_set_new(out, "creationDate", json_integer(0)); @@ -663,11 +847,6 @@ static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { json_object_set_new(out, "trackers", trackers ? trackers : json_array()); json_object_set_new(out, "peersList", peers_list ? peers_list : json_array()); json_object_set_new(out, "files", files ? files : json_array()); - } else if (detail) { - json_decref(trackers); - json_decref(files); - json_decref(peers_list); - json_decref(hosts); } free(name); return out; @@ -791,8 +970,11 @@ static void api_meta(int fd) { http_text(fd, 500, "Internal Server Error", "oom"); return; } - json_object_set_new(json, "categories", json_array()); - json_object_set_new(json, "tags", json_array()); + pthread_mutex_lock(&g_webui.meta_lock); + json_object_set_new(json, "categories", + json_deep_copy(g_webui.categories)); + json_object_set_new(json, "tags", json_deep_copy(g_webui.tags)); + pthread_mutex_unlock(&g_webui.meta_lock); json_object_set_new(json, "trackers", json_array()); json_object_set_new(preferences, "save_path", json_string(getenv("NAUT_WEBUI_SAVE_PATH") @@ -894,6 +1076,12 @@ static void api_add(int fd, const char *body, size_t len) { const char *magnet = json_string_value(json_object_get(req, "magnet")); const char *data = json_string_value(json_object_get(req, "data")); const char *save_path = json_string_value(json_object_get(req, "savePath")); + /* The UI parses the .torrent client-side and sends a display name; the + * daemon only knows the temp upload path, so we keep the name here. */ + const char *display = json_string_value(json_object_get(req, "name")); + char display_name[256] = {0}; + if (display && *display) + snprintf(display_name, sizeof display_name, "%s", display); if (!save_path || !*save_path) save_path = "."; if (!source) source = magnet; if ((!source || !*source) && (!data || !*data)) { @@ -913,9 +1101,10 @@ static void api_add(int fd, const char *body, size_t len) { http_text(fd, 502, "Bad Gateway", "add_torrent failed"); return; } + uint64_t new_id = json_u64(result, "torrent_id"); + if (display_name[0]) store_set_name(new_id, display_name); char id[32]; - snprintf(id, sizeof id, "%llu", - (unsigned long long)json_u64(result, "torrent_id")); + snprintf(id, sizeof id, "%llu", (unsigned long long)new_id); json_t *reply = json_pack("{s:b,s:s}", "ok", 1, "hash", id); http_json(fd, 200, reply); json_decref(reply); @@ -939,6 +1128,7 @@ static void api_delete(int fd, const char *body, size_t len) { json_decref(params); if (result) { removed++; + store_forget(id); json_decref(result); } } @@ -950,22 +1140,85 @@ static void api_delete(int fd, const char *body, size_t len) { if (removed) publish_snapshot(); } -/* The engine has no pause/resume/recheck/queue/category/limit verbs yet, so - * rather than claim success we tell the UI the action is unsupported. The - * front end surfaces a non-2xx as an honest "Action failed" toast. */ +/* Category/tag assignment is web-layer state the plugin owns, so those verbs + * are honored here. Engine-level verbs (pause/resume/recheck/queue/rate + * limits) have no daemon support yet, so we return 501 instead of pretending + * they worked; the front end shows that as an honest "Action failed" toast. */ static void api_action(int fd, const char *body, size_t len) { json_t *req = read_body_json(body, len); - const char *action = json_string_value(json_object_get(req, "action")); + const char *raw_action = json_string_value(json_object_get(req, "action")); + char action[64]; + snprintf(action, sizeof action, "%s", raw_action ? raw_action : ""); + json_t *hashes = json_object_get(req, "hashes"); + json_t *params = json_object_get(req, "params"); + bool handled = false; + if (json_is_array(hashes) && + (strcmp(action, "setCategory") == 0 || + strcmp(action, "addTags") == 0 || + strcmp(action, "removeTags") == 0)) { + size_t index; + json_t *hash; + json_array_foreach(hashes, index, hash) { + uint64_t id = 0; + if (!parse_id(json_string_value(hash), &id)) continue; + if (strcmp(action, "setCategory") == 0) + store_set_category(id, + json_string_value(json_object_get(params, "category"))); + else + store_update_tags(id, json_object_get(params, "tags"), + strcmp(action, "addTags") == 0); + } + handled = true; + } + json_decref(req); + if (handled) { + json_t *json = json_pack("{s:b}", "ok", 1); + http_json(fd, 200, json); + json_decref(json); + publish_snapshot(); + return; + } char message[128]; snprintf(message, sizeof message, - "action '%s' is not supported by the engine", - action ? action : ""); - json_decref(req); + "action '%s' is not supported by the engine", action); json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", message); http_json(fd, 501, json); json_decref(json); } +/* 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); + const char *name = json_string_value(json_object_get(req, "name")); + if (name && *name) { + if (remove) store_remove_category(name); + else store_add_category(name, + json_string_value(json_object_get(req, "savePath"))); + } + json_decref(req); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *reply = json_deep_copy(g_webui.categories); + pthread_mutex_unlock(&g_webui.meta_lock); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* POST /api/tags and /api/tags/delete */ +static void api_tags(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")); + if (name && *name) { + if (remove) store_remove_tag(name); + else store_add_tag(name); + } + json_decref(req); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *reply = json_deep_copy(g_webui.tags); + pthread_mutex_unlock(&g_webui.meta_lock); + http_json(fd, 200, reply); + json_decref(reply); +} + static void api_stream(int fd) { const char *head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n" @@ -1084,18 +1337,18 @@ static void handle_api(int fd, const char *method, char *path, json_t *json = json_pack("{s:b}", "alt_speed_enabled", 0); http_json(fd, 200, json); json_decref(json); - } else if ((strcmp(path, "/api/categories") == 0 || - strcmp(path, "/api/categories/delete") == 0) && + } else if (strcmp(path, "/api/categories") == 0 && strcmp(method, "POST") == 0) { - json_t *json = json_array(); - http_json(fd, 200, json); - json_decref(json); - } else if ((strcmp(path, "/api/tags") == 0 || - strcmp(path, "/api/tags/delete") == 0) && + api_categories(fd, body, body_len, false); + } else if (strcmp(path, "/api/categories/delete") == 0 && strcmp(method, "POST") == 0) { - json_t *json = json_array(); - http_json(fd, 200, json); - json_decref(json); + api_categories(fd, body, body_len, true); + } else if (strcmp(path, "/api/tags") == 0 && + strcmp(method, "POST") == 0) { + api_tags(fd, body, body_len, false); + } else if (strcmp(path, "/api/tags/delete") == 0 && + strcmp(method, "POST") == 0) { + api_tags(fd, body, body_len, true); } else if (strcmp(path, "/api/torrents") == 0 && strcmp(method, "GET") == 0) { serve_cached_torrents(fd); } else if (path_after(path, "/api/torrents/") && strcmp(method, "GET") == 0) { @@ -1358,15 +1611,27 @@ naut_err naut_plugin_register(const naut_host_api *host) { goto fail_snap_cond; if (pthread_mutex_init(&g_webui.speed_lock, NULL) != 0) goto fail_speed_lock; + if (pthread_mutex_init(&g_webui.meta_lock, NULL) != 0) + goto fail_meta_lock; + g_webui.categories = json_array(); + g_webui.tags = json_array(); + g_webui.assignments = json_object(); + if (!g_webui.categories || !g_webui.tags || !g_webui.assignments) + goto fail_store; init_auth(); error = g_webui.host.set_plugin_name(g_webui.host.host_context, "webui"); - if (error != NAUT_OK) goto fail_named; + if (error != NAUT_OK) goto fail_store; error = start_server(); - if (error != NAUT_OK) goto fail_named; + if (error != NAUT_OK) goto fail_store; return NAUT_OK; -fail_named: +fail_store: + json_decref(g_webui.categories); + json_decref(g_webui.tags); + json_decref(g_webui.assignments); + pthread_mutex_destroy(&g_webui.meta_lock); +fail_meta_lock: pthread_mutex_destroy(&g_webui.speed_lock); fail_speed_lock: pthread_cond_destroy(&g_webui.snap_cond); @@ -1408,6 +1673,14 @@ naut_err naut_plugin_shutdown(void) { g_webui.snapshot_str = NULL; g_webui.torrents_str = NULL; + json_decref(g_webui.categories); + json_decref(g_webui.tags); + json_decref(g_webui.assignments); + g_webui.categories = NULL; + g_webui.tags = NULL; + g_webui.assignments = NULL; + + pthread_mutex_destroy(&g_webui.meta_lock); pthread_mutex_destroy(&g_webui.speed_lock); pthread_cond_destroy(&g_webui.snap_cond); pthread_mutex_destroy(&g_webui.snap_lock);