From 28036e716a29920775cbeb8aa5ef8a9250d0447a Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 19 Sep 2024 14:09:14 -0700 Subject: [PATCH 1/8] WebUI: Make torrent content table logic reusable This logic was tightly coupled with the prop-files table. Signed-off-by: Thomas Piccirello --- src/webui/www/private/index.html | 1 + src/webui/www/private/scripts/dynamicTable.js | 24 +- src/webui/www/private/scripts/prop-files.js | 479 +-------------- .../www/private/scripts/torrent-content.js | 572 ++++++++++++++++++ src/webui/www/webui.qrc | 1 + 5 files changed, 610 insertions(+), 467 deletions(-) create mode 100644 src/webui/www/private/scripts/torrent-content.js diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index e10762d5f..89125c909 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -39,6 +39,7 @@ + diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 032a79a1e..463311cc5 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -2179,12 +2179,13 @@ window.qBittorrent.DynamicTable ??= (() => { populateTable(root) { this.fileTree.setRoot(root); root.children.each((node) => { - this.#addNodeToTable(node, 0); + this.#addNodeToTable(node, 0, root); }); } - #addNodeToTable(node, depth) { + #addNodeToTable(node, depth, parent) { node.depth = depth; + node.parent = parent; if (node.isFolder) { const data = { @@ -2207,7 +2208,7 @@ window.qBittorrent.DynamicTable ??= (() => { } node.children.each((child) => { - this.#addNodeToTable(child, depth + 1); + this.#addNodeToTable(child, depth + 1, node); }); } @@ -2714,12 +2715,13 @@ window.qBittorrent.DynamicTable ??= (() => { populateTable(root) { this.fileTree.setRoot(root); root.children.each((node) => { - this.#addNodeToTable(node, 0); + this.#addNodeToTable(node, 0, root); }); } - #addNodeToTable(node, depth) { + #addNodeToTable(node, depth, parent) { node.depth = depth; + node.parent = parent; if (node.isFolder) { if (!this.collapseState.has(node.rowId)) @@ -2730,7 +2732,7 @@ window.qBittorrent.DynamicTable ??= (() => { checked: node.checked, remaining: node.remaining, progress: node.progress, - priority: window.qBittorrent.PropFiles.normalizePriority(node.priority), + priority: window.qBittorrent.TorrentContent.normalizePriority(node.priority), availability: node.availability, fileId: -1, name: node.name @@ -2747,7 +2749,7 @@ window.qBittorrent.DynamicTable ??= (() => { } node.children.each((child) => { - this.#addNodeToTable(child, depth + 1); + this.#addNodeToTable(child, depth + 1, node); }); } @@ -2808,9 +2810,9 @@ window.qBittorrent.DynamicTable ??= (() => { const downloadCheckbox = td.children[1]; if (downloadCheckbox === undefined) - td.append(window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value)); + td.append(window.qBittorrent.TorrentContent.createDownloadCheckbox(id, row.full_data.fileId, value)); else - window.qBittorrent.PropFiles.updateDownloadCheckbox(downloadCheckbox, id, row.full_data.fileId, value); + window.qBittorrent.TorrentContent.updateDownloadCheckbox(downloadCheckbox, id, row.full_data.fileId, value); }; this.columns["checked"].staticWidth = 50; @@ -2900,9 +2902,9 @@ window.qBittorrent.DynamicTable ??= (() => { const priorityCombo = td.firstElementChild; if (priorityCombo === null) - td.append(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value)); + td.append(window.qBittorrent.TorrentContent.createPriorityCombo(id, row.full_data.fileId, value)); else - window.qBittorrent.PropFiles.updatePriorityCombo(priorityCombo, id, row.full_data.fileId, value); + window.qBittorrent.TorrentContent.updatePriorityCombo(priorityCombo, id, row.full_data.fileId, value); }; this.columns["priority"].staticWidth = 140; diff --git a/src/webui/www/private/scripts/prop-files.js b/src/webui/www/private/scripts/prop-files.js index b0242a9aa..d780a4be1 100644 --- a/src/webui/www/private/scripts/prop-files.js +++ b/src/webui/www/private/scripts/prop-files.js @@ -32,248 +32,16 @@ window.qBittorrent ??= {}; window.qBittorrent.PropFiles ??= (() => { const exports = () => { return { - normalizePriority: normalizePriority, - createDownloadCheckbox: createDownloadCheckbox, - updateDownloadCheckbox: updateDownloadCheckbox, - createPriorityCombo: createPriorityCombo, - updatePriorityCombo: updatePriorityCombo, updateData: updateData, clear: clear }; }; - const torrentFilesTable = new window.qBittorrent.DynamicTable.TorrentFilesTable(); - const FilePriority = window.qBittorrent.FileTree.FilePriority; - const TriState = window.qBittorrent.FileTree.TriState; - let is_seed = true; let current_hash = ""; - const normalizePriority = (priority) => { - switch (priority) { - case FilePriority.Ignored: - case FilePriority.Normal: - case FilePriority.High: - case FilePriority.Maximum: - case FilePriority.Mixed: - return priority; - default: - return FilePriority.Normal; - } - }; - - const getAllChildren = (id, fileId) => { - const node = torrentFilesTable.getNode(id); - if (!node.isFolder) { - return { - rowIds: [id], - fileIds: [fileId] - }; - } - - const rowIds = []; - const fileIds = []; - - const getChildFiles = (node) => { - if (node.isFolder) { - node.children.each((child) => { - getChildFiles(child); - }); - } - else { - rowIds.push(node.data.rowId); - fileIds.push(node.data.fileId); - } - }; - - node.children.each((child) => { - getChildFiles(child); - }); - - return { - rowIds: rowIds, - fileIds: fileIds - }; - }; - - const fileCheckboxClicked = (e) => { - e.stopPropagation(); - - const checkbox = e.target; - const priority = checkbox.checked ? FilePriority.Normal : FilePriority.Ignored; - const id = checkbox.getAttribute("data-id"); - const fileId = checkbox.getAttribute("data-file-id"); - - const rows = getAllChildren(id, fileId); - - setFilePriority(rows.rowIds, rows.fileIds, priority); - updateGlobalCheckbox(); - }; - - const fileComboboxChanged = (e) => { - const combobox = e.target; - const priority = combobox.value; - const id = combobox.getAttribute("data-id"); - const fileId = combobox.getAttribute("data-file-id"); - - const rows = getAllChildren(id, fileId); - - setFilePriority(rows.rowIds, rows.fileIds, priority); - updateGlobalCheckbox(); - }; - - const createDownloadCheckbox = (id, fileId, checked) => { - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.setAttribute("data-id", id); - checkbox.setAttribute("data-file-id", fileId); - checkbox.addEventListener("click", fileCheckboxClicked); - - updateCheckbox(checkbox, checked); - return checkbox; - }; - - const updateDownloadCheckbox = (checkbox, id, fileId, checked) => { - checkbox.setAttribute("data-id", id); - checkbox.setAttribute("data-file-id", fileId); - updateCheckbox(checkbox, checked); - }; - - const updateCheckbox = (checkbox, checked) => { - switch (checked) { - case TriState.Checked: - setCheckboxChecked(checkbox); - break; - case TriState.Unchecked: - setCheckboxUnchecked(checkbox); - break; - case TriState.Partial: - setCheckboxPartial(checkbox); - break; - } - }; - - const createPriorityCombo = (id, fileId, selectedPriority) => { - const createOption = (priority, isSelected, text) => { - const option = document.createElement("option"); - option.value = priority.toString(); - option.selected = isSelected; - option.textContent = text; - return option; - }; - - const select = document.createElement("select"); - select.id = `comboPrio${id}`; - select.setAttribute("data-id", id); - select.setAttribute("data-file-id", fileId); - select.classList.add("combo_priority"); - select.addEventListener("change", fileComboboxChanged); - - select.appendChild(createOption(FilePriority.Ignored, (FilePriority.Ignored === selectedPriority), "QBT_TR(Do not download)QBT_TR[CONTEXT=PropListDelegate]")); - select.appendChild(createOption(FilePriority.Normal, (FilePriority.Normal === selectedPriority), "QBT_TR(Normal)QBT_TR[CONTEXT=PropListDelegate]")); - select.appendChild(createOption(FilePriority.High, (FilePriority.High === selectedPriority), "QBT_TR(High)QBT_TR[CONTEXT=PropListDelegate]")); - select.appendChild(createOption(FilePriority.Maximum, (FilePriority.Maximum === selectedPriority), "QBT_TR(Maximum)QBT_TR[CONTEXT=PropListDelegate]")); - - // "Mixed" priority is for display only; it shouldn't be selectable - const mixedPriorityOption = createOption(FilePriority.Mixed, (FilePriority.Mixed === selectedPriority), "QBT_TR(Mixed)QBT_TR[CONTEXT=PropListDelegate]"); - mixedPriorityOption.disabled = true; - select.appendChild(mixedPriorityOption); - - return select; - }; - - const updatePriorityCombo = (combobox, id, fileId, selectedPriority) => { - combobox.id = `comboPrio${id}`; - combobox.setAttribute("data-id", id); - combobox.setAttribute("data-file-id", fileId); - if (Number(combobox.value) !== selectedPriority) - selectComboboxPriority(combobox, selectedPriority); - }; - - const selectComboboxPriority = (combobox, priority) => { - const options = combobox.options; - for (let i = 0; i < options.length; ++i) { - const option = options[i]; - if (Number(option.value) === priority) - option.selected = true; - else - option.selected = false; - } - - combobox.value = priority; - }; - - const switchCheckboxState = (e) => { - e.stopPropagation(); - - const rowIds = []; - const fileIds = []; - let priority = FilePriority.Ignored; - const checkbox = document.getElementById("tristate_cb"); - - if (checkbox.state === "checked") { - setCheckboxUnchecked(checkbox); - // set file priority for all checked to Ignored - torrentFilesTable.getFilteredAndSortedRows().forEach((row) => { - const rowId = row.rowId; - const fileId = row.full_data.fileId; - const isChecked = (row.full_data.checked === TriState.Checked); - const isFolder = (fileId === -1); - if (!isFolder && isChecked) { - rowIds.push(rowId); - fileIds.push(fileId); - } - }); - } - else { - setCheckboxChecked(checkbox); - priority = FilePriority.Normal; - // set file priority for all unchecked to Normal - torrentFilesTable.getFilteredAndSortedRows().forEach((row) => { - const rowId = row.rowId; - const fileId = row.full_data.fileId; - const isUnchecked = (row.full_data.checked === TriState.Unchecked); - const isFolder = (fileId === -1); - if (!isFolder && isUnchecked) { - rowIds.push(rowId); - fileIds.push(fileId); - } - }); - } - - if (rowIds.length > 0) - setFilePriority(rowIds, fileIds, priority); - }; - - const updateGlobalCheckbox = () => { - const checkbox = document.getElementById("tristate_cb"); - if (torrentFilesTable.isAllCheckboxesChecked()) - setCheckboxChecked(checkbox); - else if (torrentFilesTable.isAllCheckboxesUnchecked()) - setCheckboxUnchecked(checkbox); - else - setCheckboxPartial(checkbox); - }; - - const setCheckboxChecked = (checkbox) => { - checkbox.state = "checked"; - checkbox.indeterminate = false; - checkbox.checked = true; - }; - - const setCheckboxUnchecked = (checkbox) => { - checkbox.state = "unchecked"; - checkbox.indeterminate = false; - checkbox.checked = false; - }; - - const setCheckboxPartial = (checkbox) => { - checkbox.state = "partial"; - checkbox.indeterminate = true; - }; - - const setFilePriority = (ids, fileIds, priority) => { - if (current_hash === "") - return; + const onFilePriorityChanged = (fileIds, priority) => { + // ignore folders + fileIds = fileIds.map(id => parseInt(id, 10)).filter(id => !window.qBittorrent.TorrentContent.isFolder(id)); clearTimeout(loadTorrentFilesDataTimer); loadTorrentFilesDataTimer = -1; @@ -292,15 +60,6 @@ window.qBittorrent.PropFiles ??= (() => { loadTorrentFilesDataTimer = loadTorrentFilesData.delay(1000); }); - - const ignore = (priority === FilePriority.Ignored); - ids.forEach((id) => { - torrentFilesTable.setIgnored(id, ignore); - - const combobox = document.getElementById(`comboPrio${id}`); - if (combobox !== null) - selectComboboxPriority(combobox, priority); - }); }; let loadTorrentFilesDataTimer = -1; @@ -339,14 +98,13 @@ window.qBittorrent.PropFiles ??= (() => { const files = await response.json(); - clearTimeout(torrentFilesFilterInputTimer); - torrentFilesFilterInputTimer = -1; + window.qBittorrent.TorrentContent.clearFilterInputTimer(); if (files.length === 0) { torrentFilesTable.clear(); } else { - handleNewTorrentFiles(files); + window.qBittorrent.TorrentContent.updateData(files); if (loadedNewTorrent) torrentFilesTable.collapseAllNodes(); } @@ -363,133 +121,7 @@ window.qBittorrent.PropFiles ??= (() => { loadTorrentFilesData(); }; - const handleNewTorrentFiles = (files) => { - is_seed = (files.length > 0) ? files[0].is_seed : true; - - const rows = files.map((file, index) => { - const ignore = (file.priority === FilePriority.Ignored); - const row = { - fileId: index, - checked: (ignore ? TriState.Unchecked : TriState.Checked), - fileName: file.name, - name: window.qBittorrent.Filesystem.fileName(file.name), - size: file.size, - progress: window.qBittorrent.Misc.toFixedPointString((file.progress * 100), 1), - priority: normalizePriority(file.priority), - remaining: (ignore ? 0 : (file.size * (1 - file.progress))), - availability: file.availability - }; - return row; - }); - - addRowsToTable(rows); - updateGlobalCheckbox(); - }; - - const addRowsToTable = (rows) => { - const selectedFiles = torrentFilesTable.selectedRowsIds(); - let rowId = 0; - - const rootNode = new window.qBittorrent.FileTree.FolderNode(); - - rows.forEach((row) => { - const pathItems = row.fileName.split(window.qBittorrent.Filesystem.PathSeparator); - - pathItems.pop(); // remove last item (i.e. file name) - let parent = rootNode; - pathItems.forEach((folderName) => { - if (folderName === ".unwanted") - return; - - let folderNode = null; - if (parent.children !== null) { - for (let i = 0; i < parent.children.length; ++i) { - const childFolder = parent.children[i]; - if (childFolder.name === folderName) { - folderNode = childFolder; - break; - } - } - } - - if (folderNode === null) { - folderNode = new window.qBittorrent.FileTree.FolderNode(); - folderNode.path = (parent.path === "") - ? folderName - : [parent.path, folderName].join(window.qBittorrent.Filesystem.PathSeparator); - folderNode.name = folderName; - folderNode.rowId = rowId; - folderNode.root = parent; - parent.addChild(folderNode); - - ++rowId; - } - - parent = folderNode; - }); - - const isChecked = row.checked ? TriState.Checked : TriState.Unchecked; - const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining; - const childNode = new window.qBittorrent.FileTree.FileNode(); - childNode.name = row.name; - childNode.path = row.fileName; - childNode.rowId = rowId; - childNode.size = row.size; - childNode.checked = isChecked; - childNode.remaining = remaining; - childNode.progress = row.progress; - childNode.priority = row.priority; - childNode.availability = row.availability; - childNode.root = parent; - childNode.data = row; - parent.addChild(childNode); - - ++rowId; - }); - - torrentFilesTable.populateTable(rootNode); - torrentFilesTable.updateTable(false); - - if (selectedFiles.length > 0) - torrentFilesTable.reselectRows(selectedFiles); - }; - - const filesPriorityMenuClicked = (priority) => { - const selectedRows = torrentFilesTable.selectedRowsIds(); - if (selectedRows.length === 0) - return; - - const rowIds = []; - const fileIds = []; - selectedRows.forEach((rowId) => { - rowIds.push(rowId); - fileIds.push(torrentFilesTable.getRowFileId(rowId)); - }); - - const uniqueRowIds = {}; - const uniqueFileIds = {}; - for (let i = 0; i < rowIds.length; ++i) { - const rows = getAllChildren(rowIds[i], fileIds[i]); - rows.rowIds.forEach((rowId) => { - uniqueRowIds[rowId] = true; - }); - rows.fileIds.forEach((fileId) => { - uniqueFileIds[fileId] = true; - }); - } - - setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); - }; - - const singleFileRename = (hash) => { - const rowId = torrentFilesTable.selectedRowsIds()[0]; - if (rowId === undefined) - return; - const row = torrentFilesTable.rows.get(rowId); - if (!row) - return; - - const node = torrentFilesTable.getNode(rowId); + const singleFileRename = (hash, node) => { const path = node.path; new MochaUI.Window({ @@ -504,16 +136,19 @@ window.qBittorrent.PropFiles ??= (() => { paddingVertical: 0, paddingHorizontal: 0, width: 400, - height: 100 + height: 100, + onCloseComplete: () => { + updateData(); + } }); }; - const multiFileRename = (hash) => { + const multiFileRename = (hash, selectedRows) => { new MochaUI.Window({ id: "multiRenamePage", icon: "images/qbittorrent-tray.svg", title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]", - data: { hash: hash, selectedRows: torrentFilesTable.selectedRows }, + data: { hash: hash, selectedRows: selectedRows }, loadMethod: "xhr", contentURL: "rename_files.html", scrollbars: false, @@ -523,89 +158,21 @@ window.qBittorrent.PropFiles ??= (() => { paddingHorizontal: 0, width: 800, height: 420, - resizeLimit: { x: [800], y: [420] } + resizeLimit: { x: [800], y: [420] }, + onCloseComplete: () => { + updateData(); + } }); }; - const torrentFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({ - targets: "#torrentFilesTableDiv tbody tr", - menu: "torrentFilesMenu", - actions: { - Rename: (element, ref) => { - const hash = torrentsTable.getCurrentTorrentID(); - if (!hash) - return; + const onFileRenameHandler = (selectedRows, selectedNodes) => { + if (selectedNodes.length === 1) + singleFileRename(current_hash, selectedNodes[0]); + else if (selectedNodes.length > 1) + multiFileRename(current_hash, selectedRows); + }; - if (torrentFilesTable.selectedRowsIds().length > 1) - multiFileRename(hash); - else - singleFileRename(hash); - }, - - FilePrioIgnore: (element, ref) => { - filesPriorityMenuClicked(FilePriority.Ignored); - }, - FilePrioNormal: (element, ref) => { - filesPriorityMenuClicked(FilePriority.Normal); - }, - FilePrioHigh: (element, ref) => { - filesPriorityMenuClicked(FilePriority.High); - }, - FilePrioMaximum: (element, ref) => { - filesPriorityMenuClicked(FilePriority.Maximum); - } - }, - offsets: { - x: 0, - y: 2 - }, - onShow: function() { - if (is_seed) - this.hideItem("FilePrio"); - else - this.showItem("FilePrio"); - } - }); - - torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu, true); - // inject checkbox into table header - const tableHeaders = document.querySelectorAll("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th"); - if (tableHeaders.length > 0) { - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.id = "tristate_cb"; - checkbox.addEventListener("click", switchCheckboxState); - - const checkboxTH = tableHeaders[0]; - checkboxTH.append(checkbox); - } - - // default sort by name column - if (torrentFilesTable.getSortedColumn() === null) - torrentFilesTable.setSortedColumn("name"); - - // listen for changes to torrentFilesFilterInput - let torrentFilesFilterInputTimer = -1; - document.getElementById("torrentFilesFilterInput").addEventListener("input", (event) => { - clearTimeout(torrentFilesFilterInputTimer); - - const value = document.getElementById("torrentFilesFilterInput").value; - torrentFilesTable.setFilter(value); - - torrentFilesFilterInputTimer = setTimeout(() => { - torrentFilesFilterInputTimer = -1; - - if (current_hash === "") - return; - - torrentFilesTable.updateTable(); - - if (value.trim() === "") - torrentFilesTable.collapseAllNodes(); - else - torrentFilesTable.expandAllNodes(); - }, window.qBittorrent.Misc.FILTER_INPUT_DELAY); - }); + const torrentFilesTable = window.qBittorrent.TorrentContent.init("torrentFilesTableDiv", window.qBittorrent.DynamicTable.TorrentFilesTable, onFilePriorityChanged, onFileRenameHandler); const clear = () => { torrentFilesTable.clear(); diff --git a/src/webui/www/private/scripts/torrent-content.js b/src/webui/www/private/scripts/torrent-content.js new file mode 100644 index 000000000..24f392393 --- /dev/null +++ b/src/webui/www/private/scripts/torrent-content.js @@ -0,0 +1,572 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2025 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +"use strict"; + +window.qBittorrent ??= {}; +window.qBittorrent.TorrentContent ??= (() => { + const exports = () => { + return { + init: init, + normalizePriority: normalizePriority, + isFolder: isFolder, + createDownloadCheckbox: createDownloadCheckbox, + updateDownloadCheckbox: updateDownloadCheckbox, + createPriorityCombo: createPriorityCombo, + updatePriorityCombo: updatePriorityCombo, + updateData: updateData, + clearFilterInputTimer: clearFilterInputTimer + }; + }; + + let torrentFilesTable; + const FilePriority = window.qBittorrent.FileTree.FilePriority; + const TriState = window.qBittorrent.FileTree.TriState; + let torrentFilesFilterInputTimer = -1; + let onFilePriorityChanged = null; + + const normalizePriority = (priority) => { + priority = Number(priority); + + switch (priority) { + case FilePriority.Ignored: + case FilePriority.Normal: + case FilePriority.High: + case FilePriority.Maximum: + case FilePriority.Mixed: + return priority; + default: + return FilePriority.Normal; + } + }; + + const triStateFromPriority = (priority) => { + switch (normalizePriority(priority)) { + case FilePriority.Ignored: + return TriState.Unchecked; + case FilePriority.Normal: + case FilePriority.High: + case FilePriority.Maximum: + return TriState.Checked; + case FilePriority.Mixed: + return TriState.Partial; + } + }; + + const isFolder = (fileId) => { + return fileId === -1; + }; + + const getAllChildren = (id, fileId) => { + const getChildFiles = (node) => { + rowIds.push(node.data.rowId); + fileIds.push(node.data.fileId); + + if (node.isFolder) { + node.children.forEach((child) => { + getChildFiles(child); + }); + } + }; + + const node = torrentFilesTable.getNode(id); + const rowIds = [node.data.rowId]; + const fileIds = [node.data.fileId]; + + node.children.forEach((child) => { + getChildFiles(child); + }); + + return { + rowIds: rowIds, + fileIds: fileIds + }; + }; + + const fileCheckboxClicked = (e) => { + e.stopPropagation(); + + const checkbox = e.target; + const priority = checkbox.checked ? FilePriority.Normal : FilePriority.Ignored; + const id = checkbox.getAttribute("data-id"); + const fileId = Number(checkbox.getAttribute("data-file-id")); + + const rows = getAllChildren(id, fileId); + + setFilePriority(rows.rowIds, rows.fileIds, priority); + updateParentFolder(id); + }; + + const fileComboboxChanged = (e) => { + const combobox = e.target; + const priority = combobox.value; + const id = combobox.getAttribute("data-id"); + const fileId = Number(combobox.getAttribute("data-file-id")); + + const rows = getAllChildren(id, fileId); + + setFilePriority(rows.rowIds, rows.fileIds, priority); + updateParentFolder(id); + }; + + const createDownloadCheckbox = (id, fileId, checked) => { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.setAttribute("data-id", id); + checkbox.setAttribute("data-file-id", fileId); + checkbox.addEventListener("click", fileCheckboxClicked); + + updateCheckbox(checkbox, checked); + return checkbox; + }; + + const updateDownloadCheckbox = (checkbox, id, fileId, checked) => { + checkbox.setAttribute("data-id", id); + checkbox.setAttribute("data-file-id", fileId); + updateCheckbox(checkbox, checked); + }; + + const updateCheckbox = (checkbox, checked) => { + switch (checked) { + case TriState.Checked: + setCheckboxChecked(checkbox); + break; + case TriState.Unchecked: + setCheckboxUnchecked(checkbox); + break; + case TriState.Partial: + setCheckboxPartial(checkbox); + break; + } + }; + + const createPriorityCombo = (id, fileId, selectedPriority) => { + const createOption = (priority, isSelected, text) => { + const option = document.createElement("option"); + option.value = priority.toString(); + option.selected = isSelected; + option.textContent = text; + return option; + }; + + const select = document.createElement("select"); + select.setAttribute("data-id", id); + select.setAttribute("data-file-id", fileId); + select.classList.add("combo_priority"); + select.addEventListener("change", fileComboboxChanged); + + select.appendChild(createOption(FilePriority.Ignored, (FilePriority.Ignored === selectedPriority), "QBT_TR(Do not download)QBT_TR[CONTEXT=PropListDelegate]")); + select.appendChild(createOption(FilePriority.Normal, (FilePriority.Normal === selectedPriority), "QBT_TR(Normal)QBT_TR[CONTEXT=PropListDelegate]")); + select.appendChild(createOption(FilePriority.High, (FilePriority.High === selectedPriority), "QBT_TR(High)QBT_TR[CONTEXT=PropListDelegate]")); + select.appendChild(createOption(FilePriority.Maximum, (FilePriority.Maximum === selectedPriority), "QBT_TR(Maximum)QBT_TR[CONTEXT=PropListDelegate]")); + + // "Mixed" priority is for display only; it shouldn't be selectable + const mixedPriorityOption = createOption(FilePriority.Mixed, (FilePriority.Mixed === selectedPriority), "QBT_TR(Mixed)QBT_TR[CONTEXT=PropListDelegate]"); + mixedPriorityOption.disabled = true; + select.appendChild(mixedPriorityOption); + + return select; + }; + + const updatePriorityCombo = (combobox, id, fileId, selectedPriority) => { + combobox.setAttribute("data-id", id); + combobox.setAttribute("data-file-id", fileId); + if (normalizePriority(combobox.value) !== selectedPriority) + selectComboboxPriority(combobox, normalizePriority(selectedPriority)); + }; + + const selectComboboxPriority = (combobox, priority) => { + const options = combobox.options; + for (let i = 0; i < options.length; ++i) { + const option = options[i]; + if (normalizePriority(option.value) === priority) + option.selected = true; + else + option.selected = false; + } + + combobox.value = priority; + }; + + const getComboboxPriority = (id) => { + const row = torrentFilesTable.rows.get(id.toString()); + return normalizePriority(row.full_data.priority, 10); + }; + + const switchGlobalCheckboxState = (e) => { + e.stopPropagation(); + + const rowIds = []; + const fileIds = []; + const checkbox = document.getElementById("tristate_cb"); + const priority = (checkbox.state === TriState.Checked) ? FilePriority.Ignored : FilePriority.Normal; + + if (checkbox.state === TriState.Checked) { + setCheckboxUnchecked(checkbox); + torrentFilesTable.rows.forEach((row) => { + const rowId = row.rowId; + const fileId = row.full_data.fileId; + const isChecked = (getCheckboxState(rowId) === TriState.Checked); + if (isChecked) { + rowIds.push(rowId); + fileIds.push(fileId); + } + }); + } + else { + setCheckboxChecked(checkbox); + torrentFilesTable.rows.forEach((row) => { + const rowId = row.rowId; + const fileId = row.full_data.fileId; + const isUnchecked = (getCheckboxState(rowId) === TriState.Unchecked); + if (isUnchecked) { + rowIds.push(rowId); + fileIds.push(fileId); + } + }); + } + + if (rowIds.length > 0) { + setFilePriority(rowIds, fileIds, priority); + for (const id of rowIds) + updateParentFolder(id); + } + }; + + const updateGlobalCheckbox = () => { + const checkbox = document.getElementById("tristate_cb"); + if (torrentFilesTable.isAllCheckboxesChecked()) + setCheckboxChecked(checkbox); + else if (torrentFilesTable.isAllCheckboxesUnchecked()) + setCheckboxUnchecked(checkbox); + else + setCheckboxPartial(checkbox); + }; + + const setCheckboxChecked = (checkbox) => { + checkbox.state = TriState.Checked; + checkbox.indeterminate = false; + checkbox.checked = true; + }; + + const setCheckboxUnchecked = (checkbox) => { + checkbox.state = TriState.Unchecked; + checkbox.indeterminate = false; + checkbox.checked = false; + }; + + const setCheckboxPartial = (checkbox) => { + checkbox.state = TriState.Partial; + checkbox.indeterminate = true; + }; + + const getCheckboxState = (id) => { + const row = torrentFilesTable.rows.get(id.toString()); + return Number(row.full_data.checked); + }; + + const setFilePriority = (ids, fileIds, priority) => { + priority = normalizePriority(priority); + + if (onFilePriorityChanged) + onFilePriorityChanged(fileIds, priority); + + const ignore = (priority === FilePriority.Ignored); + ids.forEach((_id) => { + _id = _id.toString(); + torrentFilesTable.setIgnored(_id, ignore); + + const row = torrentFilesTable.rows.get(_id); + row.full_data.priority = priority; + row.full_data.checked = triStateFromPriority(priority); + }); + }; + + const updateData = (files) => { + const rows = files.map((file, index) => { + const ignore = (file.priority === FilePriority.Ignored); + const row = { + fileId: index, + checked: (ignore ? TriState.Unchecked : TriState.Checked), + fileName: file.name, + name: window.qBittorrent.Filesystem.fileName(file.name), + size: file.size, + progress: window.qBittorrent.Misc.toFixedPointString((file.progress * 100), 1), + priority: normalizePriority(file.priority), + remaining: (ignore ? 0 : (file.size * (1 - file.progress))), + availability: file.availability + }; + + return row; + }); + + addRowsToTable(rows); + updateGlobalCheckbox(); + }; + + const addRowsToTable = (rows) => { + const selectedFiles = torrentFilesTable.selectedRowsIds(); + let rowId = 0; + + const rootNode = new window.qBittorrent.FileTree.FolderNode(); + + rows.forEach((row) => { + const pathItems = row.fileName.split(window.qBittorrent.Filesystem.PathSeparator); + + pathItems.pop(); // remove last item (i.e. file name) + let parent = rootNode; + pathItems.forEach((folderName) => { + if (folderName === ".unwanted") + return; + + let folderNode = null; + if (parent.children !== null) { + for (let i = 0; i < parent.children.length; ++i) { + const childFolder = parent.children[i]; + if (childFolder.name === folderName) { + folderNode = childFolder; + break; + } + } + } + + if (folderNode === null) { + folderNode = new window.qBittorrent.FileTree.FolderNode(); + folderNode.path = (parent.path === "") + ? folderName + : [parent.path, folderName].join(window.qBittorrent.Filesystem.PathSeparator); + folderNode.name = folderName; + folderNode.rowId = rowId; + folderNode.root = parent; + parent.addChild(folderNode); + + ++rowId; + } + + parent = folderNode; + }); + + const isChecked = row.checked ? TriState.Checked : TriState.Unchecked; + const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining; + const childNode = new window.qBittorrent.FileTree.FileNode(); + childNode.name = row.name; + childNode.path = row.fileName; + childNode.rowId = rowId; + childNode.size = row.size; + childNode.checked = isChecked; + childNode.remaining = remaining; + childNode.progress = row.progress; + childNode.priority = row.priority; + childNode.availability = row.availability; + childNode.root = parent; + childNode.data = row; + parent.addChild(childNode); + + ++rowId; + }); + + torrentFilesTable.populateTable(rootNode); + torrentFilesTable.updateTable(); + + if (selectedFiles.length > 0) + torrentFilesTable.reselectRows(selectedFiles); + }; + + const filesPriorityMenuClicked = (priority) => { + const selectedRows = torrentFilesTable.selectedRowsIds(); + if (selectedRows.length === 0) + return; + + const rowIds = []; + const fileIds = []; + selectedRows.forEach((rowId) => { + rowIds.push(rowId); + fileIds.push(Number(torrentFilesTable.getRowFileId(rowId))); + }); + + const uniqueRowIds = {}; + const uniqueFileIds = {}; + for (let i = 0; i < rowIds.length; ++i) { + const rows = getAllChildren(rowIds[i], fileIds[i]); + rows.rowIds.forEach((rowId) => { + uniqueRowIds[rowId] = true; + }); + rows.fileIds.forEach((fileId) => { + uniqueFileIds[fileId] = true; + }); + } + + setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); + for (const id of rowIds) + updateParentFolder(id); + }; + + const updateParentFolder = (id) => { + const updateComplete = () => { + // we've finished recursing + updateGlobalCheckbox(); + torrentFilesTable.updateTable(true); + }; + + const node = torrentFilesTable.getNode(id); + const parent = node.parent; + if (parent === torrentFilesTable.getRoot()) { + updateComplete(); + return; + } + + const siblings = parent.children; + + let checkedCount = 0; + let uncheckedCount = 0; + let indeterminateCount = 0; + let desiredComboboxPriority = null; + for (const sibling of siblings) { + switch (getCheckboxState(sibling.rowId)) { + case TriState.Checked: + checkedCount++; + break; + case TriState.Unchecked: + uncheckedCount++; + break; + case TriState.Partial: + indeterminateCount++; + break; + } + + if (desiredComboboxPriority === null) + desiredComboboxPriority = getComboboxPriority(sibling.rowId); + else if (desiredComboboxPriority !== getComboboxPriority(sibling.rowId)) + desiredComboboxPriority = FilePriority.Mixed; + } + + const currentCheckboxState = getCheckboxState(parent.rowId); + let desiredCheckboxState; + if ((indeterminateCount > 0) || ((checkedCount > 0) && (uncheckedCount > 0))) + desiredCheckboxState = TriState.Partial; + else if (checkedCount > 0) + desiredCheckboxState = TriState.Checked; + else + desiredCheckboxState = TriState.Unchecked; + + const currentComboboxPriority = getComboboxPriority(parent.rowId); + if ((currentCheckboxState !== desiredCheckboxState) || (currentComboboxPriority !== desiredComboboxPriority)) { + const row = torrentFilesTable.rows.get(parent.rowId.toString()); + row.full_data.priority = desiredComboboxPriority; + row.full_data.checked = desiredCheckboxState; + + updateParentFolder(parent.rowId); + } + else { + updateComplete(); + } + }; + + const init = (tableId, tableClass, onFilePriorityChangedHandler = undefined, onFileRenameHandler = undefined) => { + if (onFilePriorityChangedHandler !== undefined) + onFilePriorityChanged = onFilePriorityChangedHandler; + + torrentFilesTable = new tableClass(); + + const torrentFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({ + targets: `#${tableId} tbody tr`, + menu: "torrentFilesMenu", + actions: { + Rename: (element, ref) => { + if (onFileRenameHandler !== undefined) { + const nodes = torrentFilesTable.selectedRowsIds().map(row => torrentFilesTable.getNode(row)); + onFileRenameHandler(torrentFilesTable.selectedRows, nodes); + } + }, + + FilePrioIgnore: (element, ref) => { + filesPriorityMenuClicked(FilePriority.Ignored); + }, + FilePrioNormal: (element, ref) => { + filesPriorityMenuClicked(FilePriority.Normal); + }, + FilePrioHigh: (element, ref) => { + filesPriorityMenuClicked(FilePriority.High); + }, + FilePrioMaximum: (element, ref) => { + filesPriorityMenuClicked(FilePriority.Maximum); + } + }, + offsets: { + x: 0, + y: 2 + }, + }); + + torrentFilesTable.setup(tableId, "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu, true); + // inject checkbox into table header + const tableHeaders = document.querySelectorAll("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th"); + if (tableHeaders.length > 0) { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = "tristate_cb"; + checkbox.addEventListener("click", switchGlobalCheckboxState); + + const checkboxTH = tableHeaders[0]; + checkboxTH.appendChild(checkbox); + } + + // default sort by name column + if (torrentFilesTable.getSortedColumn() === null) + torrentFilesTable.setSortedColumn("name"); + + // listen for changes to torrentFilesFilterInput + document.getElementById("torrentFilesFilterInput").addEventListener("input", (event) => { + clearTimeout(torrentFilesFilterInputTimer); + + const value = document.getElementById("torrentFilesFilterInput").value; + torrentFilesTable.setFilter(value); + + torrentFilesFilterInputTimer = setTimeout(() => { + torrentFilesFilterInputTimer = -1; + + torrentFilesTable.updateTable(); + + if (value.trim() === "") + torrentFilesTable.collapseAllNodes(); + else + torrentFilesTable.expandAllNodes(); + }, window.qBittorrent.Misc.FILTER_INPUT_DELAY); + }); + + return torrentFilesTable; + }; + + const clearFilterInputTimer = () => { + clearTimeout(torrentFilesFilterInputTimer); + torrentFilesFilterInputTimer = -1; + }; + + return exports(); +})(); +Object.freeze(window.qBittorrent.TorrentContent); diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index fa6ed311f..baf2fe633 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -420,6 +420,7 @@ private/scripts/rename-files.js private/scripts/search.js private/scripts/statistics.js + private/scripts/torrent-content.js private/setlocation.html private/shareratio.html private/speedlimit.html From c8e2431dcee501973e40e3e3a5e797f30ad49e27 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 19 Sep 2024 13:55:12 -0700 Subject: [PATCH 2/8] WebUI: Add new Add Torrent experience This dialog more closely mimics the dialog presented by the GUI. It includes information about the contents of a torrent. Signed-off-by: Thomas Piccirello --- src/webui/www/private/css/Layout.css | 6 +- src/webui/www/private/css/Window.css | 10 + src/webui/www/private/css/style.css | 14 +- src/webui/www/private/download.html | 182 +------ src/webui/www/private/index.html | 17 +- .../scripts/{download.js => addtorrent.js} | 105 +++- src/webui/www/private/scripts/client.js | 132 +++-- src/webui/www/private/scripts/dynamicTable.js | 44 +- src/webui/www/private/scripts/mocha-init.js | 65 +-- src/webui/www/private/scripts/prop-files.js | 2 +- src/webui/www/private/scripts/search.js | 13 +- src/webui/www/private/upload.html | 470 ++++++++++++------ src/webui/www/private/views/rss.html | 8 +- src/webui/www/webui.qrc | 2 +- 14 files changed, 652 insertions(+), 418 deletions(-) rename src/webui/www/private/scripts/{download.js => addtorrent.js} (56%) diff --git a/src/webui/www/private/css/Layout.css b/src/webui/www/private/css/Layout.css index fb940cbf1..457a33e0e 100644 --- a/src/webui/www/private/css/Layout.css +++ b/src/webui/www/private/css/Layout.css @@ -155,7 +155,8 @@ Required by: width: 5px; } -#desktopNavbar li ul li a { +#desktopNavbar li ul li a, +#desktopNavbar li ul li div.anchor { color: var(--color-text-default); font-weight: normal; min-width: 155px; @@ -163,7 +164,8 @@ Required by: position: relative; } -#desktopNavbar li ul li a:hover { +#desktopNavbar li ul li a:hover, +#desktopNavbar li ul li div.anchor:hover { background-color: var(--color-background-hover); color: var(--color-text-white); } diff --git a/src/webui/www/private/css/Window.css b/src/webui/www/private/css/Window.css index 9aeaf365f..99128aecd 100644 --- a/src/webui/www/private/css/Window.css +++ b/src/webui/www/private/css/Window.css @@ -184,6 +184,16 @@ div.mochaToolbarWrapper.bottom { width: 16px; } +.mochaErrorIcon { + background: url("../images/error.svg") no-repeat; + background-size: 16px; + bottom: 7px; + height: 16px; + left: 6px; + position: absolute; + width: 16px; +} + .mochaIframe { width: 100%; } diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index 3540cae2d..0a940f8f4 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -672,6 +672,17 @@ td.generalLabel { width: 1px; } +td.fullWidth { + box-sizing: border-box; + max-width: none; + width: 100%; + word-break: break-all; +} + +td.noWrap { + white-space: nowrap; +} + #tristate_cb { margin-bottom: 0; margin-top: 0; @@ -835,7 +846,8 @@ td.statusBarSeparator { color: var(--color-text-green); } -#torrentFilesTableDiv .dynamicTable tr.nonAlt:hover { +#torrentFilesTableDiv .dynamicTable tr.nonAlt:hover, +#addTorrentFilesTableDiv .dynamicTable tr.nonAlt:hover { background-color: var(--color-background-hover); color: var(--color-text-white); } diff --git a/src/webui/www/private/download.html b/src/webui/www/private/download.html index 53921e64d..a69965df3 100644 --- a/src/webui/www/private/download.html +++ b/src/webui/www/private/download.html @@ -3,17 +3,17 @@ - QBT_TR(Add Torrent Links)QBT_TR[CONTEXT=downloadFromURL] + QBT_TR(Add Torrent Links)QBT_TR[CONTEXT=DownloadFromURLDialog] - - -
-
-
-

- -

QBT_TR(One link per line (HTTP links, Magnet links and info-hashes are supported))QBT_TR[CONTEXT=AddNewTorrentDialog]

-
- QBT_TR(Torrent options)QBT_TR[CONTEXT=AddNewTorrentDialog] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
- - - -
- - - -
- - -
- - -
-
- - - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - - -
- - - - -
-
- -
-
+
+
+

QBT_TR(Add torrent links)QBT_TR[CONTEXT=DownloadFromURLDialog]

+ +

QBT_TR(One link per line (HTTP links, Magnet links and info-hashes are supported))QBT_TR[CONTEXT=DownloadFromURLDialog]

+
+
- -
+
diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index 89125c909..f17babc33 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -60,7 +60,15 @@
  • QBT_TR(File)QBT_TR[CONTEXT=MainWindow]
  •    - QBT_TR(Add Torrent File...)QBT_TR[CONTEXT=MainWindow] +
    + + +
    QBT_TR(Add Torrent Link...)QBT_TR[CONTEXT=MainWindow] QBT_TR(Remove)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Start)QBT_TR[CONTEXT=TransferListWidget] diff --git a/src/webui/www/private/scripts/download.js b/src/webui/www/private/scripts/addtorrent.js similarity index 56% rename from src/webui/www/private/scripts/download.js rename to src/webui/www/private/scripts/addtorrent.js index f62e57ae0..4572bf69d 100644 --- a/src/webui/www/private/scripts/download.js +++ b/src/webui/www/private/scripts/addtorrent.js @@ -24,16 +24,23 @@ "use strict"; window.qBittorrent ??= {}; -window.qBittorrent.Download ??= (() => { +window.qBittorrent.AddTorrent ??= (() => { const exports = () => { return { changeCategorySelect: changeCategorySelect, - changeTMM: changeTMM + changeTMM: changeTMM, + loadMetadata: loadMetadata, + metadataCompleted: metadataCompleted, + populateMetadata: populateMetadata, + setWindowId: setWindowId, + submitForm: submitForm }; }; let categories = {}; let defaultSavePath = ""; + let windowId = ""; + let source = ""; const getCategories = () => { fetch("api/v2/torrents/categories", { @@ -131,6 +138,98 @@ window.qBittorrent.Download ??= (() => { } }; + let loadMetadataTimer; + const loadMetadata = (sourceUrl = undefined) => { + if (sourceUrl) + source = sourceUrl; + + fetch("api/v2/torrents/fetchMetadata", { + method: "POST", + body: new URLSearchParams({ + source: source + }) + }) + .then(async (response) => { + if (!response.ok) { + metadataFailed(); + return; + } + + const data = await response.json(); + populateMetadata(data); + + if (response.status === 200) + metadataCompleted(); + else + loadMetadataTimer = loadMetadata.delay(1000); + }); + }; + + const metadataCompleted = (showDownloadButton = true) => { + clearTimeout(loadMetadataTimer); + + document.getElementById("metadataStatus").destroy(); + document.getElementById("loadingSpinner").style.display = "none"; + + if (showDownloadButton) + document.getElementById("saveTorrent").classList.remove("invisible"); + }; + + const metadataFailed = () => { + clearTimeout(loadMetadataTimer); + + document.getElementById("metadataStatus").textContent = "Metadata retrieval failed"; + document.getElementById("metadataStatus").classList.add("red"); + document.getElementById("loadingSpinner").style.display = "none"; + document.getElementById("errorIcon").classList.remove("invisible"); + }; + + const populateMetadata = (metadata) => { + // update window title + if (metadata.info?.name) + window.parent.document.getElementById(`${windowId}_title`).textContent = metadata.info.name; + + const notAvailable = "QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog]"; + const notApplicable = "QBT_TR(N/A)QBT_TR[CONTEXT=AddNewTorrentDialog]"; + document.getElementById("infoHashV1").textContent = (metadata.infohash_v1 === undefined) ? notAvailable : (metadata.infohash_v1 || notApplicable); + document.getElementById("infoHashV2").textContent = (metadata.infohash_v2 === undefined) ? notAvailable : (metadata.infohash_v2 || notApplicable); + + if (metadata.info?.length) + document.getElementById("size").textContent = window.qBittorrent.Misc.friendlyUnit(metadata.info.length, false); + if (metadata.creation_date && (metadata.creation_date > 1)) + document.getElementById("createdDate").textContent = new Date(metadata.creation_date * 1000).toLocaleString(); + if (metadata.comment) + document.getElementById("comment").textContent = metadata.comment; + + if (metadata.info?.files) { + const files = metadata.info.files.map((file, index) => ({ + index: index, + name: file.path, + size: file.length, + priority: window.qBittorrent.FileTree.FilePriority.Normal, + })); + window.qBittorrent.TorrentContent.updateData(files); + } + }; + + const setWindowId = (id) => { + windowId = id; + }; + + const submitForm = () => { + document.getElementById("startTorrentHidden").value = document.getElementById("startTorrent").checked ? "false" : "true"; + + document.getElementById("dlLimitHidden").value = Number(document.getElementById("dlLimitText").value) * 1024; + document.getElementById("upLimitHidden").value = Number(document.getElementById("upLimitText").value) * 1024; + + document.getElementById("filePriorities").value = [...document.getElementsByClassName("combo_priority")] + .filter((el) => !window.qBittorrent.TorrentContent.isFolder(Number(el.dataset.fileId))) + .sort((el1, el2) => Number(el1.dataset.fileId) - Number(el2.dataset.fileId)) + .map((el) => Number(el.value)); + + document.getElementById("loadingSpinner").style.display = "block"; + }; + window.addEventListener("load", async (event) => { // user might load this page directly (via browser magnet handler) // so wait for crucial initialization to complete @@ -142,4 +241,4 @@ window.qBittorrent.Download ??= (() => { return exports(); })(); -Object.freeze(window.qBittorrent.Download); +Object.freeze(window.qBittorrent.AddTorrent); diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 01f29e25f..a61ee95b8 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -42,7 +42,9 @@ window.qBittorrent.Client ??= (() => { showLogViewer: showLogViewer, isShowSearchEngine: isShowSearchEngine, isShowRssReader: isShowRssReader, - isShowLogViewer: isShowLogViewer + isShowLogViewer: isShowLogViewer, + createAddTorrentWindow: createAddTorrentWindow, + uploadTorrentFiles: uploadTorrentFiles }; }; @@ -121,6 +123,81 @@ window.qBittorrent.Client ??= (() => { return showingLogViewer; }; + const createAddTorrentWindow = (title, source, metadata = undefined) => { + const isFirefox = navigator.userAgent.includes("Firefox"); + const isSafari = navigator.userAgent.includes("AppleWebKit") && !navigator.userAgent.includes("Chrome"); + let height = 855; + if (isSafari) + height -= 40; + else if (isFirefox) + height -= 10; + + const staticId = "uploadPage"; + const id = `${staticId}-${encodeURIComponent(source)}`; + + const contentURL = new URL("addtorrent.html", window.location); + contentURL.search = new URLSearchParams({ + source: source, + fetch: metadata === undefined, + windowId: id + }); + + new MochaUI.Window({ + id: id, + icon: "images/qbittorrent-tray.svg", + title: title, + loadMethod: "iframe", + contentURL: contentURL.toString(), + scrollbars: true, + maximizable: false, + paddingVertical: 0, + paddingHorizontal: 0, + width: loadWindowWidth(staticId, 980), + height: loadWindowHeight(staticId, height), + onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { + saveWindowSize(staticId, id); + }), + onContentLoaded: () => { + if (metadata !== undefined) + document.getElementById(`${id}_iframe`).contentWindow.postMessage(metadata, window.origin); + } + }); + }; + + const uploadTorrentFiles = (files) => { + const fileNames = []; + const formData = new FormData(); + for (const file of files) { + fileNames.push(file.name); + formData.append("file", file); + } + + fetch("api/v2/torrents/parseMetadata", { + method: "POST", + body: formData + }) + .then(async (response) => { + if (!response.ok) { + alert(await response.text()); + return; + } + + const json = await response.json(); + for (const fileName of fileNames) { + let title = fileName; + const metadata = json[fileName]; + if (metadata !== undefined) + title = metadata.name; + + const hash = metadata.infohash_v2 || metadata.infohash_v1; + createAddTorrentWindow(title, hash, metadata); + } + }) + .catch((error) => { + alert("QBT_TR(Unable to parse response)QBT_TR[CONTEXT=HttpServer]"); + }); + }; + return exports(); })(); Object.freeze(window.qBittorrent.Client); @@ -1677,32 +1754,11 @@ window.addEventListener("DOMContentLoaded", (event) => { // can't handle folder due to cannot put the filelist (from dropped folder) // to `files` field for (const item of ev.dataTransfer.items) { - if (item.webkitGetAsEntry().isDirectory) + if ((item.kind !== "file") || (item.webkitGetAsEntry().isDirectory)) return; } - const id = "uploadPage"; - new MochaUI.Window({ - id: id, - icon: "images/qbittorrent-tray.svg", - title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]", - loadMethod: "iframe", - contentURL: "upload.html", - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari - scrollbars: true, - maximizable: false, - paddingVertical: 0, - paddingHorizontal: 0, - width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 460), - onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveWindowSize(id); - }), - onContentLoaded: () => { - const fileInput = document.getElementById(`${id}_iframe`).contentDocument.getElementById("fileselect"); - fileInput.files = droppedFiles; - } - }); + window.qBittorrent.Client.uploadTorrentFiles(droppedFiles); } const droppedText = ev.dataTransfer.getData("text"); @@ -1720,32 +1776,8 @@ window.addEventListener("DOMContentLoaded", (event) => { || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash }); - if (urls.length <= 0) - return; - - const id = "downloadPage"; - const contentURL = new URL("download.html", window.location); - contentURL.search = new URLSearchParams({ - urls: urls.map(encodeURIComponent).join("|") - }); - new MochaUI.Window({ - id: id, - icon: "images/qbittorrent-tray.svg", - title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]", - loadMethod: "iframe", - contentURL: contentURL.toString(), - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari - scrollbars: true, - maximizable: false, - closable: true, - paddingVertical: 0, - paddingHorizontal: 0, - width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 600), - onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveWindowSize(id); - }) - }); + for (const url of urls) + qBittorrent.Client.createAddTorrentWindow(url, url); } }); }; diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 463311cc5..8fa1533c5 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -44,6 +44,7 @@ window.qBittorrent.DynamicTable ??= (() => { TorrentTrackersTable: TorrentTrackersTable, BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable, TorrentFilesTable: TorrentFilesTable, + AddTorrentFilesTable: AddTorrentFilesTable, LogMessageTable: LogMessageTable, LogPeerTable: LogPeerTable, RssFeedTable: RssFeedTable, @@ -66,6 +67,9 @@ window.qBittorrent.DynamicTable ??= (() => { let DynamicTableHeaderContextMenuClass = null; + if (typeof LocalPreferences === "undefined") + window.LocalPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences(); + class DynamicTable { setup(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu, useVirtualList = false) { this.dynamicTableDivId = dynamicTableDivId; @@ -2884,16 +2888,18 @@ window.qBittorrent.DynamicTable ??= (() => { this.columns["size"].updateTd = displaySize; // progress - this.columns["progress"].updateTd = function(td, row) { - const value = Number(this.getRowValue(row)); + if (this.columns["progress"]) { + this.columns["progress"].updateTd = function(td, row) { + const value = Number(this.getRowValue(row)); - const progressBar = td.firstElementChild; - if (progressBar === null) - td.append(new window.qBittorrent.ProgressBar.ProgressBar(value)); - else - progressBar.setValue(value); - }; - this.columns["progress"].staticWidth = 100; + const progressBar = td.firstElementChild; + if (progressBar === null) + td.append(new window.qBittorrent.ProgressBar.ProgressBar(value)); + else + progressBar.setValue(value); + }; + this.columns["progress"].staticWidth = 100; + } // priority this.columns["priority"].updateTd = function(td, row) { @@ -2909,8 +2915,10 @@ window.qBittorrent.DynamicTable ??= (() => { this.columns["priority"].staticWidth = 140; // remaining, availability - this.columns["remaining"].updateTd = displaySize; - this.columns["availability"].updateTd = displayPercentage; + if (this.columns["remaining"]) + this.columns["remaining"].updateTd = displaySize; + if (this.columns["availability"]) + this.columns["availability"].updateTd = displayPercentage; } #sortNodesByColumn(root, column) { @@ -3065,6 +3073,17 @@ window.qBittorrent.DynamicTable ??= (() => { } } + class AddTorrentFilesTable extends TorrentFilesTable { + initColumns() { + this.newColumn("checked", "", "", 50, true); + this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 190, true); + this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); + this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 140, true); + + this.initColumnsFunctions(); + } + } + class RssFeedTable extends DynamicTable { initColumns() { this.newColumn("state_icon", "", "", 30, true); @@ -3243,7 +3262,8 @@ window.qBittorrent.DynamicTable ??= (() => { if (!tr) return; - showDownloadPage([this.getRow(tr.rowId).full_data.torrentURL]); + const { name, torrentURL } = this._this.rows.get(this.rowId).full_data; + qBittorrent.Client.createAddTorrentWindow(name, torrentURL); }); } updateRow(tr, fullUpdate) { diff --git a/src/webui/www/private/scripts/mocha-init.js b/src/webui/www/private/scripts/mocha-init.js index 9e358f442..bfbc7f2f7 100644 --- a/src/webui/www/private/scripts/mocha-init.js +++ b/src/webui/www/private/scripts/mocha-init.js @@ -150,10 +150,11 @@ let setQueuePositionFN = () => {}; let exportTorrentFN = () => {}; const initializeWindows = () => { - saveWindowSize = (windowId) => { - const size = document.getElementById(windowId).getSize(); - LocalPreferences.set(`window_${windowId}_width`, size.x); - LocalPreferences.set(`window_${windowId}_height`, size.y); + saveWindowSize = (windowName, windowId = windowName) => { + const windowInstance = MochaUI.Windows.instances[windowId]; + const size = windowInstance.contentWrapperEl.getSize(); + LocalPreferences.set(`window_${windowName}_width`, size.x); + LocalPreferences.set(`window_${windowName}_height`, size.y); }; loadWindowWidth = (windowId, defaultValue) => { @@ -193,14 +194,13 @@ const initializeWindows = () => { title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]", loadMethod: "iframe", contentURL: contentURL.toString(), - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari scrollbars: true, maximizable: false, closable: true, paddingVertical: 0, paddingHorizontal: 0, width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 600), + height: loadWindowHeight(id, 300), onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { saveWindowSize(id); }) @@ -283,31 +283,32 @@ const initializeWindows = () => { }); }); - addClickEvent("upload", (e) => { - e.preventDefault(); - e.stopPropagation(); - - const id = "uploadPage"; - new MochaUI.Window({ - id: id, - icon: "images/qbittorrent-tray.svg", - title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]", - loadMethod: "iframe", - contentURL: "upload.html", - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari - scrollbars: true, - maximizable: false, - paddingVertical: 0, - paddingHorizontal: 0, - width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 460), - onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveWindowSize(id); - }) - }); - updateMainData(); + document.querySelector("#uploadButton #fileselectButton").addEventListener("click", function(event) { + // clear the value so that reselecting the same file(s) still triggers the 'change' event + this.value = null; }); + // make the entire anchor tag trigger the input, despite the input's label not spanning the entire anchor + document.getElementById("uploadLink").addEventListener("click", (e) => { + // clear the value so that reselecting the same file(s) still triggers the 'change' event + if (e.target === document.getElementById("fileselectLink")) { + e.target.value = null; + } + else { + e.preventDefault(); + document.getElementById("fileselectLink").click(); + } + }); + + for (const element of document.querySelectorAll("#uploadButton #fileselectButton, #uploadLink #fileselectLink")) { + element.addEventListener("change", () => { + if (element.files.length === 0) + return; + + window.qBittorrent.Client.uploadTorrentFiles(element.files); + }); + } + globalUploadLimitFN = () => { const contentURL = new URL("speedlimit.html", window.location); contentURL.search = new URLSearchParams({ @@ -1356,4 +1357,10 @@ const initializeWindows = () => { e.stopPropagation(); }); } + + const userAgent = (navigator.userAgentData?.platform ?? navigator.platform).toLowerCase(); + if (userAgent.includes("ipad") || userAgent.includes("iphone") || (userAgent.includes("mac") && (navigator.maxTouchPoints > 1))) { + for (const element of document.getElementsByClassName("fileselect")) + element.accept = ".torrent"; + } }; diff --git a/src/webui/www/private/scripts/prop-files.js b/src/webui/www/private/scripts/prop-files.js index d780a4be1..65aea83d8 100644 --- a/src/webui/www/private/scripts/prop-files.js +++ b/src/webui/www/private/scripts/prop-files.js @@ -41,7 +41,7 @@ window.qBittorrent.PropFiles ??= (() => { const onFilePriorityChanged = (fileIds, priority) => { // ignore folders - fileIds = fileIds.map(id => parseInt(id, 10)).filter(id => !window.qBittorrent.TorrentContent.isFolder(id)); + fileIds = fileIds.map(id => Number(id)).filter(id => !window.qBittorrent.TorrentContent.isFolder(id)); clearTimeout(loadTorrentFilesDataTimer); loadTorrentFilesDataTimer = -1; diff --git a/src/webui/www/private/scripts/search.js b/src/webui/www/private/scripts/search.js index bff6030eb..4faef6229 100644 --- a/src/webui/www/private/scripts/search.js +++ b/src/webui/www/private/scripts/search.js @@ -559,15 +559,10 @@ window.qBittorrent.Search ??= (() => { }; const downloadSearchTorrent = () => { - const urls = []; - for (const rowID of searchResultsTable.selectedRowsIds()) - urls.push(searchResultsTable.getRow(rowID).full_data.fileUrl); - - // only proceed if at least 1 row was selected - if (!urls.length) - return; - - showDownloadPage(urls); + for (const rowID of searchResultsTable.selectedRowsIds()) { + const { fileName, fileUrl } = searchResultsTable.getRow(rowID).full_data; + qBittorrent.Client.createAddTorrentWindow(fileName, fileUrl); + } }; const manageSearchPlugins = () => { diff --git a/src/webui/www/private/upload.html b/src/webui/www/private/upload.html index 1a89cffe1..76d182ea9 100644 --- a/src/webui/www/private/upload.html +++ b/src/webui/www/private/upload.html @@ -3,27 +3,44 @@ - QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer] + QBT_TR(Add torrent)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + - + + + + + + + + + + + -
    -
    - -
    -
    - QBT_TR(Torrent options)QBT_TR[CONTEXT=AddNewTorrentDialog] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - -
    - - - -
    - - - -
    - - -
    - - -
    -
    - - - - -
    - - - -
    - - - -
    - - - -
    - - - -
    - - - -
    - - - -
    - - - - -
    - - - - -
    -
    - + + + +
    +
    +
    + QBT_TR(Save at)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + + + + + + + + + +
    + + + +
    + + + +
    +
    +
    + QBT_TR(Torrent settings)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +
    + + +
    + + +
    +
    + + + + +
    + + + +
    + + + +
    + + + +
    + + + +
    + + + +
    + + + +
    + + + + +
    + + + + +
    +
    + +
    + QBT_TR(Torrent information)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + + + + + + + + + + + + + + + + + + + + + +
    + + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
    + + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
    + + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
    + + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
    + + + +
    +
    -
    +
    +
    + QBT_TR(Files)QBT_TR[CONTEXT=AddNewTorrentDialog] +
    + +
    +
    +
    + + + + +
    +
    +
    + + + + + +
    +
    +
    +
    +
    +
    +
    +
    + + Retrieving metadata + + +   +
    + +
    +
    -
    diff --git a/src/webui/www/private/views/rss.html b/src/webui/www/private/views/rss.html index cd264b130..8541c36b7 100644 --- a/src/webui/www/private/views/rss.html +++ b/src/webui/www/private/views/rss.html @@ -294,10 +294,10 @@ menu: "rssArticleMenu", actions: { Download: (el) => { - let dlString = ""; - for (const rowID of rssArticleTable.selectedRows) - dlString += `${rssArticleTable.getRow(rowID).full_data.torrentURL}\n`; - showDownloadPage([dlString]); + for (const rowID of rssArticleTable.selectedRows) { + const { name, torrentURL } = rssArticleTable.getRow(rowID).full_data; + window.qBittorrent.Client.createAddTorrentWindow(name, torrentURL); + } }, OpenNews: (el) => { for (const rowID of rssArticleTable.selectedRows) diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index baf2fe633..99a9110cf 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -392,11 +392,11 @@ private/rename_file.html private/rename_files.html private/rename_rule.html + private/scripts/addtorrent.js private/scripts/cache.js private/scripts/client.js private/scripts/color-scheme.js private/scripts/contextmenu.js - private/scripts/download.js private/scripts/dynamicTable.js private/scripts/file-tree.js private/scripts/filesystem.js From 83ad16a19eb8606b97e6844beea7b9acbfa20d40 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Mon, 28 Oct 2024 05:25:23 -0700 Subject: [PATCH 3/8] WebUI: Rename file This is done in a separate commit to avoid losing revision history. --- src/webui/www/private/{upload.html => addtorrent.html} | 0 src/webui/www/webui.qrc | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/webui/www/private/{upload.html => addtorrent.html} (100%) diff --git a/src/webui/www/private/upload.html b/src/webui/www/private/addtorrent.html similarity index 100% rename from src/webui/www/private/upload.html rename to src/webui/www/private/addtorrent.html diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index 99a9110cf..6d15d1f04 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -1,6 +1,7 @@ private/addpeers.html + private/addtorrent.html private/addtrackers.html private/addwebseeds.html private/confirmfeeddeletion.html @@ -424,7 +425,6 @@ private/setlocation.html private/shareratio.html private/speedlimit.html - private/upload.html private/views/about.html private/views/aboutToolbar.html private/views/confirmAutoTMM.html From bd674f0328920da0f1f78a728cd1a0f2fee8305d Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 6 Jun 2025 11:16:33 -0700 Subject: [PATCH 4/8] WebUI: Switch from Object to Set --- src/webui/www/private/scripts/torrent-content.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/webui/www/private/scripts/torrent-content.js b/src/webui/www/private/scripts/torrent-content.js index 24f392393..24ecd9d3c 100644 --- a/src/webui/www/private/scripts/torrent-content.js +++ b/src/webui/www/private/scripts/torrent-content.js @@ -409,19 +409,19 @@ window.qBittorrent.TorrentContent ??= (() => { fileIds.push(Number(torrentFilesTable.getRowFileId(rowId))); }); - const uniqueRowIds = {}; - const uniqueFileIds = {}; + const uniqueRowIds = new Set(); + const uniqueFileIds = new Set(); for (let i = 0; i < rowIds.length; ++i) { const rows = getAllChildren(rowIds[i], fileIds[i]); rows.rowIds.forEach((rowId) => { - uniqueRowIds[rowId] = true; + uniqueRowIds.add(rowId); }); rows.fileIds.forEach((fileId) => { - uniqueFileIds[fileId] = true; + uniqueFileIds.add(fileId); }); } - setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); + setFilePriority([...uniqueRowIds.keys()], [...uniqueFileIds.keys()], priority); for (const id of rowIds) updateParentFolder(id); }; From 789f92ff84b559ac3000d008b73ab2a52217ce78 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 19 Sep 2024 13:41:43 -0700 Subject: [PATCH 5/8] WebUI: Support using separate download path Signed-off-by: Thomas Piccirello --- src/webui/www/private/addtorrent.html | 13 +- src/webui/www/private/scripts/addtorrent.js | 134 ++++++++++++++------ 2 files changed, 104 insertions(+), 43 deletions(-) diff --git a/src/webui/www/private/addtorrent.html b/src/webui/www/private/addtorrent.html index 76d182ea9..32e934273 100644 --- a/src/webui/www/private/addtorrent.html +++ b/src/webui/www/private/addtorrent.html @@ -140,7 +140,7 @@ - @@ -156,6 +156,17 @@ +
    + + + + + +
    + + +
    +
    QBT_TR(Torrent settings)QBT_TR[CONTEXT=AddNewTorrentDialog] diff --git a/src/webui/www/private/scripts/addtorrent.js b/src/webui/www/private/scripts/addtorrent.js index 4572bf69d..d6f1b4cac 100644 --- a/src/webui/www/private/scripts/addtorrent.js +++ b/src/webui/www/private/scripts/addtorrent.js @@ -37,51 +37,39 @@ window.qBittorrent.AddTorrent ??= (() => { }; }; - let categories = {}; let defaultSavePath = ""; + let defaultTempPath = ""; + let defaultTempPathEnabled = false; let windowId = ""; let source = ""; const getCategories = () => { - fetch("api/v2/torrents/categories", { - method: "GET", - cache: "no-store" - }) - .then(async (response) => { - if (!response.ok) - return; - - const data = await response.json(); - - categories = data; - for (const i in data) { - if (!Object.hasOwn(data, i)) - continue; - - const category = data[i]; - const option = document.createElement("option"); - option.value = category.name; - option.textContent = category.name; - document.getElementById("categorySelect").appendChild(option); - } - }); + for (const name of window.parent.qBittorrent.Client.categoryMap.keys()) { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + document.getElementById("categorySelect").appendChild(option); + } }; const getPreferences = () => { const pref = window.parent.qBittorrent.Cache.preferences.get(); defaultSavePath = pref.save_path; - document.getElementById("savepath").value = defaultSavePath; + defaultTempPath = pref.temp_path; + defaultTempPathEnabled = pref.temp_path_enabled; document.getElementById("startTorrent").checked = !pref.add_stopped_enabled; document.getElementById("addToTopOfQueue").checked = pref.add_to_top_of_queue; + const autoTMM = document.getElementById("autoTMM"); if (pref.auto_tmm_enabled) { - document.getElementById("autoTMM").selectedIndex = 1; + autoTMM.selectedIndex = 1; document.getElementById("savepath").disabled = true; } else { - document.getElementById("autoTMM").selectedIndex = 0; + autoTMM.selectedIndex = 0; } + changeTMM(); if (pref.torrent_stop_condition === "MetadataReceived") document.getElementById("stopCondition").selectedIndex = 1; @@ -98,43 +86,97 @@ window.qBittorrent.AddTorrent ??= (() => { document.getElementById("contentLayout").selectedIndex = 0; }; + const categorySavePath = (categoryName) => { + const category = window.parent.qBittorrent.Client.categoryMap.get(categoryName); + return (category === undefined) ? defaultSavePath : (category.savePath || `${defaultSavePath}/${categoryName}`); + }; + + const categoryDownloadPath = (categoryName) => { + const category = window.parent.qBittorrent.Client.categoryMap.get(categoryName); + if (category === undefined) + return defaultTempPath; + if (category.downloadPath === false) + return ""; + return category.downloadPath || `${defaultTempPath}/${categoryName}`; + }; + + const categoryDownloadPathEnabled = (categoryName) => { + const category = window.parent.qBittorrent.Client.categoryMap.get(categoryName); + if ((category === undefined) || (category.downloadPath === null)) + return defaultTempPathEnabled; + return category.downloadPath !== false; + }; + const changeCategorySelect = (item) => { - if (item.value === "\\other") { + const categoryName = item.value; + if (categoryName === "\\other") { item.nextElementSibling.hidden = false; item.nextElementSibling.value = ""; item.nextElementSibling.select(); - if (document.getElementById("autoTMM").selectedIndex === 1) + if (isAutoTMMEnabled()) { document.getElementById("savepath").value = defaultSavePath; + + const downloadPathEnabled = categoryDownloadPathEnabled(categoryName); + document.getElementById("useDownloadPath").checked = downloadPathEnabled; + changeUseDownloadPath(downloadPathEnabled); + } } else { item.nextElementSibling.hidden = true; const text = item.options[item.selectedIndex].textContent; item.nextElementSibling.value = text; - if (document.getElementById("autoTMM").selectedIndex === 1) { - const categoryName = item.value; - const category = categories[categoryName]; - let savePath = defaultSavePath; - if (category !== undefined) - savePath = (category["savePath"] !== "") ? category["savePath"] : `${defaultSavePath}/${categoryName}`; - document.getElementById("savepath").value = savePath; + if (isAutoTMMEnabled()) { + document.getElementById("savepath").value = categorySavePath(categoryName); + + const downloadPathEnabled = categoryDownloadPathEnabled(categoryName); + document.getElementById("useDownloadPath").checked = downloadPathEnabled; + changeUseDownloadPath(downloadPathEnabled); } } }; - const changeTMM = (item) => { - if (item.selectedIndex === 1) { - document.getElementById("savepath").disabled = true; + const isAutoTMMEnabled = () => { + return document.getElementById("autoTMM").selectedIndex === 1; + }; + const changeTMM = () => { + const autoTMMEnabled = isAutoTMMEnabled(); + const savepath = document.getElementById("savepath"); + const useDownloadPath = document.getElementById("useDownloadPath"); + + if (autoTMMEnabled) { const categorySelect = document.getElementById("categorySelect"); const categoryName = categorySelect.options[categorySelect.selectedIndex].value; - const category = categories[categoryName]; - document.getElementById("savepath").value = (category === undefined) ? "" : category["savePath"]; + savepath.value = categorySavePath(categoryName); + useDownloadPath.checked = categoryDownloadPathEnabled(categoryName); } else { - document.getElementById("savepath").disabled = false; - document.getElementById("savepath").value = defaultSavePath; + savepath.value = defaultSavePath; + useDownloadPath.checked = defaultTempPathEnabled; + } + + savepath.disabled = autoTMMEnabled; + useDownloadPath.disabled = autoTMMEnabled; + + // only submit this value when using manual tmm + document.getElementById("useDownloadPathHidden").disabled = autoTMMEnabled; + + changeUseDownloadPath(useDownloadPath.checked); + }; + + const changeUseDownloadPath = (enabled) => { + const downloadPath = document.getElementById("downloadPath"); + if (isAutoTMMEnabled()) { + const categorySelect = document.getElementById("categorySelect"); + const categoryName = categorySelect.options[categorySelect.selectedIndex].value; + downloadPath.value = enabled ? categoryDownloadPath(categoryName) : ""; + downloadPath.disabled = true; + } + else { + downloadPath.value = enabled ? defaultTempPath : ""; + downloadPath.disabled = !enabled; } }; @@ -227,7 +269,11 @@ window.qBittorrent.AddTorrent ??= (() => { .sort((el1, el2) => Number(el1.dataset.fileId) - Number(el2.dataset.fileId)) .map((el) => Number(el.value)); + if (!isAutoTMMEnabled()) + document.getElementById("useDownloadPathHidden").value = document.getElementById("useDownloadPath").checked; + document.getElementById("loadingSpinner").style.display = "block"; + }; window.addEventListener("load", async (event) => { @@ -239,6 +285,10 @@ window.qBittorrent.AddTorrent ??= (() => { getCategories(); }); + window.addEventListener("DOMContentLoaded", (event) => { + document.getElementById("useDownloadPath").addEventListener("change", (e) => changeUseDownloadPath(e.target.checked)); + }); + return exports(); })(); Object.freeze(window.qBittorrent.AddTorrent); From cc9d687a4d228ea06c56260f63ab1194983b300f Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Thu, 19 Sep 2024 15:08:51 -0700 Subject: [PATCH 6/8] WebUI: Support specifying tags when adding torrent Signed-off-by: Thomas Piccirello --- src/webui/www/private/addtorrent.html | 17 +++++++++++++ src/webui/www/private/scripts/addtorrent.js | 27 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/webui/www/private/addtorrent.html b/src/webui/www/private/addtorrent.html index 32e934273..dd5dc514e 100644 --- a/src/webui/www/private/addtorrent.html +++ b/src/webui/www/private/addtorrent.html @@ -7,8 +7,10 @@ + + @@ -109,6 +111,10 @@ } } + #btn-group-tagsSelect button { + background-color: initial; + } + @@ -193,6 +199,17 @@ + + + + + +
    + + +
    + + diff --git a/src/webui/www/private/scripts/addtorrent.js b/src/webui/www/private/scripts/addtorrent.js index d6f1b4cac..3addf2750 100644 --- a/src/webui/www/private/scripts/addtorrent.js +++ b/src/webui/www/private/scripts/addtorrent.js @@ -52,6 +52,26 @@ window.qBittorrent.AddTorrent ??= (() => { } }; + const getTags = () => { + const tagsSelect = document.getElementById("tagsSelect"); + for (const tag of window.parent.qBittorrent.Client.tagMap.keys()) { + const option = document.createElement("option"); + option.value = tag; + option.textContent = tag; + tagsSelect.appendChild(option); + } + + new vanillaSelectBox("#tagsSelect", { + maxHeight: 200, + search: false, + disableSelectAll: true, + translations: { + all: window.parent.qBittorrent.Client.tagMap.length === 0 ? "" : "QBT_TR(All)QBT_TR[CONTEXT=AddNewTorrentDialog]", + }, + keepInlineStyles: false + }); + }; + const getPreferences = () => { const pref = window.parent.qBittorrent.Cache.preferences.get(); @@ -137,6 +157,11 @@ window.qBittorrent.AddTorrent ??= (() => { } }; + const changeTagsSelect = (element) => { + const tags = [...element.options].filter(opt => opt.selected).map(opt => opt.value); + document.getElementById("tags").value = tags.join(","); + }; + const isAutoTMMEnabled = () => { return document.getElementById("autoTMM").selectedIndex === 1; }; @@ -283,10 +308,12 @@ window.qBittorrent.AddTorrent ??= (() => { getPreferences(); getCategories(); + getTags(); }); window.addEventListener("DOMContentLoaded", (event) => { document.getElementById("useDownloadPath").addEventListener("change", (e) => changeUseDownloadPath(e.target.checked)); + document.getElementById("tagsSelect").addEventListener("change", (e) => changeTagsSelect(e.target)); }); return exports(); From 14facdd0a7a56a64ef3882c75dfac19856dfe5e8 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 27 Jun 2025 23:50:04 -0700 Subject: [PATCH 7/8] WebAPI: Request post request to fetch/parse metadata --- src/webui/webapplication.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/webui/webapplication.h b/src/webui/webapplication.h index 6ceb28593..aca56b641 100644 --- a/src/webui/webapplication.h +++ b/src/webui/webapplication.h @@ -187,8 +187,10 @@ private: {{u"torrents"_s, u"editCategory"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"editTracker"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"editWebSeed"_s}, Http::METHOD_POST}, + {{u"torrents"_s, u"fetchMetadata"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"filePrio"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"increasePrio"_s}, Http::METHOD_POST}, + {{u"torrents"_s, u"parseMetadata"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"reannounce"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"recheck"_s}, Http::METHOD_POST}, {{u"torrents"_s, u"removeCategories"_s}, Http::METHOD_POST}, From 5b814cf1495ea99dcac6dec64e800b9befc62025 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Tue, 1 Jul 2025 07:13:16 -0700 Subject: [PATCH 8/8] WebUI: Allow setting default category when adding torrent --- src/webui/www/private/addtorrent.html | 9 ++++++++- src/webui/www/private/scripts/addtorrent.js | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/webui/www/private/addtorrent.html b/src/webui/www/private/addtorrent.html index dd5dc514e..23df8c8fa 100644 --- a/src/webui/www/private/addtorrent.html +++ b/src/webui/www/private/addtorrent.html @@ -195,10 +195,17 @@ - + + + + + + + + diff --git a/src/webui/www/private/scripts/addtorrent.js b/src/webui/www/private/scripts/addtorrent.js index 3addf2750..c2979265e 100644 --- a/src/webui/www/private/scripts/addtorrent.js +++ b/src/webui/www/private/scripts/addtorrent.js @@ -43,13 +43,21 @@ window.qBittorrent.AddTorrent ??= (() => { let windowId = ""; let source = ""; + const LocalPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences(); + const getCategories = () => { + const defaultCategory = LocalPreferences.get("add_torrent_default_category", ""); + const categorySelect = document.getElementById("categorySelect"); for (const name of window.parent.qBittorrent.Client.categoryMap.keys()) { const option = document.createElement("option"); option.value = name; option.textContent = name; - document.getElementById("categorySelect").appendChild(option); + option.selected = name === defaultCategory; + categorySelect.appendChild(option); } + + if (defaultCategory !== "") + changeCategorySelect(categorySelect); }; const getTags = () => { @@ -299,6 +307,13 @@ window.qBittorrent.AddTorrent ??= (() => { document.getElementById("loadingSpinner").style.display = "block"; + if (document.getElementById("setDefaultCategory").checked) { + const category = document.getElementById("category").value.trim(); + if (category.length === 0) + LocalPreferences.remove("add_torrent_default_category"); + else + LocalPreferences.set("add_torrent_default_category", category); + } }; window.addEventListener("load", async (event) => {