3 * Copyright (C) 2024 Thomas Piccirello
5 * Permission is hereby granted, free of charge, to any person obtaining a copy
6 * of this software and associated documentation files (the "Software"), to deal
7 * in the Software without restriction, including without limitation the rights
8 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 * copies of the Software, and to permit persons to whom the Software is
10 * furnished to do so, subject to the following conditions:
12 * The above copyright notice and this permission notice shall be included in
13 * all copies or substantial portions of the Software.
15 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
26 window.qBittorrent ??= {};
27 window.qBittorrent.Search ??= (() => {
28 const exports = () => {
30 startStopSearch: startStopSearch,
31 manageSearchPlugins: manageSearchPlugins,
32 searchPlugins: searchPlugins,
33 searchText: searchText,
34 searchSeedsFilter: searchSeedsFilter,
35 searchSizeFilter: searchSizeFilter,
38 searchInTorrentName: searchInTorrentName,
39 onSearchPatternChanged: onSearchPatternChanged,
40 categorySelected: categorySelected,
41 pluginSelected: pluginSelected,
42 searchSeedsFilterChanged: searchSeedsFilterChanged,
43 searchSizeFilterChanged: searchSizeFilterChanged,
44 searchSizeFilterPrefixChanged: searchSizeFilterPrefixChanged,
45 closeSearchTab: closeSearchTab,
49 const searchTabIdPrefix = "Search-";
50 let loadSearchPluginsTimer = -1;
51 const searchPlugins = [];
52 let prevSearchPluginsResponse;
53 let selectedCategory = "QBT_TR(All categories)QBT_TR[CONTEXT=SearchEngineWidget]";
54 let selectedPlugin = "enabled";
55 let prevSelectedPlugin;
56 // whether the current search pattern differs from the pattern that the active search was performed with
57 let searchPatternChanged = false;
59 let searchResultsTable;
60 /** @type Map<number, {
61 * searchPattern: string,
62 * filterPattern: string,
63 * seedsFilter: {min: number, max: number},
64 * sizeFilter: {min: number, minUnit: number, max: number, maxUnit: number},
68 * selectedRowIds: number[],
70 * loadResultsTimer: Timer,
71 * sort: {column: string, reverse: string},
73 const searchState = new Map();
78 const searchSeedsFilter = {
82 const searchSizeFilter = {
84 minUnit: 2, // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
89 const init = function() {
90 // load "Search in" preference from local storage
91 $("searchInTorrentName").value = (LocalPreferences.get("search_in_filter") === "names") ? "names" : "everywhere";
92 const searchResultsTableContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
93 targets: ".searchTableRow",
94 menu: "searchResultsTableMenu",
96 Download: downloadSearchTorrent,
97 OpenDescriptionUrl: openSearchTorrentDescriptionUrl
104 searchResultsTable = new window.qBittorrent.DynamicTable.SearchResultsTable();
105 searchResultsTable.setup("searchResultsTableDiv", "searchResultsTableFixedHeaderDiv", searchResultsTableContextMenu);
108 // listen for changes to searchInNameFilter
109 let searchInNameFilterTimer = -1;
110 $("searchInNameFilter").addEventListener("input", () => {
111 clearTimeout(searchInNameFilterTimer);
112 searchInNameFilterTimer = setTimeout(() => {
113 searchInNameFilterTimer = -1;
115 const value = $("searchInNameFilter").value;
116 searchText.filterPattern = value;
117 searchFilterChanged();
118 }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
122 defaultEventType: "keydown",
124 "Enter": function(e) {
125 // accept enter key as a click
129 const elem = e.event.srcElement;
130 if (elem.className.contains("searchInputField")) {
131 document.getElementById("startSearchButton").click();
136 case "manageSearchPlugins":
137 manageSearchPlugins();
144 // restore search tabs
145 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
146 for (const { id, pattern } of searchJobs)
147 createSearchTab(id, pattern);
150 const numSearchTabs = function() {
151 return $("searchTabs").getElements("li").length;
154 const getSearchIdFromTab = function(tab) {
155 return Number(tab.id.substring(searchTabIdPrefix.length));
158 const createSearchTab = function(searchId, pattern) {
159 const newTabId = `${searchTabIdPrefix}${searchId}`;
160 const tabElem = new Element("a", {
164 const closeTabElem = new Element("img", {
165 alt: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
166 title: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
167 src: "images/application-exit.svg",
170 style: "padding-right: 7px; margin-bottom: -1px; margin-left: -5px",
171 onclick: "qBittorrent.Search.closeSearchTab(event, this);",
173 closeTabElem.inject(tabElem, "top");
175 tabElem.appendChild(getStatusIconElement("QBT_TR(Searching...)QBT_TR[CONTEXT=SearchJobWidget]", "images/queued.svg"));
177 const listItem = document.createElement("li");
178 listItem.id = newTabId;
179 listItem.classList.add("selected");
180 listItem.addEventListener("click", (e) => {
181 setActiveTab(listItem);
182 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
184 listItem.appendChild(tabElem);
185 $("searchTabs").appendChild(listItem);
187 // unhide the results elements
188 if (numSearchTabs() >= 1) {
189 $("searchResultsNoSearches").style.display = "none";
190 $("searchResultsFilters").style.display = "block";
191 $("searchResultsTableContainer").style.display = "block";
192 $("searchTabsToolbar").style.display = "block";
196 setActiveTab(listItem);
198 searchResultsTable.clear();
201 searchState.set(searchId, {
202 searchPattern: pattern,
203 filterPattern: searchText.filterPattern,
204 seedsFilter: { min: searchSeedsFilter.min, max: searchSeedsFilter.max },
205 sizeFilter: { min: searchSizeFilter.min, minUnit: searchSizeFilter.minUnit, max: searchSizeFilter.max, maxUnit: searchSizeFilter.maxUnit },
206 searchIn: getSearchInTorrentName(),
211 loadResultsTimer: -1,
212 sort: { column: searchResultsTable.sortedColumn, reverse: searchResultsTable.reverseSort },
214 updateSearchResultsData(searchId);
217 const closeSearchTab = function(e, el) {
220 const tab = el.parentElement.parentElement;
221 const searchId = getSearchIdFromTab(tab);
222 const isTabSelected = tab.hasClass("selected");
223 const newTabToSelect = isTabSelected ? (tab.nextSibling || tab.previousSibling) : null;
225 const currentSearchId = getSelectedSearchId();
226 const state = searchState.get(currentSearchId);
227 // don't bother sending a stop request if already stopped
228 if (state && state.running)
229 stopSearch(searchId);
234 url: new URI("api/v2/search/delete"),
241 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
242 const jobIndex = searchJobs.findIndex((job) => job.id === searchId);
244 searchJobs.splice(jobIndex, 1);
245 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
248 if (numSearchTabs() === 0) {
252 $("numSearchResultsVisible").textContent = 0;
253 $("numSearchResultsTotal").textContent = 0;
254 $("searchResultsNoSearches").style.display = "block";
255 $("searchResultsFilters").style.display = "none";
256 $("searchResultsTableContainer").style.display = "none";
257 $("searchTabsToolbar").style.display = "none";
259 else if (isTabSelected && newTabToSelect) {
260 setActiveTab(newTabToSelect);
261 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
265 const saveCurrentTabState = function() {
266 const currentSearchId = getSelectedSearchId();
267 if (!currentSearchId)
270 const state = searchState.get(currentSearchId);
274 state.filterPattern = searchText.filterPattern;
275 state.seedsFilter = {
276 min: searchSeedsFilter.min,
277 max: searchSeedsFilter.max,
280 min: searchSizeFilter.min,
281 minUnit: searchSizeFilter.minUnit,
282 max: searchSizeFilter.max,
283 maxUnit: searchSizeFilter.maxUnit,
285 state.searchIn = getSearchInTorrentName();
288 column: searchResultsTable.sortedColumn,
289 reverse: searchResultsTable.reverseSort,
292 // we must copy the array to avoid taking a reference to it
293 state.selectedRowIds = [...searchResultsTable.selectedRows];
296 const setActiveTab = function(tab) {
297 const searchId = getSearchIdFromTab(tab);
298 if (searchId === getSelectedSearchId())
301 saveCurrentTabState();
303 MochaUI.selected(tab, "searchTabs");
305 const state = searchState.get(searchId);
306 let rowsToSelect = [];
308 // restore table rows
309 searchResultsTable.clear();
311 for (const row of state.rows)
312 searchResultsTable.updateRowData(row);
314 rowsToSelect = state.selectedRowIds;
317 searchText.pattern = state.searchPattern;
318 searchText.filterPattern = state.filterPattern;
319 $("searchInNameFilter").value = state.filterPattern;
321 searchSeedsFilter.min = state.seedsFilter.min;
322 searchSeedsFilter.max = state.seedsFilter.max;
323 $("searchMinSeedsFilter").value = state.seedsFilter.min;
324 $("searchMaxSeedsFilter").value = state.seedsFilter.max;
326 searchSizeFilter.min = state.sizeFilter.min;
327 searchSizeFilter.minUnit = state.sizeFilter.minUnit;
328 searchSizeFilter.max = state.sizeFilter.max;
329 searchSizeFilter.maxUnit = state.sizeFilter.maxUnit;
330 $("searchMinSizeFilter").value = state.sizeFilter.min;
331 $("searchMinSizePrefix").value = state.sizeFilter.minUnit;
332 $("searchMaxSizeFilter").value = state.sizeFilter.max;
333 $("searchMaxSizePrefix").value = state.sizeFilter.maxUnit;
335 const currentSearchPattern = $("searchPattern").value.trim();
336 if (state.running && (state.searchPattern === currentSearchPattern)) {
337 // allow search to be stopped
338 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
339 searchPatternChanged = false;
342 searchResultsTable.setSortedColumn(state.sort.column, state.sort.reverse);
344 $("searchInTorrentName").value = state.searchIn;
347 // must restore all filters before calling updateTable
348 searchResultsTable.updateTable();
350 // must reselect rows after calling updateTable
351 if (rowsToSelect.length > 0)
352 searchResultsTable.reselectRows(rowsToSelect);
354 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
355 $("numSearchResultsTotal").textContent = searchResultsTable.getRowIds().length;
357 setupSearchTableEvents(true);
360 const getStatusIconElement = function(text, image) {
361 return new Element("img", {
368 style: "margin-bottom: -2px; margin-left: 7px",
372 const updateStatusIconElement = function(searchId, text, image) {
373 const searchTab = $(`${searchTabIdPrefix}${searchId}`);
375 const statusIcon = searchTab.getElement(".statusIcon");
376 statusIcon.alt = text;
377 statusIcon.title = text;
378 statusIcon.src = image;
382 const startSearch = function(pattern, category, plugins) {
383 searchPatternChanged = false;
385 const url = new URI("api/v2/search/start");
394 onSuccess: (response) => {
395 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
396 const searchId = response.id;
397 createSearchTab(searchId, pattern);
399 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
400 searchJobs.push({ id: searchId, pattern: pattern });
401 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
406 const stopSearch = function(searchId) {
407 const url = new URI("api/v2/search/stop");
414 onSuccess: function(response) {
415 resetSearchState(searchId);
416 // not strictly necessary to do this when the tab is being closed, but there's no harm in it
417 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
422 const getSelectedSearchId = function() {
423 const selectedTab = $("searchTabs").getElement("li.selected");
424 return selectedTab ? getSearchIdFromTab(selectedTab) : null;
427 const startStopSearch = function() {
428 const currentSearchId = getSelectedSearchId();
429 const state = searchState.get(currentSearchId);
430 const isSearchRunning = state && state.running;
431 if (!isSearchRunning || searchPatternChanged) {
432 const pattern = $("searchPattern").value.trim();
433 const category = $("categorySelect").value;
434 const plugins = $("pluginsSelect").value;
436 if (!pattern || !category || !plugins)
439 searchText.pattern = pattern;
440 startSearch(pattern, category, plugins);
443 stopSearch(currentSearchId);
447 const openSearchTorrentDescriptionUrl = function() {
448 searchResultsTable.selectedRowsIds().each((rowId) => {
449 window.open(searchResultsTable.rows.get(rowId).full_data.descrLink, "_blank");
453 const copySearchTorrentName = function() {
455 searchResultsTable.selectedRowsIds().each((rowId) => {
456 names.push(searchResultsTable.rows.get(rowId).full_data.fileName);
458 return names.join("\n");
461 const copySearchTorrentDownloadLink = function() {
463 searchResultsTable.selectedRowsIds().each((rowId) => {
464 urls.push(searchResultsTable.rows.get(rowId).full_data.fileUrl);
466 return urls.join("\n");
469 const copySearchTorrentDescriptionUrl = function() {
471 searchResultsTable.selectedRowsIds().each((rowId) => {
472 urls.push(searchResultsTable.rows.get(rowId).full_data.descrLink);
474 return urls.join("\n");
477 const downloadSearchTorrent = function() {
479 searchResultsTable.selectedRowsIds().each((rowId) => {
480 urls.push(searchResultsTable.rows.get(rowId).full_data.fileUrl);
483 // only proceed if at least 1 row was selected
487 showDownloadPage(urls);
490 const manageSearchPlugins = function() {
491 const id = "searchPlugins";
495 title: "QBT_TR(Search plugins)QBT_TR[CONTEXT=PluginSelectDlg]",
496 icon: "images/qbittorrent-tray.svg",
498 contentURL: "views/searchplugins.html",
502 paddingHorizontal: 0,
503 width: loadWindowWidth(id, 600),
504 height: loadWindowHeight(id, 360),
505 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
508 onBeforeBuild: function() {
511 onClose: function() {
512 clearTimeout(loadSearchPluginsTimer);
513 loadSearchPluginsTimer = -1;
519 const loadSearchPlugins = function() {
521 loadSearchPluginsTimer = loadSearchPlugins.delay(2000);
524 const onSearchPatternChanged = function() {
525 const currentSearchId = getSelectedSearchId();
526 const state = searchState.get(currentSearchId);
527 const currentSearchPattern = $("searchPattern").value.trim();
528 // start a new search if pattern has changed, otherwise allow the search to be stopped
529 if (state && (state.searchPattern === currentSearchPattern)) {
530 searchPatternChanged = false;
531 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
534 searchPatternChanged = true;
535 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
539 const categorySelected = function() {
540 selectedCategory = $("categorySelect").value;
543 const pluginSelected = function() {
544 selectedPlugin = $("pluginsSelect").value;
546 if (selectedPlugin !== prevSelectedPlugin) {
547 prevSelectedPlugin = selectedPlugin;
548 getSearchCategories();
552 const reselectCategory = function() {
553 for (let i = 0; i < $("categorySelect").options.length; ++i) {
554 if ($("categorySelect").options[i].get("value") === selectedCategory)
555 $("categorySelect").options[i].selected = true;
561 const reselectPlugin = function() {
562 for (let i = 0; i < $("pluginsSelect").options.length; ++i) {
563 if ($("pluginsSelect").options[i].get("value") === selectedPlugin)
564 $("pluginsSelect").options[i].selected = true;
570 const resetSearchState = function(searchId) {
571 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
572 const state = searchState.get(searchId);
574 state.running = false;
575 clearTimeout(state.loadResultsTimer);
576 state.loadResultsTimer = -1;
580 const getSearchCategories = () => {
581 const populateCategorySelect = (categories) => {
582 const categoryOptions = [];
584 for (const category of categories) {
585 const option = document.createElement("option");
586 option.value = category.id;
587 option.textContent = category.name;
588 categoryOptions.push(option);
591 // first category is "All Categories"
592 if (categoryOptions.length > 1) {
594 const option = document.createElement("option");
595 option.disabled = true;
596 option.textContent = "──────────";
597 categoryOptions.splice(1, 0, option);
600 $("categorySelect").replaceChildren(...categoryOptions);
603 const selectedPlugin = $("pluginsSelect").value;
605 if ((selectedPlugin === "all") || (selectedPlugin === "enabled")) {
606 const uniqueCategories = {};
607 for (const plugin of searchPlugins) {
608 if ((selectedPlugin === "enabled") && !plugin.enabled)
610 for (const category of plugin.supportedCategories) {
611 if (uniqueCategories[category.id] === undefined)
612 uniqueCategories[category.id] = category;
615 // we must sort the ids to maintain consistent order.
616 const categories = Object.keys(uniqueCategories).sort().map(id => uniqueCategories[id]);
617 populateCategorySelect(categories);
620 const plugin = getPlugin(selectedPlugin);
621 const plugins = (plugin === null) ? [] : plugin.supportedCategories;
622 populateCategorySelect(plugins);
628 const getPlugins = function() {
630 url: new URI("api/v2/search/plugins"),
633 onSuccess: (response) => {
634 const createOption = (text, value, disabled = false) => {
635 const option = document.createElement("option");
636 if (value !== undefined)
637 option.value = value;
638 option.textContent = text;
639 option.disabled = disabled;
643 if (response !== prevSearchPluginsResponse) {
644 prevSearchPluginsResponse = response;
645 searchPlugins.length = 0;
646 response.forEach((plugin) => {
647 searchPlugins.push(plugin);
650 const pluginOptions = [];
651 pluginOptions.push(createOption("QBT_TR(Only enabled)QBT_TR[CONTEXT=SearchEngineWidget]", "enabled"));
652 pluginOptions.push(createOption("QBT_TR(All plugins)QBT_TR[CONTEXT=SearchEngineWidget]", "all"));
654 const searchPluginsEmpty = (searchPlugins.length === 0);
655 if (!searchPluginsEmpty) {
656 $("searchResultsNoPlugins").style.display = "none";
657 if (numSearchTabs() === 0)
658 $("searchResultsNoSearches").style.display = "block";
660 // sort plugins alphabetically
661 const allPlugins = searchPlugins.sort((left, right) => {
662 const leftName = left.fullName;
663 const rightName = right.fullName;
664 return window.qBittorrent.Misc.naturalSortCollator.compare(leftName, rightName);
667 allPlugins.each((plugin) => {
668 if (plugin.enabled === true)
669 pluginOptions.push(createOption(plugin.fullName, plugin.name));
672 if (pluginOptions.length > 2)
673 pluginOptions.splice(2, 0, createOption("──────────", undefined, true));
676 $("pluginsSelect").replaceChildren(...pluginOptions);
678 $("searchPattern").disabled = searchPluginsEmpty;
679 $("categorySelect").disabled = searchPluginsEmpty;
680 $("pluginsSelect").disabled = searchPluginsEmpty;
681 document.getElementById("startSearchButton").disabled = searchPluginsEmpty;
683 if (window.qBittorrent.SearchPlugins !== undefined)
684 window.qBittorrent.SearchPlugins.updateTable();
692 const getPlugin = function(name) {
693 for (let i = 0; i < searchPlugins.length; ++i) {
694 if (searchPlugins[i].name === name)
695 return searchPlugins[i];
701 const resetFilters = function() {
702 searchText.filterPattern = "";
703 $("searchInNameFilter").value = "";
705 searchSeedsFilter.min = 0;
706 searchSeedsFilter.max = 0;
707 $("searchMinSeedsFilter").value = searchSeedsFilter.min;
708 $("searchMaxSeedsFilter").value = searchSeedsFilter.max;
710 searchSizeFilter.min = 0.00;
711 searchSizeFilter.minUnit = 2; // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
712 searchSizeFilter.max = 0.00;
713 searchSizeFilter.maxUnit = 3;
714 $("searchMinSizeFilter").value = searchSizeFilter.min;
715 $("searchMinSizePrefix").value = searchSizeFilter.minUnit;
716 $("searchMaxSizeFilter").value = searchSizeFilter.max;
717 $("searchMaxSizePrefix").value = searchSizeFilter.maxUnit;
720 const getSearchInTorrentName = function() {
721 return ($("searchInTorrentName").value === "names") ? "names" : "everywhere";
724 const searchInTorrentName = function() {
725 LocalPreferences.set("search_in_filter", getSearchInTorrentName());
726 searchFilterChanged();
729 const searchSeedsFilterChanged = function() {
730 searchSeedsFilter.min = $("searchMinSeedsFilter").value;
731 searchSeedsFilter.max = $("searchMaxSeedsFilter").value;
733 searchFilterChanged();
736 const searchSizeFilterChanged = function() {
737 searchSizeFilter.min = $("searchMinSizeFilter").value;
738 searchSizeFilter.minUnit = $("searchMinSizePrefix").value;
739 searchSizeFilter.max = $("searchMaxSizeFilter").value;
740 searchSizeFilter.maxUnit = $("searchMaxSizePrefix").value;
742 searchFilterChanged();
745 const searchSizeFilterPrefixChanged = function() {
746 if ((Number($("searchMinSizeFilter").value) !== 0) || (Number($("searchMaxSizeFilter").value) !== 0))
747 searchSizeFilterChanged();
750 const searchFilterChanged = function() {
751 searchResultsTable.updateTable();
752 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
755 const setupSearchTableEvents = function(enable) {
756 const clickHandler = (e) => { downloadSearchTorrent(); };
758 $$(".searchTableRow").each((target) => {
759 target.addEventListener("dblclick", clickHandler);
763 $$(".searchTableRow").each((target) => {
764 target.removeEventListener("dblclick", clickHandler);
769 const loadSearchResultsData = function(searchId) {
770 const state = searchState.get(searchId);
772 const maxResults = 500;
773 const url = new URI("api/v2/search/results");
783 onFailure: function(response) {
784 if ((response.status === 400) || (response.status === 404)) {
785 // bad params. search id is invalid
786 resetSearchState(searchId);
787 updateStatusIconElement(searchId, "QBT_TR(An error occurred during search...)QBT_TR[CONTEXT=SearchJobWidget]", "images/error.svg");
790 clearTimeout(state.loadResultsTimer);
791 state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
794 onSuccess: function(response) {
795 $("error_div").textContent = "";
797 const state = searchState.get(searchId);
798 // check if user stopped the search prior to receiving the response
799 if (!state.running) {
800 clearTimeout(state.loadResultsTimer);
801 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
806 setupSearchTableEvents(false);
808 const state = searchState.get(searchId);
811 if (response.results) {
812 const results = response.results;
813 for (let i = 0; i < results.length; ++i) {
814 const result = results[i];
817 descrLink: result.descrLink,
818 fileName: result.fileName,
819 fileSize: result.fileSize,
820 fileUrl: result.fileUrl,
821 nbLeechers: result.nbLeechers,
822 nbSeeders: result.nbSeeders,
823 siteUrl: result.siteUrl,
824 pubDate: result.pubDate,
828 state.rows.push(row);
833 // only update table if this search is currently being displayed
834 if (searchId === getSelectedSearchId()) {
835 for (const row of newRows)
836 searchResultsTable.updateRowData(row);
838 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
839 $("numSearchResultsTotal").textContent = searchResultsTable.getRowIds().length;
841 searchResultsTable.updateTable();
844 setupSearchTableEvents(true);
846 if ((response.status === "Stopped") && (state.rowId >= response.total)) {
847 resetSearchState(searchId);
848 updateStatusIconElement(searchId, "QBT_TR(Search has finished)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-complete.svg");
853 clearTimeout(state.loadResultsTimer);
854 state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
859 const updateSearchResultsData = function(searchId) {
860 const state = searchState.get(searchId);
861 clearTimeout(state.loadResultsTimer);
862 state.loadResultsTimer = loadSearchResultsData.delay(500, this, searchId);
865 new ClipboardJS(".copySearchDataToClipboard", {
866 text: function(trigger) {
867 switch (trigger.id) {
868 case "copySearchTorrentName":
869 return copySearchTorrentName();
870 case "copySearchTorrentDownloadLink":
871 return copySearchTorrentDownloadLink();
872 case "copySearchTorrentDescriptionUrl":
873 return copySearchTorrentDescriptionUrl();
882 Object.freeze(window.qBittorrent.Search);