From cd3fbfbf9b63a42f1d4d9d48d0c2d20a5c1a4b28 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 27 Jun 2025 22:29:19 -0700 Subject: [PATCH 1/4] Modify `CategoryOptions` serialization to JSON When a category's download path option is set to "Default", its `downloadPath` is serialized into JSON as `undefined`. This results in the `downloadPath` field being omitted from `torrents/categories` and `torrents/maindata` payloads (as is expected with an `undefined` value). The use of `undefined` here causes an issue in the WebUI. Specifically, when the category previously contained a value for this field (i.e. download path option set to either "Yes" or "No"), the `processMap` logic in `SyncController` does not detect the removal this field. This results in the category's new `downloadPath` not being properly sent to the client. By switching from `undefined` to `null`, we ensure that the `downloadPath` value is always included in the category's payload. This allows `processMap` to properly detect whenever the value changes. This change is backwards compatible with existing categories.json files. Older qBittorrent versions should also be able to parse new categories.json files containing `null`. --- src/base/bittorrent/categoryoptions.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/base/bittorrent/categoryoptions.cpp b/src/base/bittorrent/categoryoptions.cpp index fec609ff7..c839670c6 100644 --- a/src/base/bittorrent/categoryoptions.cpp +++ b/src/base/bittorrent/categoryoptions.cpp @@ -52,7 +52,7 @@ BitTorrent::CategoryOptions BitTorrent::CategoryOptions::fromJSON(const QJsonObj QJsonObject BitTorrent::CategoryOptions::toJSON() const { - QJsonValue downloadPathValue = QJsonValue::Undefined; + QJsonValue downloadPathValue = QJsonValue::Null; if (downloadPath) { if (downloadPath->enabled) From e525375248b4bc6b430feef7addadd2c8394b275 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 27 Jun 2025 23:10:56 -0700 Subject: [PATCH 2/4] Bump WebAPI version --- WebAPI_Changelog.md | 5 +++++ src/webui/webapplication.h | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/WebAPI_Changelog.md b/WebAPI_Changelog.md index 23272bb5a..84dec5afe 100644 --- a/WebAPI_Changelog.md +++ b/WebAPI_Changelog.md @@ -1,5 +1,10 @@ # WebAPI Changelog +## 2.11.10 + +* [#22932](https://github.com/qbittorrent/qBittorrent/pull/22932) + * `torrents/categories` and `sync/maindata` now serialize categories' `downloadPath` to `null`, rather than `undefined` + ## 2.11.9 * [#21015](https://github.com/qbittorrent/qBittorrent/pull/21015) diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index 6ceb28593..2098e3635 100644 --- a/src/webui/webapplication.h +++ b/src/webui/webapplication.h @@ -53,7 +53,7 @@ #include "base/utils/version.h" #include "api/isessionmanager.h" -inline const Utils::Version<3, 2> API_VERSION {2, 11, 9}; +inline const Utils::Version<3, 2> API_VERSION {2, 11, 10}; class APIController; class AuthController; From 2f974fac7c6c595d30ee6ac61648dd2c1380215c Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 27 Jun 2025 22:43:15 -0700 Subject: [PATCH 3/4] WebUI: Globally expose loaded categories and tags This allows iframes to access these values without having to re-fetch them. --- src/webui/www/private/scripts/client.js | 51 ++++++++++--------- src/webui/www/private/scripts/contextmenu.js | 4 +- src/webui/www/private/scripts/dynamicTable.js | 2 +- src/webui/www/private/scripts/mocha-init.js | 4 +- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 01f29e25f..399e6e058 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -42,10 +42,17 @@ window.qBittorrent.Client ??= (() => { showLogViewer: showLogViewer, isShowSearchEngine: isShowSearchEngine, isShowRssReader: isShowRssReader, - isShowLogViewer: isShowLogViewer + isShowLogViewer: isShowLogViewer, + categoryMap: categoryMap, + tagMap: tagMap }; }; + // Map + const categoryMap = new Map(); + // Map + const tagMap = new Map(); + let cacheAllSettled; const setup = () => { // fetch various data and store it in memory @@ -146,9 +153,6 @@ const displayFullURLTrackerColumn = LocalPreferences.get("full_url_tracker_colum const CATEGORIES_ALL = "b4af0e4c-e76d-4bac-a392-46cbc18d9655"; const CATEGORIES_UNCATEGORIZED = "e24bd469-ea22-404c-8e2e-a17c82f37ea0"; -// Map -const categoryMap = new Map(); - let selectedCategory = LocalPreferences.get("selected_category", CATEGORIES_ALL); let setCategoryFilter = () => {}; @@ -156,9 +160,6 @@ let setCategoryFilter = () => {}; const TAGS_ALL = "b4af0e4c-e76d-4bac-a392-46cbc18d9655"; const TAGS_UNTAGGED = "e24bd469-ea22-404c-8e2e-a17c82f37ea0"; -// Map -const tagMap = new Map(); - let selectedTag = LocalPreferences.get("selected_tag", TAGS_ALL); let setTagFilter = () => {}; @@ -389,7 +390,7 @@ window.addEventListener("DOMContentLoaded", (event) => { return false; let removed = false; - for (const data of categoryMap.values()) { + for (const data of window.qBittorrent.Client.categoryMap.values()) { const deleteResult = data.torrents.delete(hash); removed ||= deleteResult; } @@ -407,12 +408,12 @@ window.addEventListener("DOMContentLoaded", (event) => { return true; } - let categoryData = categoryMap.get(category); + let categoryData = window.qBittorrent.Client.categoryMap.get(category); if (categoryData === undefined) { // This should not happen categoryData = { torrents: new Set() }; - categoryMap.set(category, categoryData); + window.qBittorrent.Client.categoryMap.set(category, categoryData); } if (categoryData.torrents.has(hash)) @@ -428,7 +429,7 @@ window.addEventListener("DOMContentLoaded", (event) => { return false; let removed = false; - for (const torrents of tagMap.values()) { + for (const torrents of window.qBittorrent.Client.tagMap.values()) { const deleteResult = torrents.delete(hash); removed ||= deleteResult; } @@ -448,10 +449,10 @@ window.addEventListener("DOMContentLoaded", (event) => { const tags = torrent["tags"].split(", "); let added = false; for (const tag of tags) { - let torrents = tagMap.get(tag); + let torrents = window.qBittorrent.Client.tagMap.get(tag); if (torrents === undefined) { // This should not happen torrents = new Set(); - tagMap.set(tag, torrents); + window.qBittorrent.Client.tagMap.set(tag, torrents); } if (!torrents.has(hash)) { @@ -550,7 +551,7 @@ window.addEventListener("DOMContentLoaded", (event) => { } const sortedCategories = []; - for (const [category, categoryData] of categoryMap) { + for (const [category, categoryData] of window.qBittorrent.Client.categoryMap) { sortedCategories.push({ categoryName: category, categoryCount: categoryData.torrents.size, @@ -651,7 +652,7 @@ window.addEventListener("DOMContentLoaded", (event) => { tagFilterList.appendChild(createLink(TAGS_UNTAGGED, "QBT_TR(Untagged)QBT_TR[CONTEXT=TagFilterModel]", untagged)); const sortedTags = []; - for (const [tag, torrents] of tagMap) { + for (const [tag, torrents] of window.qBittorrent.Client.tagMap) { sortedTags.push({ tagName: tag, tagSize: torrents.size @@ -815,8 +816,8 @@ window.addEventListener("DOMContentLoaded", (event) => { updateTrackers = true; updateTorrents = true; torrentsTable.clear(); - categoryMap.clear(); - tagMap.clear(); + window.qBittorrent.Client.categoryMap.clear(); + window.qBittorrent.Client.tagMap.clear(); trackerMap.clear(); } if (responseJSON["rid"]) @@ -827,9 +828,9 @@ window.addEventListener("DOMContentLoaded", (event) => { continue; const responseData = responseJSON["categories"][responseName]; - const categoryData = categoryMap.get(responseName); + const categoryData = window.qBittorrent.Client.categoryMap.get(responseName); if (categoryData === undefined) { - categoryMap.set(responseName, { + window.qBittorrent.Client.categoryMap.set(responseName, { savePath: responseData.savePath, torrents: new Set() }); @@ -843,19 +844,19 @@ window.addEventListener("DOMContentLoaded", (event) => { } if (responseJSON["categories_removed"]) { for (const category of responseJSON["categories_removed"]) - categoryMap.delete(category); + window.qBittorrent.Client.categoryMap.delete(category); updateCategories = true; } if (responseJSON["tags"]) { for (const tag of responseJSON["tags"]) { - if (!tagMap.has(tag)) - tagMap.set(tag, new Set()); + if (!window.qBittorrent.Client.tagMap.has(tag)) + window.qBittorrent.Client.tagMap.set(tag, new Set()); } updateTags = true; } if (responseJSON["tags_removed"]) { for (const tag of responseJSON["tags_removed"]) - tagMap.delete(tag); + window.qBittorrent.Client.tagMap.delete(tag); updateTags = true; } if (responseJSON["trackers"]) { @@ -945,11 +946,11 @@ window.addEventListener("DOMContentLoaded", (event) => { if (updateCategories) { updateCategoryList(); - window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(categoryMap); + window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(window.qBittorrent.Client.categoryMap); } if (updateTags) { updateTagList(); - window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagMap); + window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(window.qBittorrent.Client.tagMap); } if (updateTrackers) updateTrackerList(); diff --git a/src/webui/www/private/scripts/contextmenu.js b/src/webui/www/private/scripts/contextmenu.js index 687a8c1cf..f3c7a693c 100644 --- a/src/webui/www/private/scripts/contextmenu.js +++ b/src/webui/www/private/scripts/contextmenu.js @@ -449,7 +449,7 @@ window.qBittorrent.ContextMenu ??= (() => { this.setEnabled("copyInfohash2", thereAreV2Hashes); const contextTagList = document.getElementById("contextTagList"); - for (const tag of tagMap.keys()) { + for (const tag of window.qBittorrent.Client.tagMap.keys()) { const checkbox = contextTagList.querySelector(`a[href="#Tag/${tag}"] input[type="checkbox"]`); const count = tagCount.get(tag); const hasCount = (count !== undefined); @@ -459,7 +459,7 @@ window.qBittorrent.ContextMenu ??= (() => { } const contextCategoryList = document.getElementById("contextCategoryList"); - for (const category of categoryMap.keys()) { + for (const category of window.qBittorrent.Client.categoryMap.keys()) { const categoryIcon = contextCategoryList.querySelector(`a[href$="#Category/${category}"] img`); const count = categoryCount.get(category); const isEqual = ((count !== undefined) && (count === selectedRows.length)); diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 032a79a1e..e09a60be9 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -1630,7 +1630,7 @@ window.qBittorrent.DynamicTable ??= (() => { return false; } else { - const selectedCategory = categoryMap.get(category); + const selectedCategory = window.qBittorrent.Client.categoryMap.get(category); if (selectedCategory !== undefined) { const selectedCategoryName = `${category}/`; const torrentCategoryName = `${row["full_data"].category}/`; diff --git a/src/webui/www/private/scripts/mocha-init.js b/src/webui/www/private/scripts/mocha-init.js index 9e358f442..b564124fc 100644 --- a/src/webui/www/private/scripts/mocha-init.js +++ b/src/webui/www/private/scripts/mocha-init.js @@ -977,7 +977,7 @@ const initializeWindows = () => { deleteUnusedCategoriesFN = () => { const categories = []; - for (const category of categoryMap.keys()) { + for (const category of window.qBittorrent.Client.categoryMap.keys()) { if (torrentsTable.getFilteredTorrentsNumber("all", category, TAGS_ALL, TRACKERS_ALL) === 0) categories.push(category); } @@ -1082,7 +1082,7 @@ const initializeWindows = () => { deleteUnusedTagsFN = () => { const tags = []; - for (const tag of tagMap.keys()) { + for (const tag of window.qBittorrent.Client.tagMap.keys()) { if (torrentsTable.getFilteredTorrentsNumber("all", CATEGORIES_ALL, tag, TRACKERS_ALL) === 0) tags.push(tag); } From 6b5faba6b2084e310cf2466bd71319fdb08db32b Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 27 Jun 2025 22:45:36 -0700 Subject: [PATCH 4/4] WebUI: Support managing category download path --- src/webui/www/private/newcategory.html | 116 +++++++++++++++++--- src/webui/www/private/scripts/client.js | 7 +- src/webui/www/private/scripts/mocha-init.js | 11 +- 3 files changed, 113 insertions(+), 21 deletions(-) diff --git a/src/webui/www/private/newcategory.html b/src/webui/www/private/newcategory.html index 3abdc6f2a..91cd22e02 100644 --- a/src/webui/www/private/newcategory.html +++ b/src/webui/www/private/newcategory.html @@ -3,7 +3,7 @@ - QBT_TR(New Category)QBT_TR[CONTEXT=TransferListWidget] + QBT_TR(New Category)QBT_TR[CONTEXT=Category] @@ -26,27 +26,78 @@ } }); + const defaultDownloadPath = () => { + const category = document.getElementById("categoryName").value.trim(); + return `${pref.temp_path}/${category}`; + }; + + const setDownloadPath = (option) => { + const downloadPath = document.getElementById("downloadPath"); + const defaultPath = defaultDownloadPath(); + switch (option) { + case "default": + downloadPath.disabled = true; + downloadPath.value = pref.temp_path_enabled ? defaultPath : ""; + downloadPath.placeholder = ""; + break; + case "yes": + downloadPath.disabled = false; + if ((categoryData !== undefined) && (categoryData.downloadPath !== false) && (categoryData.downloadPath !== null)) + downloadPath.value = categoryData.downloadPath; + else + downloadPath.value = ""; + downloadPath.placeholder = defaultPath; + break; + case "no": + downloadPath.disabled = true; + downloadPath.value = ""; + downloadPath.placeholder = ""; + break; + } + }; + const searchParams = new URLSearchParams(window.location.search); const uriAction = window.qBittorrent.Misc.safeTrim(searchParams.get("action")); const uriHashes = window.qBittorrent.Misc.safeTrim(searchParams.get("hashes")); const uriCategoryName = window.qBittorrent.Misc.safeTrim(searchParams.get("categoryName")); - const uriSavePath = window.qBittorrent.Misc.safeTrim(searchParams.get("savePath")); + const pref = window.parent.qBittorrent.Cache.preferences.get(); + const categoryData = window.parent.qBittorrent.Client.categoryMap.get(uriCategoryName); + + const useDownloadPathElem = document.getElementById("useDownloadPath"); + const categoryNameElem = document.getElementById("categoryName"); + const savePathElem = document.getElementById("savePath"); if (uriAction === "edit") { if (!uriCategoryName) return; - document.getElementById("categoryName").disabled = true; - document.getElementById("categoryName").value = window.qBittorrent.Misc.escapeHtml(uriCategoryName); - document.getElementById("savePath").value = window.qBittorrent.Misc.escapeHtml(uriSavePath); - document.getElementById("savePath").focus(); + categoryNameElem.disabled = true; + categoryNameElem.value = uriCategoryName; + savePathElem.value = categoryData.savePath; + savePathElem.placeholder = `${pref.save_path}/${uriCategoryName}`; + savePathElem.focus(); + + switch (categoryData.downloadPath) { + case false: + useDownloadPathElem.selectedIndex = 2; + setDownloadPath("no"); + break; + case null: + useDownloadPathElem.selectedIndex = 0; + setDownloadPath("default"); + break; + default: + useDownloadPathElem.selectedIndex = 1; + setDownloadPath("yes"); + break; + } } else if (uriAction === "createSubcategory") { - document.getElementById("categoryName").value = window.qBittorrent.Misc.escapeHtml(uriCategoryName); - document.getElementById("categoryName").focus(); + categoryNameElem.value = uriCategoryName; + categoryNameElem.focus(); } else { - document.getElementById("categoryName").focus(); + categoryNameElem.focus(); } document.getElementById("categoryNameButton").addEventListener("click", (e) => { @@ -66,6 +117,16 @@ return true; }; + const body = {}; + const useDownloadPath = document.getElementById("useDownloadPath"); + if (useDownloadPath.value === "no") { + body.downloadPathEnabled = false; + } + else if (useDownloadPath.value === "yes") { + body.downloadPathEnabled = true; + body.downloadPath = document.getElementById("downloadPath").value.trim(); + } + switch (uriAction) { case "set": if ((uriHashes === "") || !verifyCategoryName(categoryName)) @@ -74,6 +135,7 @@ fetch("api/v2/torrents/createCategory", { method: "POST", body: new URLSearchParams({ + ...body, category: categoryName, savePath: savePath }) @@ -110,6 +172,7 @@ fetch("api/v2/torrents/createCategory", { method: "POST", body: new URLSearchParams({ + ...body, category: categoryName, savePath: savePath }) @@ -129,6 +192,7 @@ fetch("api/v2/torrents/editCategory", { method: "POST", body: new URLSearchParams({ + ...body, category: uriCategoryName, // category name can't be changed savePath: savePath }) @@ -146,6 +210,10 @@ } }); + useDownloadPathElem.addEventListener("change", (e) => { + setDownloadPath(e.target.value); + }); + window.qBittorrent.pathAutofill.attachPathAutofill(); }); @@ -153,10 +221,32 @@
- - - - +
+ + +
+
+ + +
+ +
+ QBT_TR(Save path for incomplete torrents:)QBT_TR[CONTEXT=Category] + +
+ + +
+
+ + +
+
+
diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 399e6e058..59462273e 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -832,12 +832,15 @@ window.addEventListener("DOMContentLoaded", (event) => { if (categoryData === undefined) { window.qBittorrent.Client.categoryMap.set(responseName, { savePath: responseData.savePath, + downloadPath: responseData.download_path ?? null, torrents: new Set() }); } else { - // only the save path can change for existing categories - categoryData.savePath = responseData.savePath; + if (responseData.savePath !== undefined) + categoryData.savePath = responseData.savePath; + if (responseData.download_path !== undefined) + categoryData.downloadPath = responseData.download_path; } } updateCategories = true; diff --git a/src/webui/www/private/scripts/mocha-init.js b/src/webui/www/private/scripts/mocha-init.js index b564124fc..7ea2bb13d 100644 --- a/src/webui/www/private/scripts/mocha-init.js +++ b/src/webui/www/private/scripts/mocha-init.js @@ -869,7 +869,7 @@ const initializeWindows = () => { paddingVertical: 0, paddingHorizontal: 0, width: 400, - height: 150 + height: 200 }); }; @@ -910,7 +910,7 @@ const initializeWindows = () => { paddingVertical: 0, paddingHorizontal: 0, width: 400, - height: 150 + height: 200 }); }; @@ -932,7 +932,7 @@ const initializeWindows = () => { paddingVertical: 0, paddingHorizontal: 0, width: 400, - height: 150 + height: 200 }); }; @@ -940,8 +940,7 @@ const initializeWindows = () => { const contentURL = new URL("newcategory.html", window.location); contentURL.search = new URLSearchParams({ action: "edit", - categoryName: category, - savePath: categoryMap.get(category).savePath + categoryName: category }); new MochaUI.Window({ id: "editCategoryPage", @@ -955,7 +954,7 @@ const initializeWindows = () => { paddingVertical: 0, paddingHorizontal: 0, width: 400, - height: 150 + height: 200 }); };