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: ".searchTableRow",
113 menu: "searchResultsTableMenu",
115 Download: downloadSearchTorrent,
116 OpenDescriptionUrl: openSearchTorrentDescriptionUrl
123 searchResultsTable = new window.qBittorrent.DynamicTable.SearchResultsTable();
124 searchResultsTable.setup("searchResultsTableDiv", "searchResultsTableFixedHeaderDiv", searchResultsTableContextMenu);
127 // listen for changes to searchInNameFilter
128 let searchInNameFilterTimer = -1;
129 $("searchInNameFilter").addEventListener("input", () => {
130 clearTimeout(searchInNameFilterTimer);
131 searchInNameFilterTimer = setTimeout(() => {
132 searchInNameFilterTimer = -1;
134 const value = $("searchInNameFilter").value;
135 searchText.filterPattern = value;
136 searchFilterChanged();
137 }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
141 defaultEventType: "keydown",
143 "Enter": function(e) {
144 // accept enter key as a click
148 const elem = e.event.srcElement;
149 if (elem.className.contains("searchInputField")) {
150 document.getElementById("startSearchButton").click();
155 case "manageSearchPlugins":
156 manageSearchPlugins();
163 // restore search tabs
164 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
165 for (const { id, pattern } of searchJobs)
166 createSearchTab(id, pattern);
169 const numSearchTabs = () => {
170 return $("searchTabs").getElements("li").length;
173 const getSearchIdFromTab = (tab) => {
174 return Number(tab.id.substring(searchTabIdPrefix.length));
177 const createSearchTab = (searchId, pattern) => {
178 const newTabId = `${searchTabIdPrefix}${searchId}`;
179 const tabElem = new Element("a", {
183 const closeTabElem = new Element("img", {
184 alt: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
185 title: "QBT_TR(Close tab)QBT_TR[CONTEXT=SearchWidget]",
186 src: "images/application-exit.svg",
189 onclick: "qBittorrent.Search.closeSearchTab(this);",
191 closeTabElem.inject(tabElem, "top");
193 tabElem.appendChild(getStatusIconElement("QBT_TR(Searching...)QBT_TR[CONTEXT=SearchJobWidget]", "images/queued.svg"));
195 const listItem = document.createElement("li");
196 listItem.id = newTabId;
197 listItem.classList.add("selected", "searchTab");
198 listItem.addEventListener("click", (e) => {
199 setActiveTab(listItem);
200 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
202 listItem.appendChild(tabElem);
203 $("searchTabs").appendChild(listItem);
204 searchResultsTabsContextMenu.addTarget(listItem);
206 // unhide the results elements
207 if (numSearchTabs() >= 1) {
208 $("searchResultsNoSearches").classList.add("invisible");
209 $("searchResultsFilters").classList.remove("invisible");
210 $("searchResultsTableContainer").classList.remove("invisible");
211 $("searchTabsToolbar").classList.remove("invisible");
215 setActiveTab(listItem);
217 searchResultsTable.clear();
220 searchState.set(searchId, {
221 searchPattern: pattern,
222 filterPattern: searchText.filterPattern,
223 seedsFilter: { min: searchSeedsFilter.min, max: searchSeedsFilter.max },
224 sizeFilter: { min: searchSizeFilter.min, minUnit: searchSizeFilter.minUnit, max: searchSizeFilter.max, maxUnit: searchSizeFilter.maxUnit },
225 searchIn: getSearchInTorrentName(),
230 loadResultsTimer: -1,
231 sort: { column: searchResultsTable.sortedColumn, reverse: searchResultsTable.reverseSort },
233 updateSearchResultsData(searchId);
236 const closeSearchTab = (el) => {
237 const tab = el.closest("li.searchTab");
241 const searchId = getSearchIdFromTab(tab);
242 const isTabSelected = tab.hasClass("selected");
243 const newTabToSelect = isTabSelected ? (tab.nextSibling || tab.previousSibling) : null;
245 const currentSearchId = getSelectedSearchId();
246 const state = searchState.get(currentSearchId);
247 // don't bother sending a stop request if already stopped
248 if (state && state.running)
249 stopSearch(searchId);
254 url: new URI("api/v2/search/delete"),
261 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
262 const jobIndex = searchJobs.findIndex((job) => job.id === searchId);
264 searchJobs.splice(jobIndex, 1);
265 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
268 if (numSearchTabs() === 0) {
272 $("numSearchResultsVisible").textContent = 0;
273 $("numSearchResultsTotal").textContent = 0;
274 $("searchResultsNoSearches").classList.remove("invisible");
275 $("searchResultsFilters").classList.add("invisible");
276 $("searchResultsTableContainer").classList.add("invisible");
277 $("searchTabsToolbar").classList.add("invisible");
279 else if (isTabSelected && newTabToSelect) {
280 setActiveTab(newTabToSelect);
281 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
285 const saveCurrentTabState = () => {
286 const currentSearchId = getSelectedSearchId();
287 if (!currentSearchId)
290 const state = searchState.get(currentSearchId);
294 state.filterPattern = searchText.filterPattern;
295 state.seedsFilter = {
296 min: searchSeedsFilter.min,
297 max: searchSeedsFilter.max,
300 min: searchSizeFilter.min,
301 minUnit: searchSizeFilter.minUnit,
302 max: searchSizeFilter.max,
303 maxUnit: searchSizeFilter.maxUnit,
305 state.searchIn = getSearchInTorrentName();
308 column: searchResultsTable.sortedColumn,
309 reverse: searchResultsTable.reverseSort,
312 // we must copy the array to avoid taking a reference to it
313 state.selectedRowIds = [...searchResultsTable.selectedRows];
316 const setActiveTab = (tab) => {
317 const searchId = getSearchIdFromTab(tab);
318 if (searchId === getSelectedSearchId())
321 saveCurrentTabState();
323 MochaUI.selected(tab, "searchTabs");
325 const state = searchState.get(searchId);
326 let rowsToSelect = [];
328 // restore table rows
329 searchResultsTable.clear();
331 for (const row of state.rows)
332 searchResultsTable.updateRowData(row);
334 rowsToSelect = state.selectedRowIds;
337 searchText.pattern = state.searchPattern;
338 searchText.filterPattern = state.filterPattern;
339 $("searchInNameFilter").value = state.filterPattern;
341 searchSeedsFilter.min = state.seedsFilter.min;
342 searchSeedsFilter.max = state.seedsFilter.max;
343 $("searchMinSeedsFilter").value = state.seedsFilter.min;
344 $("searchMaxSeedsFilter").value = state.seedsFilter.max;
346 searchSizeFilter.min = state.sizeFilter.min;
347 searchSizeFilter.minUnit = state.sizeFilter.minUnit;
348 searchSizeFilter.max = state.sizeFilter.max;
349 searchSizeFilter.maxUnit = state.sizeFilter.maxUnit;
350 $("searchMinSizeFilter").value = state.sizeFilter.min;
351 $("searchMinSizePrefix").value = state.sizeFilter.minUnit;
352 $("searchMaxSizeFilter").value = state.sizeFilter.max;
353 $("searchMaxSizePrefix").value = state.sizeFilter.maxUnit;
355 const currentSearchPattern = $("searchPattern").value.trim();
356 if (state.running && (state.searchPattern === currentSearchPattern)) {
357 // allow search to be stopped
358 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
359 searchPatternChanged = false;
362 searchResultsTable.setSortedColumn(state.sort.column, state.sort.reverse);
364 $("searchInTorrentName").value = state.searchIn;
367 // must restore all filters before calling updateTable
368 searchResultsTable.updateTable();
370 // must reselect rows after calling updateTable
371 if (rowsToSelect.length > 0)
372 searchResultsTable.reselectRows(rowsToSelect);
374 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
375 $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
377 setupSearchTableEvents(true);
380 const getStatusIconElement = (text, image) => {
381 return new Element("img", {
391 const updateStatusIconElement = (searchId, text, image) => {
392 const searchTab = $(`${searchTabIdPrefix}${searchId}`);
394 const statusIcon = searchTab.getElement(".statusIcon");
395 statusIcon.alt = text;
396 statusIcon.title = text;
397 statusIcon.src = image;
401 const startSearch = (pattern, category, plugins) => {
402 searchPatternChanged = false;
404 const url = new URI("api/v2/search/start");
413 onSuccess: (response) => {
414 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
415 const searchId = response.id;
416 createSearchTab(searchId, pattern);
418 const searchJobs = JSON.parse(LocalPreferences.get("search_jobs", "[]"));
419 searchJobs.push({ id: searchId, pattern: pattern });
420 LocalPreferences.set("search_jobs", JSON.stringify(searchJobs));
425 const stopSearch = (searchId) => {
426 const url = new URI("api/v2/search/stop");
433 onSuccess: (response) => {
434 resetSearchState(searchId);
435 // not strictly necessary to do this when the tab is being closed, but there's no harm in it
436 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
441 const getSelectedSearchId = () => {
442 const selectedTab = $("searchTabs").getElement("li.selected");
443 return selectedTab ? getSearchIdFromTab(selectedTab) : null;
446 const startStopSearch = () => {
447 const currentSearchId = getSelectedSearchId();
448 const state = searchState.get(currentSearchId);
449 const isSearchRunning = state && state.running;
450 if (!isSearchRunning || searchPatternChanged) {
451 const pattern = $("searchPattern").value.trim();
452 const category = $("categorySelect").value;
453 const plugins = $("pluginsSelect").value;
455 if (!pattern || !category || !plugins)
458 searchText.pattern = pattern;
459 startSearch(pattern, category, plugins);
462 stopSearch(currentSearchId);
466 const openSearchTorrentDescriptionUrl = () => {
467 for (const rowID of searchResultsTable.selectedRowsIds())
468 window.open(searchResultsTable.getRow(rowID).full_data.descrLink, "_blank");
471 const copySearchTorrentName = () => {
473 searchResultsTable.selectedRowsIds().each((rowId) => {
474 names.push(searchResultsTable.getRow(rowId).full_data.fileName);
476 return names.join("\n");
479 const copySearchTorrentDownloadLink = () => {
481 searchResultsTable.selectedRowsIds().each((rowId) => {
482 urls.push(searchResultsTable.getRow(rowId).full_data.fileUrl);
484 return urls.join("\n");
487 const copySearchTorrentDescriptionUrl = () => {
489 searchResultsTable.selectedRowsIds().each((rowId) => {
490 urls.push(searchResultsTable.getRow(rowId).full_data.descrLink);
492 return urls.join("\n");
495 const downloadSearchTorrent = () => {
497 for (const rowID of searchResultsTable.selectedRowsIds())
498 urls.push(searchResultsTable.getRow(rowID).full_data.fileUrl);
500 // only proceed if at least 1 row was selected
504 showDownloadPage(urls);
507 const manageSearchPlugins = () => {
508 const id = "searchPlugins";
512 title: "QBT_TR(Search plugins)QBT_TR[CONTEXT=PluginSelectDlg]",
513 icon: "images/qbittorrent-tray.svg",
515 contentURL: "views/searchplugins.html",
519 paddingHorizontal: 0,
520 width: loadWindowWidth(id, 600),
521 height: loadWindowHeight(id, 360),
522 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
525 onBeforeBuild: () => {
529 clearTimeout(loadSearchPluginsTimer);
530 loadSearchPluginsTimer = -1;
536 const loadSearchPlugins = () => {
538 loadSearchPluginsTimer = loadSearchPlugins.delay(2000);
541 const onSearchPatternChanged = () => {
542 const currentSearchId = getSelectedSearchId();
543 const state = searchState.get(currentSearchId);
544 const currentSearchPattern = $("searchPattern").value.trim();
545 // start a new search if pattern has changed, otherwise allow the search to be stopped
546 if (state && (state.searchPattern === currentSearchPattern)) {
547 searchPatternChanged = false;
548 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Stop)QBT_TR[CONTEXT=SearchEngineWidget]";
551 searchPatternChanged = true;
552 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
556 const categorySelected = () => {
557 selectedCategory = $("categorySelect").value;
560 const pluginSelected = () => {
561 selectedPlugin = $("pluginsSelect").value;
563 if (selectedPlugin !== prevSelectedPlugin) {
564 prevSelectedPlugin = selectedPlugin;
565 getSearchCategories();
569 const reselectCategory = () => {
570 for (let i = 0; i < $("categorySelect").options.length; ++i) {
571 if ($("categorySelect").options[i].get("value") === selectedCategory)
572 $("categorySelect").options[i].selected = true;
578 const reselectPlugin = () => {
579 for (let i = 0; i < $("pluginsSelect").options.length; ++i) {
580 if ($("pluginsSelect").options[i].get("value") === selectedPlugin)
581 $("pluginsSelect").options[i].selected = true;
587 const resetSearchState = (searchId) => {
588 document.getElementById("startSearchButton").lastChild.textContent = "QBT_TR(Search)QBT_TR[CONTEXT=SearchEngineWidget]";
589 const state = searchState.get(searchId);
591 state.running = false;
592 clearTimeout(state.loadResultsTimer);
593 state.loadResultsTimer = -1;
597 const getSearchCategories = () => {
598 const populateCategorySelect = (categories) => {
599 const categoryOptions = [];
601 for (const category of categories) {
602 const option = document.createElement("option");
603 option.value = category.id;
604 option.textContent = category.name;
605 categoryOptions.push(option);
608 // first category is "All Categories"
609 if (categoryOptions.length > 1) {
611 const option = document.createElement("option");
612 option.disabled = true;
613 option.textContent = "──────────";
614 categoryOptions.splice(1, 0, option);
617 $("categorySelect").replaceChildren(...categoryOptions);
620 const selectedPlugin = $("pluginsSelect").value;
622 if ((selectedPlugin === "all") || (selectedPlugin === "enabled")) {
623 const uniqueCategories = {};
624 for (const plugin of searchPlugins) {
625 if ((selectedPlugin === "enabled") && !plugin.enabled)
627 for (const category of plugin.supportedCategories) {
628 if (uniqueCategories[category.id] === undefined)
629 uniqueCategories[category.id] = category;
632 // we must sort the ids to maintain consistent order.
633 const categories = Object.keys(uniqueCategories).sort().map(id => uniqueCategories[id]);
634 populateCategorySelect(categories);
637 const plugin = getPlugin(selectedPlugin);
638 const plugins = (plugin === null) ? [] : plugin.supportedCategories;
639 populateCategorySelect(plugins);
645 const getPlugins = () => {
647 url: new URI("api/v2/search/plugins"),
650 onSuccess: (response) => {
651 const createOption = (text, value, disabled = false) => {
652 const option = document.createElement("option");
653 if (value !== undefined)
654 option.value = value;
655 option.textContent = text;
656 option.disabled = disabled;
660 if (response !== prevSearchPluginsResponse) {
661 prevSearchPluginsResponse = response;
662 searchPlugins.length = 0;
663 response.forEach((plugin) => {
664 searchPlugins.push(plugin);
667 const pluginOptions = [];
668 pluginOptions.push(createOption("QBT_TR(Only enabled)QBT_TR[CONTEXT=SearchEngineWidget]", "enabled"));
669 pluginOptions.push(createOption("QBT_TR(All plugins)QBT_TR[CONTEXT=SearchEngineWidget]", "all"));
671 const searchPluginsEmpty = (searchPlugins.length === 0);
672 if (!searchPluginsEmpty) {
673 $("searchResultsNoPlugins").classList.add("invisible");
674 if (numSearchTabs() === 0)
675 $("searchResultsNoSearches").classList.remove("invisible");
677 // sort plugins alphabetically
678 const allPlugins = searchPlugins.sort((left, right) => {
679 const leftName = left.fullName;
680 const rightName = right.fullName;
681 return window.qBittorrent.Misc.naturalSortCollator.compare(leftName, rightName);
684 allPlugins.each((plugin) => {
685 if (plugin.enabled === true)
686 pluginOptions.push(createOption(plugin.fullName, plugin.name));
689 if (pluginOptions.length > 2)
690 pluginOptions.splice(2, 0, createOption("──────────", undefined, true));
693 $("pluginsSelect").replaceChildren(...pluginOptions);
695 $("searchPattern").disabled = searchPluginsEmpty;
696 $("categorySelect").disabled = searchPluginsEmpty;
697 $("pluginsSelect").disabled = searchPluginsEmpty;
698 document.getElementById("startSearchButton").disabled = searchPluginsEmpty;
700 if (window.qBittorrent.SearchPlugins !== undefined)
701 window.qBittorrent.SearchPlugins.updateTable();
709 const getPlugin = (name) => {
710 for (let i = 0; i < searchPlugins.length; ++i) {
711 if (searchPlugins[i].name === name)
712 return searchPlugins[i];
718 const resetFilters = () => {
719 searchText.filterPattern = "";
720 $("searchInNameFilter").value = "";
722 searchSeedsFilter.min = 0;
723 searchSeedsFilter.max = 0;
724 $("searchMinSeedsFilter").value = searchSeedsFilter.min;
725 $("searchMaxSeedsFilter").value = searchSeedsFilter.max;
727 searchSizeFilter.min = 0.00;
728 searchSizeFilter.minUnit = 2; // B = 0, KiB = 1, MiB = 2, GiB = 3, TiB = 4, PiB = 5, EiB = 6
729 searchSizeFilter.max = 0.00;
730 searchSizeFilter.maxUnit = 3;
731 $("searchMinSizeFilter").value = searchSizeFilter.min;
732 $("searchMinSizePrefix").value = searchSizeFilter.minUnit;
733 $("searchMaxSizeFilter").value = searchSizeFilter.max;
734 $("searchMaxSizePrefix").value = searchSizeFilter.maxUnit;
737 const getSearchInTorrentName = () => {
738 return ($("searchInTorrentName").value === "names") ? "names" : "everywhere";
741 const searchInTorrentName = () => {
742 LocalPreferences.set("search_in_filter", getSearchInTorrentName());
743 searchFilterChanged();
746 const searchSeedsFilterChanged = () => {
747 searchSeedsFilter.min = $("searchMinSeedsFilter").value;
748 searchSeedsFilter.max = $("searchMaxSeedsFilter").value;
750 searchFilterChanged();
753 const searchSizeFilterChanged = () => {
754 searchSizeFilter.min = $("searchMinSizeFilter").value;
755 searchSizeFilter.minUnit = $("searchMinSizePrefix").value;
756 searchSizeFilter.max = $("searchMaxSizeFilter").value;
757 searchSizeFilter.maxUnit = $("searchMaxSizePrefix").value;
759 searchFilterChanged();
762 const searchSizeFilterPrefixChanged = () => {
763 if ((Number($("searchMinSizeFilter").value) !== 0) || (Number($("searchMaxSizeFilter").value) !== 0))
764 searchSizeFilterChanged();
767 const searchFilterChanged = () => {
768 searchResultsTable.updateTable();
769 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
772 const setupSearchTableEvents = (enable) => {
773 const clickHandler = (e) => { downloadSearchTorrent(); };
775 $$(".searchTableRow").each((target) => {
776 target.addEventListener("dblclick", clickHandler);
780 $$(".searchTableRow").each((target) => {
781 target.removeEventListener("dblclick", clickHandler);
786 const loadSearchResultsData = function(searchId) {
787 const state = searchState.get(searchId);
789 const maxResults = 500;
790 const url = new URI("api/v2/search/results");
800 onFailure: function(response) {
801 if ((response.status === 400) || (response.status === 404)) {
802 // bad params. search id is invalid
803 resetSearchState(searchId);
804 updateStatusIconElement(searchId, "QBT_TR(An error occurred during search...)QBT_TR[CONTEXT=SearchJobWidget]", "images/error.svg");
807 clearTimeout(state.loadResultsTimer);
808 state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
811 onSuccess: function(response) {
812 $("error_div").textContent = "";
814 const state = searchState.get(searchId);
815 // check if user stopped the search prior to receiving the response
816 if (!state.running) {
817 clearTimeout(state.loadResultsTimer);
818 updateStatusIconElement(searchId, "QBT_TR(Search aborted)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-reject.svg");
823 setupSearchTableEvents(false);
825 const state = searchState.get(searchId);
828 if (response.results) {
829 const results = response.results;
830 for (let i = 0; i < results.length; ++i) {
831 const result = results[i];
834 descrLink: result.descrLink,
835 fileName: result.fileName,
836 fileSize: result.fileSize,
837 fileUrl: result.fileUrl,
838 nbLeechers: result.nbLeechers,
839 nbSeeders: result.nbSeeders,
840 engineName: result.engineName,
841 siteUrl: result.siteUrl,
842 pubDate: result.pubDate,
846 state.rows.push(row);
851 // only update table if this search is currently being displayed
852 if (searchId === getSelectedSearchId()) {
853 for (const row of newRows)
854 searchResultsTable.updateRowData(row);
856 $("numSearchResultsVisible").textContent = searchResultsTable.getFilteredAndSortedRows().length;
857 $("numSearchResultsTotal").textContent = searchResultsTable.getRowSize();
859 searchResultsTable.updateTable();
862 setupSearchTableEvents(true);
864 if ((response.status === "Stopped") && (state.rowId >= response.total)) {
865 resetSearchState(searchId);
866 updateStatusIconElement(searchId, "QBT_TR(Search has finished)QBT_TR[CONTEXT=SearchJobWidget]", "images/task-complete.svg");
871 clearTimeout(state.loadResultsTimer);
872 state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
877 const updateSearchResultsData = function(searchId) {
878 const state = searchState.get(searchId);
879 clearTimeout(state.loadResultsTimer);
880 state.loadResultsTimer = loadSearchResultsData.delay(500, this, searchId);
883 new ClipboardJS(".copySearchDataToClipboard", {
885 switch (trigger.id) {
886 case "copySearchTorrentName":
887 return copySearchTorrentName();
888 case "copySearchTorrentDownloadLink":
889 return copySearchTorrentDownloadLink();
890 case "copySearchTorrentDescriptionUrl":
891 return copySearchTorrentDescriptionUrl();
900 Object.freeze(window.qBittorrent.Search);