Display External IP Address in status bar
[qBittorrent.git] / src / webui / www / private / scripts / client.js
blob3a9ba783b10d9954cc7749fdc6ddc8caeebea551
1 /*
2  * MIT License
3  * Copyright (C) 2024  Mike Tzou (Chocobo1)
4  * Copyright (c) 2008 Ishan Arora <ishan@qbittorrent.org>,
5  * Christophe Dumez <chris@qbittorrent.org>
6  *
7  * Permission is hereby granted, free of charge, to any person obtaining a copy
8  * of this software and associated documentation files (the "Software"), to deal
9  * in the Software without restriction, including without limitation the rights
10  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11  * copies of the Software, and to permit persons to whom the Software is
12  * furnished to do so, subject to the following conditions:
13  *
14  * The above copyright notice and this permission notice shall be included in
15  * all copies or substantial portions of the Software.
16  *
17  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23  * THE SOFTWARE.
24  */
26 "use strict";
28 window.qBittorrent ??= {};
29 window.qBittorrent.Client ??= (() => {
30     const exports = () => {
31         return {
32             closeWindow: closeWindow,
33             closeWindows: closeWindows,
34             getSyncMainDataInterval: getSyncMainDataInterval,
35             isStopped: isStopped,
36             stop: stop,
37             mainTitle: mainTitle,
38             showSearchEngine: showSearchEngine,
39             showRssReader: showRssReader,
40             showLogViewer: showLogViewer,
41             isShowSearchEngine: isShowSearchEngine,
42             isShowRssReader: isShowRssReader,
43             isShowLogViewer: isShowLogViewer
44         };
45     };
47     const closeWindow = (windowID) => {
48         const window = document.getElementById(windowID);
49         if (!window)
50             return;
51         MochaUI.closeWindow(window);
52     };
54     const closeWindows = () => {
55         MochaUI.closeAll();
56     };
58     const getSyncMainDataInterval = () => {
59         return customSyncMainDataInterval ? customSyncMainDataInterval : serverSyncMainDataInterval;
60     };
62     let stopped = false;
63     const isStopped = () => {
64         return stopped;
65     };
67     const stop = () => {
68         stopped = true;
69     };
71     const mainTitle = () => {
72         const emDash = "\u2014";
73         const qbtVersion = window.qBittorrent.Cache.qbtVersion.get();
74         const suffix = window.qBittorrent.Cache.preferences.get()["app_instance_name"] || "";
75         const title = `qBittorrent ${qbtVersion} QBT_TR(WebUI)QBT_TR[CONTEXT=OptionsDialog]`
76             + ((suffix.length > 0) ? ` ${emDash} ${suffix}` : "");
77         return title;
78     };
80     let showingSearchEngine = false;
81     let showingRssReader = false;
82     let showingLogViewer = false;
84     const showSearchEngine = (bool) => {
85         showingSearchEngine = bool;
86     };
87     const showRssReader = (bool) => {
88         showingRssReader = bool;
89     };
90     const showLogViewer = (bool) => {
91         showingLogViewer = bool;
92     };
93     const isShowSearchEngine = () => {
94         return showingSearchEngine;
95     };
96     const isShowRssReader = () => {
97         return showingRssReader;
98     };
99     const isShowLogViewer = () => {
100         return showingLogViewer;
101     };
103     return exports();
104 })();
105 Object.freeze(window.qBittorrent.Client);
107 // TODO: move global functions/variables into some namespace/scope
109 this.torrentsTable = new window.qBittorrent.DynamicTable.TorrentsTable();
111 let updatePropertiesPanel = () => {};
113 this.updateMainData = () => {};
114 let alternativeSpeedLimits = false;
115 let queueing_enabled = true;
116 let serverSyncMainDataInterval = 1500;
117 let customSyncMainDataInterval = null;
118 let useSubcategories = true;
119 const useAutoHideZeroStatusFilters = LocalPreferences.get("hide_zero_status_filters", "false") === "true";
120 const displayFullURLTrackerColumn = LocalPreferences.get("full_url_tracker_column", "false") === "true";
122 /* Categories filter */
123 const CATEGORIES_ALL = 1;
124 const CATEGORIES_UNCATEGORIZED = 2;
126 const category_list = new Map();
128 let selectedCategory = Number(LocalPreferences.get("selected_category", CATEGORIES_ALL));
129 let setCategoryFilter = () => {};
131 /* Tags filter */
132 const TAGS_ALL = 1;
133 const TAGS_UNTAGGED = 2;
135 const tagList = new Map();
137 let selectedTag = Number(LocalPreferences.get("selected_tag", TAGS_ALL));
138 let setTagFilter = () => {};
140 /* Trackers filter */
141 const TRACKERS_ALL = 1;
142 const TRACKERS_TRACKERLESS = 2;
144 /** @type Map<number, {host: string, trackerTorrentMap: Map<string, string[]>}> **/
145 const trackerList = new Map();
147 let selectedTracker = Number(LocalPreferences.get("selected_tracker", TRACKERS_ALL));
148 let setTrackerFilter = () => {};
150 /* All filters */
151 let selectedStatus = LocalPreferences.get("selected_filter", "all");
152 let setStatusFilter = () => {};
153 let toggleFilterDisplay = () => {};
155 window.addEventListener("DOMContentLoaded", () => {
156     let isSearchPanelLoaded = false;
157     let isLogPanelLoaded = false;
158     let isRssPanelLoaded = false;
160     const saveColumnSizes = () => {
161         const filters_width = $("Filters").getSize().x;
162         LocalPreferences.set("filters_width", filters_width);
163         const properties_height_rel = $("propertiesPanel").getSize().y / Window.getSize().y;
164         LocalPreferences.set("properties_height_rel", properties_height_rel);
165     };
167     window.addEventListener("resize", window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
168         // only save sizes if the columns are visible
169         if (!$("mainColumn").hasClass("invisible"))
170             saveColumnSizes();
171     }));
173     /* MochaUI.Desktop = new MochaUI.Desktop();
174     MochaUI.Desktop.desktop.style.background = "#fff";
175     MochaUI.Desktop.desktop.style.visibility = "visible"; */
176     MochaUI.Desktop.initialize();
178     const buildTransfersTab = () => {
179         new MochaUI.Column({
180             id: "filtersColumn",
181             placement: "left",
182             onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
183                 saveColumnSizes();
184             }),
185             width: Number(LocalPreferences.get("filters_width", 210)),
186             resizeLimit: [1, 1000]
187         });
188         new MochaUI.Column({
189             id: "mainColumn",
190             placement: "main"
191         });
192     };
194     const buildSearchTab = () => {
195         new MochaUI.Column({
196             id: "searchTabColumn",
197             placement: "main",
198             width: null
199         });
201         // start off hidden
202         $("searchTabColumn").addClass("invisible");
203     };
205     const buildRssTab = () => {
206         new MochaUI.Column({
207             id: "rssTabColumn",
208             placement: "main",
209             width: null
210         });
212         // start off hidden
213         $("rssTabColumn").addClass("invisible");
214     };
216     const buildLogTab = () => {
217         new MochaUI.Column({
218             id: "logTabColumn",
219             placement: "main",
220             width: null
221         });
223         // start off hidden
224         $("logTabColumn").addClass("invisible");
225     };
227     buildTransfersTab();
228     buildSearchTab();
229     buildRssTab();
230     buildLogTab();
231     MochaUI.initializeTabs("mainWindowTabsList");
233     const handleFilterSelectionChange = (prevSelectedTorrent, currSelectedTorrent) => {
234         // clear properties panels when filter changes (e.g. selected torrent is no longer visible)
235         if (prevSelectedTorrent !== currSelectedTorrent) {
236             window.qBittorrent.PropGeneral.clear();
237             window.qBittorrent.PropTrackers.clear();
238             window.qBittorrent.PropPeers.clear();
239             window.qBittorrent.PropWebseeds.clear();
240             window.qBittorrent.PropFiles.clear();
241         }
242     };
244     setStatusFilter = (name) => {
245         const currentHash = torrentsTable.getCurrentTorrentID();
247         LocalPreferences.set("selected_filter", name);
248         selectedStatus = name;
249         highlightSelectedStatus();
250         updateMainData();
252         const newHash = torrentsTable.getCurrentTorrentID();
253         handleFilterSelectionChange(currentHash, newHash);
254     };
256     setCategoryFilter = (hash) => {
257         const currentHash = torrentsTable.getCurrentTorrentID();
259         LocalPreferences.set("selected_category", hash);
260         selectedCategory = Number(hash);
261         highlightSelectedCategory();
262         updateMainData();
264         const newHash = torrentsTable.getCurrentTorrentID();
265         handleFilterSelectionChange(currentHash, newHash);
266     };
268     setTagFilter = (hash) => {
269         const currentHash = torrentsTable.getCurrentTorrentID();
271         LocalPreferences.set("selected_tag", hash);
272         selectedTag = Number(hash);
273         highlightSelectedTag();
274         updateMainData();
276         const newHash = torrentsTable.getCurrentTorrentID();
277         handleFilterSelectionChange(currentHash, newHash);
278     };
280     setTrackerFilter = (hash) => {
281         const currentHash = torrentsTable.getCurrentTorrentID();
283         LocalPreferences.set("selected_tracker", hash);
284         selectedTracker = Number(hash);
285         highlightSelectedTracker();
286         updateMainData();
288         const newHash = torrentsTable.getCurrentTorrentID();
289         handleFilterSelectionChange(currentHash, newHash);
290     };
292     toggleFilterDisplay = (filterListID) => {
293         const filterList = document.getElementById(filterListID);
294         const filterTitle = filterList.previousElementSibling;
295         const toggleIcon = filterTitle.firstElementChild;
296         toggleIcon.classList.toggle("rotate");
297         LocalPreferences.set(`filter_${filterListID.replace("FilterList", "")}_collapsed`, filterList.classList.toggle("invisible").toString());
298     };
300     new MochaUI.Panel({
301         id: "Filters",
302         title: "Panel",
303         header: false,
304         padding: {
305             top: 0,
306             right: 0,
307             bottom: 0,
308             left: 0
309         },
310         loadMethod: "xhr",
311         contentURL: "views/filters.html",
312         onContentLoaded: () => {
313             highlightSelectedStatus();
314         },
315         column: "filtersColumn",
316         height: 300
317     });
318     initializeWindows();
320     // Show Top Toolbar is enabled by default
321     let showTopToolbar = LocalPreferences.get("show_top_toolbar", "true") === "true";
322     if (!showTopToolbar) {
323         $("showTopToolbarLink").firstChild.style.opacity = "0";
324         $("mochaToolbar").addClass("invisible");
325     }
327     // Show Status Bar is enabled by default
328     let showStatusBar = LocalPreferences.get("show_status_bar", "true") === "true";
329     if (!showStatusBar) {
330         $("showStatusBarLink").firstChild.style.opacity = "0";
331         $("desktopFooterWrapper").addClass("invisible");
332     }
334     // Show Filters Sidebar is enabled by default
335     let showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
336     if (!showFiltersSidebar) {
337         $("showFiltersSidebarLink").firstChild.style.opacity = "0";
338         $("filtersColumn").addClass("invisible");
339         $("filtersColumn_handle").addClass("invisible");
340     }
342     let speedInTitle = LocalPreferences.get("speed_in_browser_title_bar") === "true";
343     if (!speedInTitle)
344         $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
346     // After showing/hiding the toolbar + status bar
347     window.qBittorrent.Client.showSearchEngine(LocalPreferences.get("show_search_engine") !== "false");
348     window.qBittorrent.Client.showRssReader(LocalPreferences.get("show_rss_reader") !== "false");
349     window.qBittorrent.Client.showLogViewer(LocalPreferences.get("show_log_viewer") === "true");
351     // After Show Top Toolbar
352     MochaUI.Desktop.setDesktopSize();
354     let syncMainDataLastResponseId = 0;
355     const serverState = {};
357     const removeTorrentFromCategoryList = (hash) => {
358         if (!hash)
359             return false;
361         let removed = false;
362         category_list.forEach((category) => {
363             const deleteResult = category.torrents.delete(hash);
364             removed ||= deleteResult;
365         });
367         return removed;
368     };
370     const addTorrentToCategoryList = (torrent) => {
371         const category = torrent["category"];
372         if (typeof category === "undefined")
373             return false;
375         const hash = torrent["hash"];
376         if (category.length === 0) { // Empty category
377             removeTorrentFromCategoryList(hash);
378             return true;
379         }
381         const categoryHash = window.qBittorrent.Misc.genHash(category);
382         if (!category_list.has(categoryHash)) { // This should not happen
383             category_list.set(categoryHash, {
384                 name: category,
385                 torrents: new Set()
386             });
387         }
389         const torrents = category_list.get(categoryHash).torrents;
390         if (!torrents.has(hash)) {
391             removeTorrentFromCategoryList(hash);
392             torrents.add(hash);
393             return true;
394         }
395         return false;
396     };
398     const removeTorrentFromTagList = (hash) => {
399         if (!hash)
400             return false;
402         let removed = false;
403         tagList.forEach((tag) => {
404             const deleteResult = tag.torrents.delete(hash);
405             removed ||= deleteResult;
406         });
408         return removed;
409     };
411     const addTorrentToTagList = (torrent) => {
412         if (torrent["tags"] === undefined) // Tags haven't changed
413             return false;
415         const hash = torrent["hash"];
416         removeTorrentFromTagList(hash);
418         if (torrent["tags"].length === 0) // No tags
419             return true;
421         const tags = torrent["tags"].split(",");
422         let added = false;
423         for (let i = 0; i < tags.length; ++i) {
424             const tagHash = window.qBittorrent.Misc.genHash(tags[i].trim());
425             if (!tagList.has(tagHash)) { // This should not happen
426                 tagList.set(tagHash, {
427                     name: tags,
428                     torrents: new Set()
429                 });
430             }
432             const torrents = tagList.get(tagHash).torrents;
433             if (!torrents.has(hash)) {
434                 torrents.add(hash);
435                 added = true;
436             }
437         }
438         return added;
439     };
441     const updateFilter = (filter, filterTitle) => {
442         const filterEl = document.getElementById(`${filter}_filter`);
443         const filterTorrentCount = torrentsTable.getFilteredTorrentsNumber(filter, CATEGORIES_ALL, TAGS_ALL, TRACKERS_ALL);
444         if (useAutoHideZeroStatusFilters) {
445             const hideFilter = (filterTorrentCount === 0) && (filter !== "all");
446             if (filterEl.classList.toggle("invisible", hideFilter))
447                 return;
448         }
449         filterEl.firstElementChild.lastChild.nodeValue = filterTitle.replace("%1", filterTorrentCount);
450     };
452     const updateFiltersList = () => {
453         updateFilter("all", "QBT_TR(All (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
454         updateFilter("downloading", "QBT_TR(Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
455         updateFilter("seeding", "QBT_TR(Seeding (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
456         updateFilter("completed", "QBT_TR(Completed (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
457         updateFilter("running", "QBT_TR(Running (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
458         updateFilter("stopped", "QBT_TR(Stopped (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
459         updateFilter("active", "QBT_TR(Active (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
460         updateFilter("inactive", "QBT_TR(Inactive (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
461         updateFilter("stalled", "QBT_TR(Stalled (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
462         updateFilter("stalled_uploading", "QBT_TR(Stalled Uploading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
463         updateFilter("stalled_downloading", "QBT_TR(Stalled Downloading (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
464         updateFilter("checking", "QBT_TR(Checking (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
465         updateFilter("moving", "QBT_TR(Moving (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
466         updateFilter("errored", "QBT_TR(Errored (%1))QBT_TR[CONTEXT=StatusFilterWidget]");
467     };
469     const highlightSelectedStatus = () => {
470         const statusFilter = document.getElementById("statusFilterList");
471         const filterID = `${selectedStatus}_filter`;
472         for (const status of statusFilter.children)
473             status.classList.toggle("selectedFilter", (status.id === filterID));
474     };
476     const updateCategoryList = () => {
477         const categoryList = document.getElementById("categoryFilterList");
478         if (!categoryList)
479             return;
480         categoryList.getChildren().each(c => c.destroy());
482         const categoryItemTemplate = document.getElementById("categoryFilterItem");
484         const createCategoryLink = (hash, name, count) => {
485             const categoryFilterItem = categoryItemTemplate.content.cloneNode(true).firstElementChild;
486             categoryFilterItem.id = hash;
487             categoryFilterItem.classList.toggle("selectedFilter", hash === selectedCategory);
489             const span = categoryFilterItem.firstElementChild;
490             span.lastElementChild.textContent = `${name} (${count})`;
492             return categoryFilterItem;
493         };
495         const createCategoryTree = (category) => {
496             const stack = [{ parent: categoriesFragment, category: category }];
497             while (stack.length > 0) {
498                 const { parent, category } = stack.pop();
499                 const displayName = category.nameSegments.at(-1);
500                 const listItem = createCategoryLink(category.categoryHash, displayName, category.categoryCount);
501                 listItem.firstElementChild.style.paddingLeft = `${(category.nameSegments.length - 1) * 20 + 6}px`;
503                 parent.appendChild(listItem);
505                 if (category.children.length > 0) {
506                     listItem.querySelector(".categoryToggle").style.visibility = "visible";
507                     const unorderedList = document.createElement("ul");
508                     listItem.appendChild(unorderedList);
509                     for (const subcategory of category.children.reverse())
510                         stack.push({ parent: unorderedList, category: subcategory });
511                 }
512                 const categoryLocalPref = `category_${category.categoryHash}_collapsed`;
513                 const isCollapsed = !category.forceExpand && (LocalPreferences.get(categoryLocalPref, "false") === "true");
514                 LocalPreferences.set(categoryLocalPref, listItem.classList.toggle("collapsedCategory", isCollapsed).toString());
515             }
516         };
518         let uncategorized = 0;
519         for (const { full_data: { category } } of torrentsTable.getRowValues()) {
520             if (category.length === 0)
521                 uncategorized += 1;
522         }
524         const sortedCategories = [];
525         category_list.forEach((category, hash) => sortedCategories.push({
526             categoryName: category.name,
527             categoryHash: hash,
528             categoryCount: category.torrents.size,
529             nameSegments: category.name.split("/"),
530             ...(useSubcategories && {
531                 children: [],
532                 parentID: null,
533                 forceExpand: LocalPreferences.get(`category_${hash}_collapsed`) === null
534             })
535         }));
536         sortedCategories.sort((left, right) => {
537             const leftSegments = left.nameSegments;
538             const rightSegments = right.nameSegments;
540             for (let i = 0, iMax = Math.min(leftSegments.length, rightSegments.length); i < iMax; ++i) {
541                 const compareResult = window.qBittorrent.Misc.naturalSortCollator.compare(
542                     leftSegments[i], rightSegments[i]);
543                 if (compareResult !== 0)
544                     return compareResult;
545             }
547             return leftSegments.length - rightSegments.length;
548         });
550         const categoriesFragment = new DocumentFragment();
551         categoriesFragment.appendChild(createCategoryLink(CATEGORIES_ALL, "QBT_TR(All)QBT_TR[CONTEXT=CategoryFilterModel]", torrentsTable.getRowSize()));
552         categoriesFragment.appendChild(createCategoryLink(CATEGORIES_UNCATEGORIZED, "QBT_TR(Uncategorized)QBT_TR[CONTEXT=CategoryFilterModel]", uncategorized));
554         if (useSubcategories) {
555             categoryList.classList.add("subcategories");
556             for (let i = 0; i < sortedCategories.length; ++i) {
557                 const category = sortedCategories[i];
558                 for (let j = (i + 1);
559                     ((j < sortedCategories.length) && sortedCategories[j].categoryName.startsWith(`${category.categoryName}/`)); ++j) {
560                     const subcategory = sortedCategories[j];
561                     category.categoryCount += subcategory.categoryCount;
562                     category.forceExpand ||= subcategory.forceExpand;
564                     const isDirectSubcategory = (subcategory.nameSegments.length - category.nameSegments.length) === 1;
565                     if (isDirectSubcategory) {
566                         subcategory.parentID = category.categoryHash;
567                         category.children.push(subcategory);
568                     }
569                 }
570             }
571             for (const category of sortedCategories) {
572                 if (category.parentID === null)
573                     createCategoryTree(category);
574             }
575         }
576         else {
577             categoryList.classList.remove("subcategories");
578             for (const { categoryHash, categoryName, categoryCount } of sortedCategories)
579                 categoriesFragment.appendChild(createCategoryLink(categoryHash, categoryName, categoryCount));
580         }
582         categoryList.appendChild(categoriesFragment);
583         window.qBittorrent.Filters.categoriesFilterContextMenu.searchAndAddTargets();
584     };
586     const highlightSelectedCategory = () => {
587         const categoryList = document.getElementById("categoryFilterList");
588         if (!categoryList)
589             return;
591         for (const category of categoryList.getElementsByTagName("li"))
592             category.classList.toggle("selectedFilter", (Number(category.id) === selectedCategory));
593     };
595     const updateTagList = () => {
596         const tagFilterList = $("tagFilterList");
597         if (tagFilterList === null)
598             return;
600         tagFilterList.getChildren().each(c => c.destroy());
602         const tagItemTemplate = document.getElementById("tagFilterItem");
604         const createLink = (hash, text, count) => {
605             const tagFilterItem = tagItemTemplate.content.cloneNode(true).firstElementChild;
606             tagFilterItem.id = hash;
607             tagFilterItem.classList.toggle("selectedFilter", hash === selectedTag);
609             const span = tagFilterItem.firstElementChild;
610             span.lastChild.textContent = `${text} (${count})`;
612             return tagFilterItem;
613         };
615         let untagged = 0;
616         for (const { full_data: { tags } } of torrentsTable.getRowValues()) {
617             if (tags.length === 0)
618                 untagged += 1;
619         }
621         tagFilterList.appendChild(createLink(TAGS_ALL, "QBT_TR(All)QBT_TR[CONTEXT=TagFilterModel]", torrentsTable.getRowSize()));
622         tagFilterList.appendChild(createLink(TAGS_UNTAGGED, "QBT_TR(Untagged)QBT_TR[CONTEXT=TagFilterModel]", untagged));
624         const sortedTags = [];
625         tagList.forEach((tag, hash) => sortedTags.push({
626             tagName: tag.name,
627             tagHash: hash,
628             tagSize: tag.torrents.size
629         }));
630         sortedTags.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.tagName, right.tagName));
632         for (const { tagName, tagHash, tagSize } of sortedTags)
633             tagFilterList.appendChild(createLink(tagHash, tagName, tagSize));
635         window.qBittorrent.Filters.tagsFilterContextMenu.searchAndAddTargets();
636     };
638     const highlightSelectedTag = () => {
639         const tagFilterList = document.getElementById("tagFilterList");
640         if (!tagFilterList)
641             return;
643         for (const tag of tagFilterList.children)
644             tag.classList.toggle("selectedFilter", (Number(tag.id) === selectedTag));
645     };
647     const updateTrackerList = () => {
648         const trackerFilterList = $("trackerFilterList");
649         if (trackerFilterList === null)
650             return;
652         trackerFilterList.getChildren().each(c => c.destroy());
654         const trackerItemTemplate = document.getElementById("trackerFilterItem");
656         const createLink = (hash, text, count) => {
657             const trackerFilterItem = trackerItemTemplate.content.cloneNode(true).firstElementChild;
658             trackerFilterItem.id = hash;
659             trackerFilterItem.classList.toggle("selectedFilter", hash === selectedTracker);
661             const span = trackerFilterItem.firstElementChild;
662             span.lastChild.textContent = text.replace("%1", count);
664             return trackerFilterItem;
665         };
667         let trackerlessTorrentsCount = 0;
668         for (const { full_data: { trackers_count: trackersCount } } of torrentsTable.getRowValues()) {
669             if (trackersCount === 0)
670                 trackerlessTorrentsCount += 1;
671         }
673         trackerFilterList.appendChild(createLink(TRACKERS_ALL, "QBT_TR(All (%1))QBT_TR[CONTEXT=TrackerFiltersList]", torrentsTable.getRowSize()));
674         trackerFilterList.appendChild(createLink(TRACKERS_TRACKERLESS, "QBT_TR(Trackerless (%1))QBT_TR[CONTEXT=TrackerFiltersList]", trackerlessTorrentsCount));
676         // Sort trackers by hostname
677         const sortedList = [];
678         trackerList.forEach(({ host, trackerTorrentMap }, hash) => {
679             const uniqueTorrents = new Set();
680             for (const torrents of trackerTorrentMap.values()) {
681                 for (const torrent of torrents)
682                     uniqueTorrents.add(torrent);
683             }
685             sortedList.push({
686                 trackerHost: host,
687                 trackerHash: hash,
688                 trackerCount: uniqueTorrents.size,
689             });
690         });
691         sortedList.sort((left, right) => window.qBittorrent.Misc.naturalSortCollator.compare(left.trackerHost, right.trackerHost));
692         for (const { trackerHost, trackerHash, trackerCount } of sortedList)
693             trackerFilterList.appendChild(createLink(trackerHash, (trackerHost + " (%1)"), trackerCount));
695         window.qBittorrent.Filters.trackersFilterContextMenu.searchAndAddTargets();
696     };
698     const highlightSelectedTracker = () => {
699         const trackerFilterList = document.getElementById("trackerFilterList");
700         if (!trackerFilterList)
701             return;
703         for (const tracker of trackerFilterList.children)
704             tracker.classList.toggle("selectedFilter", (Number(tracker.id) === selectedTracker));
705     };
707     const statusSortOrder = Object.freeze({
708         "unknown": -1,
709         "forcedDL": 0,
710         "downloading": 1,
711         "forcedMetaDL": 2,
712         "metaDL": 3,
713         "stalledDL": 4,
714         "forcedUP": 5,
715         "uploading": 6,
716         "stalledUP": 7,
717         "checkingResumeData": 8,
718         "queuedDL": 9,
719         "queuedUP": 10,
720         "checkingUP": 11,
721         "checkingDL": 12,
722         "stoppedDL": 13,
723         "stoppedUP": 14,
724         "moving": 15,
725         "missingFiles": 16,
726         "error": 17
727     });
729     let syncMainDataTimeoutID = -1;
730     let syncRequestInProgress = false;
731     const syncMainData = () => {
732         const url = new URI("api/v2/sync/maindata");
733         url.setData("rid", syncMainDataLastResponseId);
734         const request = new Request.JSON({
735             url: url,
736             noCache: true,
737             method: "get",
738             onFailure: () => {
739                 const errorDiv = $("error_div");
740                 if (errorDiv)
741                     errorDiv.textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
742                 syncRequestInProgress = false;
743                 syncData(2000);
744             },
745             onSuccess: (response) => {
746                 $("error_div").textContent = "";
747                 if (response) {
748                     clearTimeout(torrentsFilterInputTimer);
749                     torrentsFilterInputTimer = -1;
751                     let torrentsTableSelectedRows;
752                     let update_categories = false;
753                     let updateTags = false;
754                     let updateTrackers = false;
755                     let updateTorrents = false;
756                     const full_update = (response["full_update"] === true);
757                     if (full_update) {
758                         torrentsTableSelectedRows = torrentsTable.selectedRowsIds();
759                         update_categories = true;
760                         updateTags = true;
761                         updateTrackers = true;
762                         updateTorrents = true;
763                         torrentsTable.clear();
764                         category_list.clear();
765                         tagList.clear();
766                         trackerList.clear();
767                     }
768                     if (response["rid"])
769                         syncMainDataLastResponseId = response["rid"];
770                     if (response["categories"]) {
771                         for (const key in response["categories"]) {
772                             if (!Object.hasOwn(response["categories"], key))
773                                 continue;
775                             const responseCategory = response["categories"][key];
776                             const categoryHash = window.qBittorrent.Misc.genHash(key);
777                             const category = category_list.get(categoryHash);
778                             if (category !== undefined) {
779                                 // only the save path can change for existing categories
780                                 category.savePath = responseCategory.savePath;
781                             }
782                             else {
783                                 category_list.set(categoryHash, {
784                                     name: responseCategory.name,
785                                     savePath: responseCategory.savePath,
786                                     torrents: new Set()
787                                 });
788                             }
789                         }
790                         update_categories = true;
791                     }
792                     if (response["categories_removed"]) {
793                         response["categories_removed"].each((category) => {
794                             const categoryHash = window.qBittorrent.Misc.genHash(category);
795                             category_list.delete(categoryHash);
796                         });
797                         update_categories = true;
798                     }
799                     if (response["tags"]) {
800                         for (const tag of response["tags"]) {
801                             const tagHash = window.qBittorrent.Misc.genHash(tag);
802                             if (!tagList.has(tagHash)) {
803                                 tagList.set(tagHash, {
804                                     name: tag,
805                                     torrents: new Set()
806                                 });
807                             }
808                         }
809                         updateTags = true;
810                     }
811                     if (response["tags_removed"]) {
812                         for (let i = 0; i < response["tags_removed"].length; ++i) {
813                             const tagHash = window.qBittorrent.Misc.genHash(response["tags_removed"][i]);
814                             tagList.delete(tagHash);
815                         }
816                         updateTags = true;
817                     }
818                     if (response["trackers"]) {
819                         for (const [tracker, torrents] of Object.entries(response["trackers"])) {
820                             const host = window.qBittorrent.Misc.getHost(tracker);
821                             const hash = window.qBittorrent.Misc.genHash(host);
823                             let trackerListItem = trackerList.get(hash);
824                             if (trackerListItem === undefined) {
825                                 trackerListItem = { host: host, trackerTorrentMap: new Map() };
826                                 trackerList.set(hash, trackerListItem);
827                             }
828                             trackerListItem.trackerTorrentMap.set(tracker, new Set(torrents));
829                         }
830                         updateTrackers = true;
831                     }
832                     if (response["trackers_removed"]) {
833                         for (let i = 0; i < response["trackers_removed"].length; ++i) {
834                             const tracker = response["trackers_removed"][i];
835                             const host = window.qBittorrent.Misc.getHost(tracker);
836                             const hash = window.qBittorrent.Misc.genHash(host);
837                             const trackerListEntry = trackerList.get(hash);
838                             if (trackerListEntry) {
839                                 trackerListEntry.trackerTorrentMap.delete(tracker);
840                                 // Remove unused trackers
841                                 if (trackerListEntry.trackerTorrentMap.size === 0) {
842                                     trackerList.delete(hash);
843                                     if (selectedTracker === hash) {
844                                         selectedTracker = TRACKERS_ALL;
845                                         LocalPreferences.set("selected_tracker", selectedTracker.toString());
846                                     }
847                                 }
848                             }
849                         }
850                         updateTrackers = true;
851                     }
852                     if (response["torrents"]) {
853                         for (const key in response["torrents"]) {
854                             if (!Object.hasOwn(response["torrents"], key))
855                                 continue;
857                             response["torrents"][key]["hash"] = key;
858                             response["torrents"][key]["rowId"] = key;
859                             if (response["torrents"][key]["state"]) {
860                                 const state = response["torrents"][key]["state"];
861                                 response["torrents"][key]["status"] = state;
862                                 response["torrents"][key]["_statusOrder"] = statusSortOrder[state];
863                             }
864                             torrentsTable.updateRowData(response["torrents"][key]);
865                             if (addTorrentToCategoryList(response["torrents"][key]))
866                                 update_categories = true;
867                             if (addTorrentToTagList(response["torrents"][key]))
868                                 updateTags = true;
869                             updateTorrents = true;
870                         }
871                     }
872                     if (response["torrents_removed"]) {
873                         response["torrents_removed"].each((hash) => {
874                             torrentsTable.removeRow(hash);
875                             removeTorrentFromCategoryList(hash);
876                             update_categories = true; // Always to update All category
877                             removeTorrentFromTagList(hash);
878                             updateTags = true; // Always to update All tag
879                         });
880                         updateTorrents = true;
881                     }
883                     // don't update the table unnecessarily
884                     if (updateTorrents)
885                         torrentsTable.updateTable(full_update);
887                     if (response["server_state"]) {
888                         const tmp = response["server_state"];
889                         for (const k in tmp) {
890                             if (!Object.hasOwn(tmp, k))
891                                 continue;
892                             serverState[k] = tmp[k];
893                         }
894                         processServerState();
895                     }
896                     updateFiltersList();
897                     if (update_categories) {
898                         updateCategoryList();
899                         window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(category_list);
900                     }
901                     if (updateTags) {
902                         updateTagList();
903                         window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagList);
904                     }
905                     if (updateTrackers)
906                         updateTrackerList();
908                     if (full_update)
909                         // re-select previously selected rows
910                         torrentsTable.reselectRows(torrentsTableSelectedRows);
911                 }
912                 syncRequestInProgress = false;
913                 syncData(window.qBittorrent.Client.getSyncMainDataInterval());
914             }
915         });
916         syncRequestInProgress = true;
917         request.send();
918     };
920     updateMainData = () => {
921         torrentsTable.updateTable();
922         syncData(100);
923     };
925     const syncData = (delay) => {
926         if (syncRequestInProgress)
927             return;
929         clearTimeout(syncMainDataTimeoutID);
930         syncMainDataTimeoutID = -1;
932         if (window.qBittorrent.Client.isStopped())
933             return;
935         syncMainDataTimeoutID = syncMainData.delay(delay);
936     };
938     const processServerState = () => {
939         let transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true);
940         if (serverState.dl_rate_limit > 0)
941             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_rate_limit, true) + "]";
942         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_data, false) + ")";
943         $("DlInfos").textContent = transfer_info;
944         transfer_info = window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true);
945         if (serverState.up_rate_limit > 0)
946             transfer_info += " [" + window.qBittorrent.Misc.friendlyUnit(serverState.up_rate_limit, true) + "]";
947         transfer_info += " (" + window.qBittorrent.Misc.friendlyUnit(serverState.up_info_data, false) + ")";
948         $("UpInfos").textContent = transfer_info;
950         document.title = (speedInTitle
951                 ? (`QBT_TR([D: %1, U: %2])QBT_TR[CONTEXT=MainWindow] `
952                     .replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.dl_info_speed, true))
953                     .replace("%2", window.qBittorrent.Misc.friendlyUnit(serverState.up_info_speed, true)))
954                 : "")
955             + window.qBittorrent.Client.mainTitle();
957         $("freeSpaceOnDisk").textContent = "QBT_TR(Free space: %1)QBT_TR[CONTEXT=HttpServer]".replace("%1", window.qBittorrent.Misc.friendlyUnit(serverState.free_space_on_disk));
959         const externalIPsElement = document.getElementById("externalIPs");
960         if (window.qBittorrent.Cache.preferences.get().status_bar_external_ip) {
961             const lastExternalAddressV4 = serverState.last_external_address_v4;
962             const lastExternalAddressV6 = serverState.last_external_address_v6;
963             const hasIPv4Address = lastExternalAddressV4 !== "";
964             const hasIPv6Address = lastExternalAddressV6 !== "";
965             let lastExternalAddressLabel = "QBT_TR(External IP: N/A)QBT_TR[CONTEXT=HttpServer]";
966             if (hasIPv4Address && hasIPv6Address)
967                 lastExternalAddressLabel = "QBT_TR(External IPs: %1, %2)QBT_TR[CONTEXT=HttpServer]";
968             else if (hasIPv4Address || hasIPv6Address)
969                 lastExternalAddressLabel = "QBT_TR(External IP: %1%2)QBT_TR[CONTEXT=HttpServer]";
970             // replace in reverse order ('%2' before '%1') in case address contains a % character.
971             // for example, see https://en.wikipedia.org/wiki/IPv6_address#Scoped_literal_IPv6_addresses_(with_zone_index)
972             externalIPsElement.textContent = lastExternalAddressLabel.replace("%2", lastExternalAddressV6).replace("%1", lastExternalAddressV4);
973             externalIPsElement.classList.remove("invisible");
974             externalIPsElement.previousElementSibling.classList.remove("invisible");
975         }
976         else {
977             externalIPsElement.classList.add("invisible");
978             externalIPsElement.previousElementSibling.classList.add("invisible");
979         }
981         const dhtElement = document.getElementById("DHTNodes");
982         if (window.qBittorrent.Cache.preferences.get().dht) {
983             dhtElement.textContent = "QBT_TR(DHT: %1 nodes)QBT_TR[CONTEXT=StatusBar]".replace("%1", serverState.dht_nodes);
984             dhtElement.classList.remove("invisible");
985             dhtElement.previousElementSibling.classList.remove("invisible");
986         }
987         else {
988             dhtElement.classList.add("invisible");
989             dhtElement.previousElementSibling.classList.add("invisible");
990         }
992         // Statistics dialog
993         if (document.getElementById("statisticsContent")) {
994             $("AlltimeDL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_dl, false);
995             $("AlltimeUL").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.alltime_ul, false);
996             $("TotalWastedSession").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_wasted_session, false);
997             $("GlobalRatio").textContent = serverState.global_ratio;
998             $("TotalPeerConnections").textContent = serverState.total_peer_connections;
999             $("ReadCacheHits").textContent = serverState.read_cache_hits + "%";
1000             $("TotalBuffersSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_buffers_size, false);
1001             $("WriteCacheOverload").textContent = serverState.write_cache_overload + "%";
1002             $("ReadCacheOverload").textContent = serverState.read_cache_overload + "%";
1003             $("QueuedIOJobs").textContent = serverState.queued_io_jobs;
1004             $("AverageTimeInQueue").textContent = serverState.average_time_queue + " ms";
1005             $("TotalQueuedSize").textContent = window.qBittorrent.Misc.friendlyUnit(serverState.total_queued_size, false);
1006         }
1008         switch (serverState.connection_status) {
1009             case "connected":
1010                 $("connectionStatus").src = "images/connected.svg";
1011                 $("connectionStatus").alt = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
1012                 $("connectionStatus").title = "QBT_TR(Connection status: Connected)QBT_TR[CONTEXT=MainWindow]";
1013                 break;
1014             case "firewalled":
1015                 $("connectionStatus").src = "images/firewalled.svg";
1016                 $("connectionStatus").alt = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
1017                 $("connectionStatus").title = "QBT_TR(Connection status: Firewalled)QBT_TR[CONTEXT=MainWindow]";
1018                 break;
1019             default:
1020                 $("connectionStatus").src = "images/disconnected.svg";
1021                 $("connectionStatus").alt = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
1022                 $("connectionStatus").title = "QBT_TR(Connection status: Disconnected)QBT_TR[CONTEXT=MainWindow]";
1023                 break;
1024         }
1026         if (queueing_enabled !== serverState.queueing) {
1027             queueing_enabled = serverState.queueing;
1028             torrentsTable.columns["priority"].force_hide = !queueing_enabled;
1029             torrentsTable.updateColumn("priority");
1030             if (queueing_enabled) {
1031                 $("topQueuePosItem").removeClass("invisible");
1032                 $("increaseQueuePosItem").removeClass("invisible");
1033                 $("decreaseQueuePosItem").removeClass("invisible");
1034                 $("bottomQueuePosItem").removeClass("invisible");
1035                 $("queueingButtons").removeClass("invisible");
1036                 $("queueingMenuItems").removeClass("invisible");
1037             }
1038             else {
1039                 $("topQueuePosItem").addClass("invisible");
1040                 $("increaseQueuePosItem").addClass("invisible");
1041                 $("decreaseQueuePosItem").addClass("invisible");
1042                 $("bottomQueuePosItem").addClass("invisible");
1043                 $("queueingButtons").addClass("invisible");
1044                 $("queueingMenuItems").addClass("invisible");
1045             }
1046         }
1048         if (alternativeSpeedLimits !== serverState.use_alt_speed_limits) {
1049             alternativeSpeedLimits = serverState.use_alt_speed_limits;
1050             updateAltSpeedIcon(alternativeSpeedLimits);
1051         }
1053         if (useSubcategories !== serverState.use_subcategories) {
1054             useSubcategories = serverState.use_subcategories;
1055             updateCategoryList();
1056         }
1058         serverSyncMainDataInterval = Math.max(serverState.refresh_interval, 500);
1059     };
1061     const updateAltSpeedIcon = (enabled) => {
1062         if (enabled) {
1063             $("alternativeSpeedLimits").src = "images/slow.svg";
1064             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1065             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: On)QBT_TR[CONTEXT=MainWindow]";
1066         }
1067         else {
1068             $("alternativeSpeedLimits").src = "images/slow_off.svg";
1069             $("alternativeSpeedLimits").alt = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1070             $("alternativeSpeedLimits").title = "QBT_TR(Alternative speed limits: Off)QBT_TR[CONTEXT=MainWindow]";
1071         }
1072     };
1074     $("alternativeSpeedLimits").addEventListener("click", () => {
1075         // Change icon immediately to give some feedback
1076         updateAltSpeedIcon(!alternativeSpeedLimits);
1078         new Request({
1079             url: "api/v2/transfer/toggleSpeedLimitsMode",
1080             method: "post",
1081             onComplete: () => {
1082                 alternativeSpeedLimits = !alternativeSpeedLimits;
1083                 updateMainData();
1084             },
1085             onFailure: () => {
1086                 // Restore icon in case of failure
1087                 updateAltSpeedIcon(alternativeSpeedLimits);
1088             }
1089         }).send();
1090     });
1092     $("DlInfos").addEventListener("click", () => { globalDownloadLimitFN(); });
1093     $("UpInfos").addEventListener("click", () => { globalUploadLimitFN(); });
1095     $("showTopToolbarLink").addEventListener("click", (e) => {
1096         showTopToolbar = !showTopToolbar;
1097         LocalPreferences.set("show_top_toolbar", showTopToolbar.toString());
1098         if (showTopToolbar) {
1099             $("showTopToolbarLink").firstChild.style.opacity = "1";
1100             $("mochaToolbar").removeClass("invisible");
1101         }
1102         else {
1103             $("showTopToolbarLink").firstChild.style.opacity = "0";
1104             $("mochaToolbar").addClass("invisible");
1105         }
1106         MochaUI.Desktop.setDesktopSize();
1107     });
1109     $("showStatusBarLink").addEventListener("click", (e) => {
1110         showStatusBar = !showStatusBar;
1111         LocalPreferences.set("show_status_bar", showStatusBar.toString());
1112         if (showStatusBar) {
1113             $("showStatusBarLink").firstChild.style.opacity = "1";
1114             $("desktopFooterWrapper").removeClass("invisible");
1115         }
1116         else {
1117             $("showStatusBarLink").firstChild.style.opacity = "0";
1118             $("desktopFooterWrapper").addClass("invisible");
1119         }
1120         MochaUI.Desktop.setDesktopSize();
1121     });
1123     const registerMagnetHandler = () => {
1124         if (typeof navigator.registerProtocolHandler !== "function") {
1125             if (window.location.protocol !== "https:")
1126                 alert("QBT_TR(To use this feature, the WebUI needs to be accessed over HTTPS)QBT_TR[CONTEXT=MainWindow]");
1127             else
1128                 alert("QBT_TR(Your browser does not support this feature)QBT_TR[CONTEXT=MainWindow]");
1129             return;
1130         }
1132         const hashString = location.hash ? location.hash.replace(/^#/, "") : "";
1133         const hashParams = new URLSearchParams(hashString);
1134         hashParams.set("download", "");
1136         const templateHashString = hashParams.toString().replace("download=", "download=%s");
1137         const templateUrl = location.origin + location.pathname
1138             + location.search + "#" + templateHashString;
1140         navigator.registerProtocolHandler("magnet", templateUrl,
1141             "qBittorrent WebUI magnet handler");
1142     };
1143     $("registerMagnetHandlerLink").addEventListener("click", (e) => {
1144         registerMagnetHandler();
1145     });
1147     $("showFiltersSidebarLink").addEventListener("click", (e) => {
1148         showFiltersSidebar = !showFiltersSidebar;
1149         LocalPreferences.set("show_filters_sidebar", showFiltersSidebar.toString());
1150         if (showFiltersSidebar) {
1151             $("showFiltersSidebarLink").firstChild.style.opacity = "1";
1152             $("filtersColumn").removeClass("invisible");
1153             $("filtersColumn_handle").removeClass("invisible");
1154         }
1155         else {
1156             $("showFiltersSidebarLink").firstChild.style.opacity = "0";
1157             $("filtersColumn").addClass("invisible");
1158             $("filtersColumn_handle").addClass("invisible");
1159         }
1160         MochaUI.Desktop.setDesktopSize();
1161     });
1163     $("speedInBrowserTitleBarLink").addEventListener("click", (e) => {
1164         speedInTitle = !speedInTitle;
1165         LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString());
1166         if (speedInTitle)
1167             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "1";
1168         else
1169             $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
1170         processServerState();
1171     });
1173     $("showSearchEngineLink").addEventListener("click", (e) => {
1174         window.qBittorrent.Client.showSearchEngine(!window.qBittorrent.Client.isShowSearchEngine());
1175         LocalPreferences.set("show_search_engine", window.qBittorrent.Client.isShowSearchEngine().toString());
1176         updateTabDisplay();
1177     });
1179     $("showRssReaderLink").addEventListener("click", (e) => {
1180         window.qBittorrent.Client.showRssReader(!window.qBittorrent.Client.isShowRssReader());
1181         LocalPreferences.set("show_rss_reader", window.qBittorrent.Client.isShowRssReader().toString());
1182         updateTabDisplay();
1183     });
1185     $("showLogViewerLink").addEventListener("click", (e) => {
1186         window.qBittorrent.Client.showLogViewer(!window.qBittorrent.Client.isShowLogViewer());
1187         LocalPreferences.set("show_log_viewer", window.qBittorrent.Client.isShowLogViewer().toString());
1188         updateTabDisplay();
1189     });
1191     const updateTabDisplay = () => {
1192         if (window.qBittorrent.Client.isShowRssReader()) {
1193             $("showRssReaderLink").firstChild.style.opacity = "1";
1194             $("mainWindowTabs").removeClass("invisible");
1195             $("rssTabLink").removeClass("invisible");
1196             if (!MochaUI.Panels.instances.RssPanel)
1197                 addRssPanel();
1198         }
1199         else {
1200             $("showRssReaderLink").firstChild.style.opacity = "0";
1201             $("rssTabLink").addClass("invisible");
1202             if ($("rssTabLink").hasClass("selected"))
1203                 $("transfersTabLink").click();
1204         }
1206         if (window.qBittorrent.Client.isShowSearchEngine()) {
1207             $("showSearchEngineLink").firstChild.style.opacity = "1";
1208             $("mainWindowTabs").removeClass("invisible");
1209             $("searchTabLink").removeClass("invisible");
1210             if (!MochaUI.Panels.instances.SearchPanel)
1211                 addSearchPanel();
1212         }
1213         else {
1214             $("showSearchEngineLink").firstChild.style.opacity = "0";
1215             $("searchTabLink").addClass("invisible");
1216             if ($("searchTabLink").hasClass("selected"))
1217                 $("transfersTabLink").click();
1218         }
1220         if (window.qBittorrent.Client.isShowLogViewer()) {
1221             $("showLogViewerLink").firstChild.style.opacity = "1";
1222             $("mainWindowTabs").removeClass("invisible");
1223             $("logTabLink").removeClass("invisible");
1224             if (!MochaUI.Panels.instances.LogPanel)
1225                 addLogPanel();
1226         }
1227         else {
1228             $("showLogViewerLink").firstChild.style.opacity = "0";
1229             $("logTabLink").addClass("invisible");
1230             if ($("logTabLink").hasClass("selected"))
1231                 $("transfersTabLink").click();
1232         }
1234         // display no tabs
1235         if (!window.qBittorrent.Client.isShowRssReader() && !window.qBittorrent.Client.isShowSearchEngine() && !window.qBittorrent.Client.isShowLogViewer())
1236             $("mainWindowTabs").addClass("invisible");
1237     };
1239     $("StatisticsLink").addEventListener("click", () => { StatisticsLinkFN(); });
1241     // main window tabs
1243     const showTransfersTab = () => {
1244         const showFiltersSidebar = LocalPreferences.get("show_filters_sidebar", "true") === "true";
1245         if (showFiltersSidebar) {
1246             $("filtersColumn").removeClass("invisible");
1247             $("filtersColumn_handle").removeClass("invisible");
1248         }
1249         $("mainColumn").removeClass("invisible");
1250         $("torrentsFilterToolbar").removeClass("invisible");
1252         customSyncMainDataInterval = null;
1253         syncData(100);
1255         hideSearchTab();
1256         hideRssTab();
1257         hideLogTab();
1259         LocalPreferences.set("selected_window_tab", "transfers");
1260     };
1262     const hideTransfersTab = () => {
1263         $("filtersColumn").addClass("invisible");
1264         $("filtersColumn_handle").addClass("invisible");
1265         $("mainColumn").addClass("invisible");
1266         $("torrentsFilterToolbar").addClass("invisible");
1267         MochaUI.Desktop.resizePanels();
1268     };
1270     const showSearchTab = (() => {
1271         let searchTabInitialized = false;
1273         return () => {
1274             // we must wait until the panel is fully loaded before proceeding.
1275             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1276             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1277             if (!isSearchPanelLoaded) {
1278                 setTimeout(() => {
1279                     showSearchTab();
1280                 }, 100);
1281                 return;
1282             }
1284             if (!searchTabInitialized) {
1285                 window.qBittorrent.Search.init();
1286                 searchTabInitialized = true;
1287             }
1289             $("searchTabColumn").removeClass("invisible");
1290             customSyncMainDataInterval = 30000;
1291             hideTransfersTab();
1292             hideRssTab();
1293             hideLogTab();
1295             LocalPreferences.set("selected_window_tab", "search");
1296         };
1297     })();
1299     const hideSearchTab = () => {
1300         $("searchTabColumn").addClass("invisible");
1301         MochaUI.Desktop.resizePanels();
1302     };
1304     const showRssTab = (() => {
1305         let rssTabInitialized = false;
1307         return () => {
1308             // we must wait until the panel is fully loaded before proceeding.
1309             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1310             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1311             if (!isRssPanelLoaded) {
1312                 setTimeout(() => {
1313                     showRssTab();
1314                 }, 100);
1315                 return;
1316             }
1318             if (!rssTabInitialized) {
1319                 window.qBittorrent.Rss.init();
1320                 rssTabInitialized = true;
1321             }
1322             else {
1323                 window.qBittorrent.Rss.load();
1324             }
1326             $("rssTabColumn").removeClass("invisible");
1327             customSyncMainDataInterval = 30000;
1328             hideTransfersTab();
1329             hideSearchTab();
1330             hideLogTab();
1332             LocalPreferences.set("selected_window_tab", "rss");
1333         };
1334     })();
1336     const hideRssTab = () => {
1337         $("rssTabColumn").addClass("invisible");
1338         window.qBittorrent.Rss && window.qBittorrent.Rss.unload();
1339         MochaUI.Desktop.resizePanels();
1340     };
1342     const showLogTab = (() => {
1343         let logTabInitialized = false;
1345         return () => {
1346             // we must wait until the panel is fully loaded before proceeding.
1347             // this include's the panel's custom js, which is loaded via MochaUI.Panel's 'require' field.
1348             // MochaUI loads these files asynchronously and thus all required libs may not be available immediately
1349             if (!isLogPanelLoaded) {
1350                 setTimeout(() => {
1351                     showLogTab();
1352                 }, 100);
1353                 return;
1354             }
1356             if (!logTabInitialized) {
1357                 window.qBittorrent.Log.init();
1358                 logTabInitialized = true;
1359             }
1360             else {
1361                 window.qBittorrent.Log.load();
1362             }
1364             $("logTabColumn").removeClass("invisible");
1365             customSyncMainDataInterval = 30000;
1366             hideTransfersTab();
1367             hideSearchTab();
1368             hideRssTab();
1370             LocalPreferences.set("selected_window_tab", "log");
1371         };
1372     })();
1374     const hideLogTab = () => {
1375         $("logTabColumn").addClass("invisible");
1376         MochaUI.Desktop.resizePanels();
1377         window.qBittorrent.Log && window.qBittorrent.Log.unload();
1378     };
1380     const addSearchPanel = () => {
1381         new MochaUI.Panel({
1382             id: "SearchPanel",
1383             title: "Search",
1384             header: false,
1385             padding: {
1386                 top: 0,
1387                 right: 0,
1388                 bottom: 0,
1389                 left: 0
1390             },
1391             loadMethod: "xhr",
1392             contentURL: "views/search.html",
1393             require: {
1394                 js: ["scripts/search.js"],
1395                 onload: () => {
1396                     isSearchPanelLoaded = true;
1397                 },
1398             },
1399             content: "",
1400             column: "searchTabColumn",
1401             height: null
1402         });
1403     };
1405     const addRssPanel = () => {
1406         new MochaUI.Panel({
1407             id: "RssPanel",
1408             title: "Rss",
1409             header: false,
1410             padding: {
1411                 top: 0,
1412                 right: 0,
1413                 bottom: 0,
1414                 left: 0
1415             },
1416             loadMethod: "xhr",
1417             contentURL: "views/rss.html",
1418             onContentLoaded: () => {
1419                 isRssPanelLoaded = true;
1420             },
1421             content: "",
1422             column: "rssTabColumn",
1423             height: null
1424         });
1425     };
1427     const addLogPanel = () => {
1428         new MochaUI.Panel({
1429             id: "LogPanel",
1430             title: "Log",
1431             header: true,
1432             padding: {
1433                 top: 0,
1434                 right: 0,
1435                 bottom: 0,
1436                 left: 0
1437             },
1438             loadMethod: "xhr",
1439             contentURL: "views/log.html",
1440             require: {
1441                 css: ["css/vanillaSelectBox.css"],
1442                 js: ["scripts/lib/vanillaSelectBox.js"],
1443                 onload: () => {
1444                     isLogPanelLoaded = true;
1445                 },
1446             },
1447             tabsURL: "views/logTabs.html",
1448             tabsOnload: () => {
1449                 MochaUI.initializeTabs("panelTabs");
1451                 $("logMessageLink").addEventListener("click", (e) => {
1452                     window.qBittorrent.Log.setCurrentTab("main");
1453                 });
1455                 $("logPeerLink").addEventListener("click", (e) => {
1456                     window.qBittorrent.Log.setCurrentTab("peer");
1457                 });
1458             },
1459             collapsible: false,
1460             content: "",
1461             column: "logTabColumn",
1462             height: null
1463         });
1464     };
1466     const handleDownloadParam = () => {
1467         // Extract torrent URL from download param in WebUI URL hash
1468         const downloadHash = "#download=";
1469         if (location.hash.indexOf(downloadHash) !== 0)
1470             return;
1472         const url = decodeURIComponent(location.hash.substring(downloadHash.length));
1473         // Remove the processed hash from the URL
1474         history.replaceState("", document.title, (location.pathname + location.search));
1475         showDownloadPage([url]);
1476     };
1478     new MochaUI.Panel({
1479         id: "transferList",
1480         title: "Panel",
1481         header: false,
1482         padding: {
1483             top: 0,
1484             right: 0,
1485             bottom: 0,
1486             left: 0
1487         },
1488         loadMethod: "xhr",
1489         contentURL: "views/transferlist.html",
1490         onContentLoaded: () => {
1491             handleDownloadParam();
1492             updateMainData();
1493         },
1494         column: "mainColumn",
1495         onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1496             saveColumnSizes();
1497         }),
1498         height: null
1499     });
1500     let prop_h = LocalPreferences.get("properties_height_rel");
1501     if (prop_h !== null)
1502         prop_h = prop_h.toFloat() * Window.getSize().y;
1503     else
1504         prop_h = Window.getSize().y / 2.0;
1505     new MochaUI.Panel({
1506         id: "propertiesPanel",
1507         title: "Panel",
1508         padding: {
1509             top: 0,
1510             right: 0,
1511             bottom: 0,
1512             left: 0
1513         },
1514         contentURL: "views/properties.html",
1515         require: {
1516             js: ["scripts/prop-general.js", "scripts/prop-trackers.js", "scripts/prop-peers.js", "scripts/prop-webseeds.js", "scripts/prop-files.js"],
1517             onload: () => {
1518                 updatePropertiesPanel = () => {
1519                     switch (LocalPreferences.get("selected_properties_tab")) {
1520                         case "propGeneralLink":
1521                             window.qBittorrent.PropGeneral.updateData();
1522                             break;
1523                         case "propTrackersLink":
1524                             window.qBittorrent.PropTrackers.updateData();
1525                             break;
1526                         case "propPeersLink":
1527                             window.qBittorrent.PropPeers.updateData();
1528                             break;
1529                         case "propWebSeedsLink":
1530                             window.qBittorrent.PropWebseeds.updateData();
1531                             break;
1532                         case "propFilesLink":
1533                             window.qBittorrent.PropFiles.updateData();
1534                             break;
1535                     }
1536                 };
1537             }
1538         },
1539         tabsURL: "views/propertiesToolbar.html",
1540         tabsOnload: () => {}, // must be included, otherwise panel won't load properly
1541         onContentLoaded: function() {
1542             this.panelHeaderCollapseBoxEl.classList.add("invisible");
1544             const togglePropertiesPanel = () => {
1545                 this.collapseToggleEl.click();
1546                 LocalPreferences.set("properties_panel_collapsed", this.isCollapsed.toString());
1547             };
1549             const selectTab = (tabID) => {
1550                 const isAlreadySelected = this.panelHeaderEl.getElementById(tabID).classList.contains("selected");
1551                 if (!isAlreadySelected) {
1552                     for (const tab of this.panelHeaderEl.getElementById("propertiesTabs").children)
1553                         tab.classList.toggle("selected", tab.id === tabID);
1555                     const tabContentID = tabID.replace("Link", "");
1556                     for (const tabContent of this.contentEl.children)
1557                         tabContent.classList.toggle("invisible", tabContent.id !== tabContentID);
1559                     LocalPreferences.set("selected_properties_tab", tabID);
1560                 }
1562                 if (isAlreadySelected || this.isCollapsed)
1563                     togglePropertiesPanel();
1564             };
1566             const lastUsedTab = LocalPreferences.get("selected_properties_tab", "propGeneralLink");
1567             selectTab(lastUsedTab);
1569             const startCollapsed = LocalPreferences.get("properties_panel_collapsed", "false") === "true";
1570             if (startCollapsed)
1571                 togglePropertiesPanel();
1573             this.panelHeaderContentEl.addEventListener("click", (e) => {
1574                 const selectedTab = e.target.closest("li");
1575                 if (!selectedTab)
1576                     return;
1578                 selectTab(selectedTab.id);
1579                 updatePropertiesPanel();
1581                 const showFilesFilter = (selectedTab.id === "propFilesLink") && !this.isCollapsed;
1582                 document.getElementById("torrentFilesFilterToolbar").classList.toggle("invisible", !showFilesFilter);
1583             });
1585             const showFilesFilter = (lastUsedTab === "propFilesLink") && !this.isCollapsed;
1586             if (showFilesFilter)
1587                 document.getElementById("torrentFilesFilterToolbar").classList.remove("invisible");
1588         },
1589         column: "mainColumn",
1590         height: prop_h
1591     });
1593     // listen for changes to torrentsFilterInput
1594     let torrentsFilterInputTimer = -1;
1595     $("torrentsFilterInput").addEventListener("input", () => {
1596         clearTimeout(torrentsFilterInputTimer);
1597         torrentsFilterInputTimer = setTimeout(() => {
1598             torrentsFilterInputTimer = -1;
1599             torrentsTable.updateTable();
1600         }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
1601     });
1603     document.getElementById("torrentsFilterToolbar").addEventListener("change", (e) => { torrentsTable.updateTable(); });
1605     $("transfersTabLink").addEventListener("click", () => { showTransfersTab(); });
1606     $("searchTabLink").addEventListener("click", () => { showSearchTab(); });
1607     $("rssTabLink").addEventListener("click", () => { showRssTab(); });
1608     $("logTabLink").addEventListener("click", () => { showLogTab(); });
1609     updateTabDisplay();
1611     const registerDragAndDrop = () => {
1612         $("desktop").addEventListener("dragover", (ev) => {
1613             if (ev.preventDefault)
1614                 ev.preventDefault();
1615         });
1617         $("desktop").addEventListener("dragenter", (ev) => {
1618             if (ev.preventDefault)
1619                 ev.preventDefault();
1620         });
1622         $("desktop").addEventListener("drop", (ev) => {
1623             if (ev.preventDefault)
1624                 ev.preventDefault();
1626             const droppedFiles = ev.dataTransfer.files;
1628             if (droppedFiles.length > 0) {
1629                 // dropped files or folders
1631                 // can't handle folder due to cannot put the filelist (from dropped folder)
1632                 // to <input> `files` field
1633                 for (const item of ev.dataTransfer.items) {
1634                     if (item.webkitGetAsEntry().isDirectory)
1635                         return;
1636                 }
1638                 const id = "uploadPage";
1639                 new MochaUI.Window({
1640                     id: id,
1641                     icon: "images/qbittorrent-tray.svg",
1642                     title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]",
1643                     loadMethod: "iframe",
1644                     contentURL: new URI("upload.html").toString(),
1645                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1646                     scrollbars: true,
1647                     maximizable: false,
1648                     paddingVertical: 0,
1649                     paddingHorizontal: 0,
1650                     width: loadWindowWidth(id, 500),
1651                     height: loadWindowHeight(id, 460),
1652                     onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1653                         saveWindowSize(id);
1654                     }),
1655                     onContentLoaded: () => {
1656                         const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect");
1657                         fileInput.files = droppedFiles;
1658                     }
1659                 });
1660             }
1662             const droppedText = ev.dataTransfer.getData("text");
1663             if (droppedText.length > 0) {
1664                 // dropped text
1666                 const urls = droppedText.split("\n")
1667                     .map((str) => str.trim())
1668                     .filter((str) => {
1669                         const lowercaseStr = str.toLowerCase();
1670                         return lowercaseStr.startsWith("http:")
1671                             || lowercaseStr.startsWith("https:")
1672                             || lowercaseStr.startsWith("magnet:")
1673                             || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash
1674                             || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash
1675                     });
1677                 if (urls.length <= 0)
1678                     return;
1680                 const id = "downloadPage";
1681                 const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|"));
1682                 new MochaUI.Window({
1683                     id: id,
1684                     icon: "images/qbittorrent-tray.svg",
1685                     title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]",
1686                     loadMethod: "iframe",
1687                     contentURL: contentURI.toString(),
1688                     addClass: "windowFrame", // fixes iframe scrolling on iOS Safari
1689                     scrollbars: true,
1690                     maximizable: false,
1691                     closable: true,
1692                     paddingVertical: 0,
1693                     paddingHorizontal: 0,
1694                     width: loadWindowWidth(id, 500),
1695                     height: loadWindowHeight(id, 600),
1696                     onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1697                         saveWindowSize(id);
1698                     })
1699                 });
1700             }
1701         });
1702     };
1703     registerDragAndDrop();
1705     new Keyboard({
1706         defaultEventType: "keydown",
1707         events: {
1708             "ctrl+a": function(event) {
1709                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1710                     return;
1711                 if (event.target.isContentEditable)
1712                     return;
1713                 torrentsTable.selectAll();
1714                 event.preventDefault();
1715             },
1716             "delete": function(event) {
1717                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1718                     return;
1719                 if (event.target.isContentEditable)
1720                     return;
1721                 deleteSelectedTorrentsFN();
1722                 event.preventDefault();
1723             },
1724             "shift+delete": (event) => {
1725                 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1726                     return;
1727                 if (event.target.isContentEditable)
1728                     return;
1729                 deleteSelectedTorrentsFN(true);
1730                 event.preventDefault();
1731             }
1732         }
1733     }).activate();
1735     new ClipboardJS(".copyToClipboard", {
1736         text: (trigger) => {
1737             switch (trigger.id) {
1738                 case "copyName":
1739                     return copyNameFN();
1740                 case "copyInfohash1":
1741                     return copyInfohashFN(1);
1742                 case "copyInfohash2":
1743                     return copyInfohashFN(2);
1744                 case "copyMagnetLink":
1745                     return copyMagnetLinkFN();
1746                 case "copyID":
1747                     return copyIdFN();
1748                 case "copyComment":
1749                     return copyCommentFN();
1750                 default:
1751                     return "";
1752             }
1753         }
1754     });
1757 window.addEventListener("load", () => {
1758     // fetch various data and store it in memory
1759     window.qBittorrent.Cache.buildInfo.init();
1760     window.qBittorrent.Cache.preferences.init();
1761     window.qBittorrent.Cache.qbtVersion.init();
1763     // Setup color scheme switching
1764     const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
1765     const updateColorScheme = () => {
1766         const root = document.documentElement;
1767         const colorScheme = LocalPreferences.get("color_scheme");
1768         const validScheme = (colorScheme === "light") || (colorScheme === "dark");
1769         const isDark = colorSchemeQuery.matches;
1770         root.classList.toggle("dark", ((!validScheme && isDark) || (colorScheme === "dark")));
1771     };
1773     colorSchemeQuery.addEventListener("change", updateColorScheme);
1774     updateColorScheme();
1776     // switch to previously used tab
1777     const previouslyUsedTab = LocalPreferences.get("selected_window_tab", "transfers");
1778     switch (previouslyUsedTab) {
1779         case "search":
1780             if (window.qBittorrent.Client.isShowSearchEngine())
1781                 $("searchTabLink").click();
1782             break;
1783         case "rss":
1784             if (window.qBittorrent.Client.isShowRssReader())
1785                 $("rssTabLink").click();
1786             break;
1787         case "log":
1788             if (window.qBittorrent.Client.isShowLogViewer())
1789                 $("logTabLink").click();
1790             break;
1791         case "transfers":
1792             $("transfersTabLink").click();
1793             break;
1794         default:
1795             console.error(`Unexpected 'selected_window_tab' value: ${previouslyUsedTab}`);
1796             $("transfersTabLink").click();
1797             break;
1798     };