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 searchResultsTabsContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
90 targets: ".searchTab",
91 menu: "searchResultsTabsMenu",
93 closeTab: (tab) => { closeSearchTab(tab); },
95 for (const tab of document.querySelectorAll("#searchTabs .searchTab"))
104 setActiveTab(this.options.element);
109 // load "Search in" preference from local storage
110 $("searchInTorrentName").value = (LocalPreferences.get("search_in_filter") === "names") ? "names" : "everywhere";
111 const searchResultsTableContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
112 targets: "#searchResultsTableDiv tr",
113 menu: "searchResultsTableMenu",
115 Download: downloadSearchTorrent,
116 OpenDescriptionUrl: openSearchTorrentDescriptionUrl
123 searchResultsTable = new window.qBittorrent.DynamicTable.SearchResultsTable();
124 searchResultsTable.setup("searchResultsTableDiv", "searchResultsTableFixedHeaderDiv", searchResultsTableContextMenu);
127 searchResultsTable.dynamicTableDiv.addEventListener("dblclick", (e) => { downloadSearchTorrent(); });
129 // listen for changes to searchInNameFilter
130 let searchInNameFilterTimer = -1;
131 $("searchInNameFilter").addEventListener("input", () => {
132 clearTimeout(searchInNameFilterTimer);
133 searchInNameFilterTimer = setTimeout(() => {
134 searchInNameFilterTimer = -1;
136 const value = $("searchInNameFilter").value;
137 searchText.filterPattern = value;
138 searchFilterChanged();
139 }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
142 document.getElementById("SearchPanel").addEventListener("keydown", (event) => {
145 event.preventDefault();
146 event.stopPropagation();
148 switch (event.target.id) {
149 case "manageSearchPlugins":
150 manageSearchPlugins();
152 case "searchPattern":
153 document.getElementById("startSearchButton").click();
162 // restore search tabs
163 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
164 for (const { id, pattern } of searchJobs)
165 createSearchTab(id, pattern);
168 const numSearchTabs = () => {
169 return $("searchTabs").getElements("li").length;
172 const getSearchIdFromTab = (tab) => {
173 return Number(tab.id.substring(searchTabIdPrefix.length));
176 const createSearchTab = (searchId, pattern) => {
177 const newTabId = `${searchTabIdPrefix}${searchId}`;
178 const tabElem = document.createElement("a");
179 tabElem.textContent = pattern;
181 const closeTabElem = document.createElement("img");
182 closeTabElem.alt = "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]";
183 closeTabElem.title = "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]";
184 closeTabElem.src = "images/application-exit.svg";
185 closeTabElem.width = "10";
186 closeTabElem.height = "10";
187 closeTabElem.addEventListener("click", function(e) { qBittorrent.Search.closeSearchTab(this); });
189 tabElem.prepend(closeTabElem);
190 tabElem.appendChild(getStatusIconElement("QBT_TR(Searching...)QBT_TR[CONTEXT=SearchJobWidget]", "images/queued.svg"));
192 const listItem = document.createElement("li");
193 listItem.id = newTabId;
194 listItem.classList.add("selected", "searchTab");
195 listItem.addEventListener("click", (e) => {
196 setActiveTab(listItem);
197 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
199 listItem.appendChild(tabElem);
200 $("searchTabs").appendChild(listItem);
201 searchResultsTabsContextMenu.addTarget(listItem);
203 // unhide the results elements
204 if (numSearchTabs() >= 1) {
205 $("searchResultsNoSearches").classList.add("invisible");
206 $("searchResultsFilters").classList.remove("invisible");
207 $("searchResultsTableContainer").classList.remove("invisible");
208 $("searchTabsToolbar").classList.remove("invisible");
212 setActiveTab(listItem);
214 searchResultsTable.clear();
217 searchState.set(searchId, {
218 searchPattern: pattern,
219 filterPattern: searchText.filterPattern,
220 seedsFilter: { min: searchSeedsFilter.min, max: searchSeedsFilter.max },
221 sizeFilter: { min: searchSizeFilter.min, minUnit: searchSizeFilter.minUnit, max: searchSizeFilter.max, maxUnit: searchSizeFilter.maxUnit },
222 searchIn: getSearchInTorrentName(),
227 loadResultsTimer: -1,
228 sort: { column: searchResultsTable.sortedColumn, reverse: searchResultsTable.reverseSort },
230 updateSearchResultsData(searchId);
233 const closeSearchTab = (el) => {
234 const tab = el.closest("li.searchTab");
238 const searchId = getSearchIdFromTab(tab);
239 const isTabSelected = tab.classList.contains("selected");
240 const newTabToSelect = isTabSelected ? (tab.nextSibling || tab.previousSibling) : null;
242 const currentSearchId = getSelectedSearchId();
243 const state = searchState.get(currentSearchId);
244 // don't bother sending a stop request if already stopped
245 if (state && state.running)
246 stopSearch(searchId);
250 fetch("api/v2/search/delete", {
252 body: new URLSearchParams({
257 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
258 const jobIndex = searchJobs.findIndex((job) => job.id === searchId);
260 searchJobs.splice(jobIndex, 1);
261 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
264 if (numSearchTabs() === 0) {
268 $("numSearchResultsVisible").textContent = 0;
269 $("numSearchResultsTotal").textContent = 0;
270 $("searchResultsNoSearches").classList.remove("invisible");
271 $("searchResultsFilters").classList.add("invisible");
272 $("searchResultsTableContainer").classList.add("invisible");
273 $("searchTabsToolbar").classList.add("invisible");
275 else if (isTabSelected && newTabToSelect) {
276 setActiveTab(newTabToSelect);
277 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
281 const saveCurrentTabState = () => {
282 const currentSearchId = getSelectedSearchId();
283 if (!currentSearchId)
286 const state = searchState.get(currentSearchId);
290 state.filterPattern = searchText.filterPattern;
291 state.seedsFilter = {
292 min: searchSeedsFilter.min,
293 max: searchSeedsFilter.max,
296 min: searchSizeFilter.min,
297 minUnit: searchSizeFilter.minUnit,
298 max: searchSizeFilter.max,
299 maxUnit: searchSizeFilter.maxUnit,
301 state.searchIn = getSearchInTorrentName();
304 column: searchResultsTable.sortedColumn,
305 reverse: searchResultsTable.reverseSort,
308 // we must copy the array to avoid taking a reference to it
309 state.selectedRowIds = [...searchResultsTable.selectedRows];
312 const setActiveTab = (tab) => {
313 const searchId = getSearchIdFromTab(tab);
314 if (searchId === getSelectedSearchId())
317 saveCurrentTabState();
319 MochaUI.selected(tab, "searchTabs");
321 const state = searchState.get(searchId);
322 let rowsToSelect = [];
324 // restore table rows
325 searchResultsTable.clear();
327 for (const row of state.rows)
328 searchResultsTable.updateRowData(row);
330 rowsToSelect = state.selectedRowIds;
333 searchText.pattern = state.searchPattern;
334 searchText.filterPattern = state.filterPattern;
335 $("searchInNameFilter").value = state.filterPattern;
337 searchSeedsFilter.min = state.seedsFilter.min;
338 searchSeedsFilter.max = state.seedsFilter.max;
339 $("searchMinSeedsFilter").value = state.seedsFilter.min;
340 $("searchMaxSeedsFilter").value = state.seedsFilter.max;
342 searchSizeFilter.min = state.sizeFilter.min;
343 searchSizeFilter.minUnit = state.sizeFilter.minUnit;
344 searchSizeFilter.max = state.sizeFilter.max;
345 searchSizeFilter.maxUnit = state.sizeFilter.maxUnit;
346 $("searchMinSizeFilter").value = state.sizeFilter.min;
347 $("searchMinSizePrefix").value = state.sizeFilter.minUnit;
348 $("searchMaxSizeFilter").value = state.sizeFilter.max;
349 $("searchMaxSizePrefix").value = state.sizeFilter.maxUnit;
351 const currentSearchPattern = $("searchPattern").value.trim();
352 if (state.running && (state.searchPattern === currentSearchPattern)) {
353 // allow search to be stopped
354 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
355 searchPatternChanged = false;
358 searchResultsTable.setSortedColumn(state.sort.column, state.sort.reverse);
360 $("searchInTorrentName").value = state.searchIn;
363 // must restore all filters before calling updateTable
364 searchResultsTable.updateTable();
366 // must reselect rows after calling updateTable
367 if (rowsToSelect.length > 0)
368 searchResultsTable.reselectRows(rowsToSelect);
370 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
371 $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
374 const getStatusIconElement = (text, image) => {
375 const statusIcon = document.createElement("img");
376 statusIcon.alt = text;
377 statusIcon.title = text;
378 statusIcon.src = image;
379 statusIcon.className = "statusIcon";
380 statusIcon.width = "12";
381 statusIcon.height = "12";
385 const updateStatusIconElement = (searchId, text, image) => {
386 const searchTab = $(`${searchTabIdPrefix}${searchId}`);
388 const statusIcon = searchTab.querySelector(".statusIcon");
389 statusIcon.alt = text;
390 statusIcon.title = text;
391 statusIcon.src = image;
395 const startSearch = (pattern, category, plugins) => {
396 searchPatternChanged = false;
397 fetch("api/v2/search/start", {
399 body: new URLSearchParams({
405 .then(async (response) => {
409 const responseJSON = await response.json();
411 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
412 const searchId = responseJSON.id;
413 createSearchTab(searchId, pattern);
415 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
416 searchJobs.push({ id: searchId, pattern: pattern });
417 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
421 const stopSearch = (searchId) => {
422 fetch("api/v2/search/stop", {
424 body: new URLSearchParams({
428 .then((response) => {
432 resetSearchState(searchId);
433 // not strictly necessary to do this when the tab is being closed, but there's no harm in it
434 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
438 const getSelectedSearchId = () => {
439 const selectedTab = $("searchTabs").querySelector("li.selected");
440 return selectedTab ? getSearchIdFromTab(selectedTab) : null;
443 const startStopSearch = () => {
444 const currentSearchId = getSelectedSearchId();
445 const state = searchState.get(currentSearchId);
446 const isSearchRunning = state && state.running;
447 if (!isSearchRunning || searchPatternChanged) {
448 const pattern = $("searchPattern").value.trim();
449 const category = $("categorySelect").value;
450 const plugins = $("pluginsSelect").value;
452 if (!pattern || !category || !plugins)
455 searchText.pattern = pattern;
456 startSearch(pattern, category, plugins);
459 stopSearch(currentSearchId);
463 const openSearchTorrentDescriptionUrl = () => {
464 for (const rowID of searchResultsTable.selectedRowsIds())
465 window.open(searchResultsTable.getRow(rowID).full_data.descrLink, "_blank");
468 const copySearchTorrentName = () => {
470 searchResultsTable.selectedRowsIds().each((rowId) => {
471 names.push(searchResultsTable.getRow(rowId).full_data.fileName);
473 return names.join("\n");
476 const copySearchTorrentDownloadLink = () => {
478 searchResultsTable.selectedRowsIds().each((rowId) => {
479 urls.push(searchResultsTable.getRow(rowId).full_data.fileUrl);
481 return urls.join("\n");
484 const copySearchTorrentDescriptionUrl = () => {
486 searchResultsTable.selectedRowsIds().each((rowId) => {
487 urls.push(searchResultsTable.getRow(rowId).full_data.descrLink);
489 return urls.join("\n");
492 const downloadSearchTorrent = () => {
494 for (const rowID of searchResultsTable.selectedRowsIds())
495 urls.push(searchResultsTable.getRow(rowID).full_data.fileUrl);
497 // only proceed if at least 1 row was selected
501 showDownloadPage(urls);
504 const manageSearchPlugins = () => {
505 const id = "searchPlugins";
509 title: "QBT_TR(Search plugins)QBT_TR[CONTEXT=PluginSelectDlg]",
510 icon: "images/qbittorrent-tray.svg",
512 contentURL: "views/searchplugins.html",
516 paddingHorizontal: 0,
517 width: loadWindowWidth(id, 600),
518 height: loadWindowHeight(id, 360),
519 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
522 onBeforeBuild: () => {
526 clearTimeout(loadSearchPluginsTimer);
527 loadSearchPluginsTimer = -1;
533 const loadSearchPlugins = () => {
535 loadSearchPluginsTimer = loadSearchPlugins.delay(2000);
538 const onSearchPatternChanged = () => {
539 const currentSearchId = getSelectedSearchId();
540 const state = searchState.get(currentSearchId);
541 const currentSearchPattern = $("searchPattern").value.trim();
542 // start a new search if pattern has changed, otherwise allow the search to be stopped
543 if (state && (state.searchPattern === currentSearchPattern)) {
544 searchPatternChanged = false;
545 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
548 searchPatternChanged = true;
549 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
553 const categorySelected = () => {
554 selectedCategory = $("categorySelect").value;
557 const pluginSelected = () => {
558 selectedPlugin = $("pluginsSelect").value;
560 if (selectedPlugin !== prevSelectedPlugin) {
561 prevSelectedPlugin = selectedPlugin;
562 getSearchCategories();
566 const reselectCategory = () => {
567 for (let i = 0; i < $("categorySelect").options.length; ++i) {
568 if ($("categorySelect").options[i].get("value") === selectedCategory)
569 $("categorySelect").options[i].selected = true;
575 const reselectPlugin = () => {
576 for (let i = 0; i < $("pluginsSelect").options.length; ++i) {
577 if ($("pluginsSelect").options[i].get("value") === selectedPlugin)
578 $("pluginsSelect").options[i].selected = true;
584 const resetSearchState = (searchId) => {
585 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
586 const state = searchState.get(searchId);
588 state.running = false;
589 clearTimeout(state.loadResultsTimer);
590 state.loadResultsTimer = -1;
594 const getSearchCategories = () => {
595 const populateCategorySelect = (categories) => {
596 const categoryOptions = [];
598 for (const category of categories) {
599 const option = document.createElement("option");
600 option.value = category.id;
601 option.textContent = category.name;
602 categoryOptions.push(option);
605 // first category is "All Categories"
606 if (categoryOptions.length > 1) {
608 const option = document.createElement("option");
609 option.disabled = true;
610 option.textContent = "──────────";
611 categoryOptions.splice(1, 0, option);
614 $("categorySelect").replaceChildren(...categoryOptions);
617 const selectedPlugin = $("pluginsSelect").value;
619 if ((selectedPlugin === "all") || (selectedPlugin === "enabled")) {
620 const uniqueCategories = {};
621 for (const plugin of searchPlugins) {
622 if ((selectedPlugin === "enabled") && !plugin.enabled)
624 for (const category of plugin.supportedCategories) {
625 if (uniqueCategories[category.id] === undefined)
626 uniqueCategories[category.id] = category;
629 // we must sort the ids to maintain consistent order.
630 const categories = Object.keys(uniqueCategories).sort().map(id => uniqueCategories[id]);
631 populateCategorySelect(categories);
634 const plugin = getPlugin(selectedPlugin);
635 const plugins = (plugin === null) ? [] : plugin.supportedCategories;
636 populateCategorySelect(plugins);
642 const getPlugins = () => {
643 fetch("api/v2/search/plugins", {
647 .then(async (response) => {
651 const responseJSON = await response.json();
653 const createOption = (text, value, disabled = false) => {
654 const option = document.createElement("option");
655 if (value !== undefined)
656 option.value = value;
657 option.textContent = text;
658 option.disabled = disabled;
662 if (prevSearchPluginsResponse !== responseJSON) {
663 prevSearchPluginsResponse = responseJSON;
664 searchPlugins.length = 0;
665 responseJSON.forEach((plugin) => {
666 searchPlugins.push(plugin);
669 const pluginOptions = [];
670 pluginOptions.push(createOption("QBT_TR(Only enabled)QBT_TR[CONTEXT=SearchEngineWidget]", "enabled"));
671 pluginOptions.push(createOption("QBT_TR(All plugins)QBT_TR[CONTEXT=SearchEngineWidget]", "all"));
673 const searchPluginsEmpty = (searchPlugins.length === 0);
674 if (!searchPluginsEmpty) {
675 $("searchResultsNoPlugins").classList.add("invisible");
676 if (numSearchTabs() === 0)
677 $("searchResultsNoSearches").classList.remove("invisible");
679 // sort plugins alphabetically
680 const allPlugins = searchPlugins.sort((left, right) => {
681 const leftName = left.fullName;
682 const rightName = right.fullName;
683 return window.qBittorrent.Misc.naturalSortCollator.compare(leftName, rightName);
686 allPlugins.each((plugin) => {
687 if (plugin.enabled === true)
688 pluginOptions.push(createOption(plugin.fullName, plugin.name));
691 if (pluginOptions.length > 2)
692 pluginOptions.splice(2, 0, createOption("──────────", undefined, true));
695 $("pluginsSelect").replaceChildren(...pluginOptions);
697 $("searchPattern").disabled = searchPluginsEmpty;
698 $("categorySelect").disabled = searchPluginsEmpty;
699 $("pluginsSelect").disabled = searchPluginsEmpty;
700 document.getElementById("startSearchButton").disabled = searchPluginsEmpty;
702 if (window.qBittorrent.SearchPlugins !== undefined)
703 window.qBittorrent.SearchPlugins.updateTable();
710 const getPlugin = (name) => {
711 for (let i = 0; i < searchPlugins.length; ++i) {
712 if (searchPlugins[i].name === name)
713 return searchPlugins[i];
719 const resetFilters = () => {
720 searchText.filterPattern = "";
721 $("searchInNameFilter").value = "";
723 searchSeedsFilter.min = 0;
724 searchSeedsFilter.max = 0;
725 $("searchMinSeedsFilter").value = searchSeedsFilter.min;
726 $("searchMaxSeedsFilter").value = searchSeedsFilter.max;
728 searchSizeFilter.min = 0.00;
729 searchSizeFilter.minUnit = 2; // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
730 searchSizeFilter.max = 0.00;
731 searchSizeFilter.maxUnit = 3;
732 $("searchMinSizeFilter").value = searchSizeFilter.min;
733 $("searchMinSizePrefix").value = searchSizeFilter.minUnit;
734 $("searchMaxSizeFilter").value = searchSizeFilter.max;
735 $("searchMaxSizePrefix").value = searchSizeFilter.maxUnit;
738 const getSearchInTorrentName = () => {
739 return ($("searchInTorrentName").value === "names") ? "names" : "everywhere";
742 const searchInTorrentName = () => {
743 LocalPreferences.set("search_in_filter", getSearchInTorrentName());
744 searchFilterChanged();
747 const searchSeedsFilterChanged = () => {
748 searchSeedsFilter.min = $("searchMinSeedsFilter").value;
749 searchSeedsFilter.max = $("searchMaxSeedsFilter").value;
751 searchFilterChanged();
754 const searchSizeFilterChanged = () => {
755 searchSizeFilter.min = $("searchMinSizeFilter").value;
756 searchSizeFilter.minUnit = $("searchMinSizePrefix").value;
757 searchSizeFilter.max = $("searchMaxSizeFilter").value;
758 searchSizeFilter.maxUnit = $("searchMaxSizePrefix").value;
760 searchFilterChanged();
763 const searchSizeFilterPrefixChanged = () => {
764 if ((Number($("searchMinSizeFilter").value) !== 0) || (Number($("searchMaxSizeFilter").value) !== 0))
765 searchSizeFilterChanged();
768 const searchFilterChanged = () => {
769 searchResultsTable.updateTable();
770 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
773 const loadSearchResultsData = function(searchId) {
774 const state = searchState.get(searchId);
775 const url = new URL("api/v2/search/results", window.location);
776 url.search = new URLSearchParams({
785 .then(async (response) => {
787 if ((response.status === 400) || (response.status === 404)) {
788 // bad params. search id is invalid
789 resetSearchState(searchId);
790 updateStatusIconElement(searchId, "QBT_TR(An error occurred during search...)QBT_TR[CONTEXT=SearchJobWidget]", "images/error.svg");
793 clearTimeout(state.loadResultsTimer);
794 state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
799 $("error_div").textContent = "";
801 const state = searchState.get(searchId);
802 // check if user stopped the search prior to receiving the response
803 if (!state.running) {
804 clearTimeout(state.loadResultsTimer);
805 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
809 const responseJSON = await response.json();
811 const state = searchState.get(searchId);
814 if (responseJSON.results) {
815 const results = responseJSON.results;
816 for (let i = 0; i < results.length; ++i) {
817 const result = results[i];
820 descrLink: result.descrLink,
821 fileName: result.fileName,
822 fileSize: result.fileSize,
823 fileUrl: result.fileUrl,
824 nbLeechers: result.nbLeechers,
825 nbSeeders: result.nbSeeders,
826 engineName: result.engineName,
827 siteUrl: result.siteUrl,
828 pubDate: result.pubDate,
832 state.rows.push(row);
837 // only update table if this search is currently being displayed
838 if (searchId === getSelectedSearchId()) {
839 for (const row of newRows)
840 searchResultsTable.updateRowData(row);
842 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
843 $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
845 searchResultsTable.updateTable();
848 if ((responseJSON.status === "Stopped") && (state.rowId >= responseJSON.total)) {
849 resetSearchState(searchId);
850 updateStatusIconElement(searchId, "QBT_TR(Search has finished)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-complete.svg");
855 clearTimeout(state.loadResultsTimer);
856 state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
860 const updateSearchResultsData = function(searchId) {
861 const state = searchState.get(searchId);
862 clearTimeout(state.loadResultsTimer);
863 state.loadResultsTimer = loadSearchResultsData.delay(500, this, searchId);
866 new ClipboardJS(".copySearchDataToClipboard", {
868 switch (trigger.id) {
869 case "copySearchTorrentName":
870 return copySearchTorrentName();
871 case "copySearchTorrentDownloadLink":
872 return copySearchTorrentDownloadLink();
873 case "copySearchTorrentDescriptionUrl":
874 return copySearchTorrentDescriptionUrl();
883 Object.freeze(window.qBittorrent.Search);