3 * Copyright (C) 2024 Mike Tzou (Chocobo1)
4 * Copyright (c) 2008 Ishan Arora <ishan@qbittorrent.org>,
5 * Christophe Dumez <chris@qbittorrent.org>
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:
14 * The above copyright notice and this permission notice shall be included in
15 * all copies or substantial portions of the Software.
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
28 window.qBittorrent ??= {};
29 window.qBittorrent.Client ??= (() => {
30 const exports = () => {
32 closeWindow: closeWindow,
33 closeWindows: closeWindows,
34 getSyncMainDataInterval: getSyncMainDataInterval,
38 showSearchEngine: showSearchEngine,
39 showRssReader: showRssReader,
40 showLogViewer: showLogViewer,
41 isShowSearchEngine: isShowSearchEngine,
42 isShowRssReader: isShowRssReader,
43 isShowLogViewer: isShowLogViewer
47 const closeWindow = (windowID) => {
48 const window = document.getElementById(windowID);
51 MochaUI.closeWindow(window);
54 const closeWindows = () => {
58 const getSyncMainDataInterval = () => {
59 return customSyncMainDataInterval ? customSyncMainDataInterval : serverSyncMainDataInterval;
63 const isStopped = () => {
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}` : "");
80 let showingSearchEngine = false;
81 let showingRssReader = false;
82 let showingLogViewer = false;
84 const showSearchEngine = (bool) => {
85 showingSearchEngine = bool;
87 const showRssReader = (bool) => {
88 showingRssReader = bool;
90 const showLogViewer = (bool) => {
91 showingLogViewer = bool;
93 const isShowSearchEngine = () => {
94 return showingSearchEngine;
96 const isShowRssReader = () => {
97 return showingRssReader;
99 const isShowLogViewer = () => {
100 return showingLogViewer;
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 = () => {};
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 = () => {};
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);
167 window.addEventListener("resize", window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
168 // only save sizes if the columns are visible
169 if (!$("mainColumn").hasClass("invisible"))
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 = () => {
182 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
185 width: Number(LocalPreferences.get("filters_width", 210)),
186 resizeLimit: [1, 1000]
194 const buildSearchTab = () => {
196 id: "searchTabColumn",
202 $("searchTabColumn").addClass("invisible");
205 const buildRssTab = () => {
213 $("rssTabColumn").addClass("invisible");
216 const buildLogTab = () => {
224 $("logTabColumn").addClass("invisible");
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();
244 setStatusFilter = (name) => {
245 const currentHash = torrentsTable.getCurrentTorrentID();
247 LocalPreferences.set("selected_filter", name);
248 selectedStatus = name;
249 highlightSelectedStatus();
252 const newHash = torrentsTable.getCurrentTorrentID();
253 handleFilterSelectionChange(currentHash, newHash);
256 setCategoryFilter = (hash) => {
257 const currentHash = torrentsTable.getCurrentTorrentID();
259 LocalPreferences.set("selected_category", hash);
260 selectedCategory = Number(hash);
261 highlightSelectedCategory();
264 const newHash = torrentsTable.getCurrentTorrentID();
265 handleFilterSelectionChange(currentHash, newHash);
268 setTagFilter = (hash) => {
269 const currentHash = torrentsTable.getCurrentTorrentID();
271 LocalPreferences.set("selected_tag", hash);
272 selectedTag = Number(hash);
273 highlightSelectedTag();
276 const newHash = torrentsTable.getCurrentTorrentID();
277 handleFilterSelectionChange(currentHash, newHash);
280 setTrackerFilter = (hash) => {
281 const currentHash = torrentsTable.getCurrentTorrentID();
283 LocalPreferences.set("selected_tracker", hash);
284 selectedTracker = Number(hash);
285 highlightSelectedTracker();
288 const newHash = torrentsTable.getCurrentTorrentID();
289 handleFilterSelectionChange(currentHash, newHash);
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());
311 contentURL: "views/filters.html",
312 onContentLoaded: () => {
313 highlightSelectedStatus();
315 column: "filtersColumn",
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");
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");
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");
342 let speedInTitle = LocalPreferences.get("speed_in_browser_title_bar") === "true";
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) => {
362 category_list.forEach((category) => {
363 const deleteResult = category.torrents.delete(hash);
364 removed ||= deleteResult;
370 const addTorrentToCategoryList = (torrent) => {
371 const category = torrent["category"];
372 if (typeof category === "undefined")
375 const hash = torrent["hash"];
376 if (category.length === 0) { // Empty category
377 removeTorrentFromCategoryList(hash);
381 const categoryHash = window.qBittorrent.Misc.genHash(category);
382 if (!category_list.has(categoryHash)) { // This should not happen
383 category_list.set(categoryHash, {
389 const torrents = category_list.get(categoryHash).torrents;
390 if (!torrents.has(hash)) {
391 removeTorrentFromCategoryList(hash);
398 const removeTorrentFromTagList = (hash) => {
403 tagList.forEach((tag) => {
404 const deleteResult = tag.torrents.delete(hash);
405 removed ||= deleteResult;
411 const addTorrentToTagList = (torrent) => {
412 if (torrent["tags"] === undefined) // Tags haven't changed
415 const hash = torrent["hash"];
416 removeTorrentFromTagList(hash);
418 if (torrent["tags"].length === 0) // No tags
421 const tags = torrent["tags"].split(",");
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, {
432 const torrents = tagList.get(tagHash).torrents;
433 if (!torrents.has(hash)) {
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))
449 filterEl.firstElementChild.lastChild.nodeValue = filterTitle.replace("%1", filterTorrentCount);
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]");
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));
476 const updateCategoryList = () => {
477 const categoryList = document.getElementById("categoryFilterList");
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;
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 });
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());
518 let uncategorized = 0;
519 for (const { full_data: { category } } of torrentsTable.getRowValues()) {
520 if (category.length === 0)
524 const sortedCategories = [];
525 category_list.forEach((category, hash) => sortedCategories.push({
526 categoryName: category.name,
528 categoryCount: category.torrents.size,
529 nameSegments: category.name.split("/"),
530 ...(useSubcategories && {
533 forceExpand: LocalPreferences.get(`category_${hash}_collapsed`) === null
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;
547 return leftSegments.length - rightSegments.length;
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);
571 for (const category of sortedCategories) {
572 if (category.parentID === null)
573 createCategoryTree(category);
577 categoryList.classList.remove("subcategories");
578 for (const { categoryHash, categoryName, categoryCount } of sortedCategories)
579 categoriesFragment.appendChild(createCategoryLink(categoryHash, categoryName, categoryCount));
582 categoryList.appendChild(categoriesFragment);
583 window.qBittorrent.Filters.categoriesFilterContextMenu.searchAndAddTargets();
586 const highlightSelectedCategory = () => {
587 const categoryList = document.getElementById("categoryFilterList");
591 for (const category of categoryList.getElementsByTagName("li"))
592 category.classList.toggle("selectedFilter", (Number(category.id) === selectedCategory));
595 const updateTagList = () => {
596 const tagFilterList = $("tagFilterList");
597 if (tagFilterList === null)
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;
616 for (const { full_data: { tags } } of torrentsTable.getRowValues()) {
617 if (tags.length === 0)
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({
628 tagSize: tag.torrents.size
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();
638 const highlightSelectedTag = () => {
639 const tagFilterList = document.getElementById("tagFilterList");
643 for (const tag of tagFilterList.children)
644 tag.classList.toggle("selectedFilter", (Number(tag.id) === selectedTag));
647 const updateTrackerList = () => {
648 const trackerFilterList = $("trackerFilterList");
649 if (trackerFilterList === null)
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;
667 let trackerlessTorrentsCount = 0;
668 for (const { full_data: { trackers_count: trackersCount } } of torrentsTable.getRowValues()) {
669 if (trackersCount === 0)
670 trackerlessTorrentsCount += 1;
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);
688 trackerCount: uniqueTorrents.size,
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();
698 const highlightSelectedTracker = () => {
699 const trackerFilterList = document.getElementById("trackerFilterList");
700 if (!trackerFilterList)
703 for (const tracker of trackerFilterList.children)
704 tracker.classList.toggle("selectedFilter", (Number(tracker.id) === selectedTracker));
707 const statusSortOrder = Object.freeze({
717 "checkingResumeData": 8,
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({
739 const errorDiv = $("error_div");
741 errorDiv.textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
742 syncRequestInProgress = false;
745 onSuccess: (response) => {
746 $("error_div").textContent = "";
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);
758 torrentsTableSelectedRows = torrentsTable.selectedRowsIds();
759 update_categories = true;
761 updateTrackers = true;
762 updateTorrents = true;
763 torrentsTable.clear();
764 category_list.clear();
769 syncMainDataLastResponseId = response["rid"];
770 if (response["categories"]) {
771 for (const key in response["categories"]) {
772 if (!Object.hasOwn(response["categories"], key))
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;
783 category_list.set(categoryHash, {
784 name: responseCategory.name,
785 savePath: responseCategory.savePath,
790 update_categories = true;
792 if (response["categories_removed"]) {
793 response["categories_removed"].each((category) => {
794 const categoryHash = window.qBittorrent.Misc.genHash(category);
795 category_list.delete(categoryHash);
797 update_categories = true;
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, {
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);
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);
828 trackerListItem.trackerTorrentMap.set(tracker, new Set(torrents));
830 updateTrackers = true;
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());
850 updateTrackers = true;
852 if (response["torrents"]) {
853 for (const key in response["torrents"]) {
854 if (!Object.hasOwn(response["torrents"], key))
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];
864 torrentsTable.updateRowData(response["torrents"][key]);
865 if (addTorrentToCategoryList(response["torrents"][key]))
866 update_categories = true;
867 if (addTorrentToTagList(response["torrents"][key]))
869 updateTorrents = true;
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
880 updateTorrents = true;
883 // don't update the table unnecessarily
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))
892 serverState[k] = tmp[k];
894 processServerState();
897 if (update_categories) {
898 updateCategoryList();
899 window.qBittorrent.TransferList.contextMenu.updateCategoriesSubMenu(category_list);
903 window.qBittorrent.TransferList.contextMenu.updateTagsSubMenu(tagList);
909 // re-select previously selected rows
910 torrentsTable.reselectRows(torrentsTableSelectedRows);
912 syncRequestInProgress = false;
913 syncData(window.qBittorrent.Client.getSyncMainDataInterval());
916 syncRequestInProgress = true;
920 updateMainData = () => {
921 torrentsTable.updateTable();
925 const syncData = (delay) => {
926 if (syncRequestInProgress)
929 clearTimeout(syncMainDataTimeoutID);
930 syncMainDataTimeoutID = -1;
932 if (window.qBittorrent.Client.isStopped())
935 syncMainDataTimeoutID = syncMainData.delay(delay);
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)))
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");
977 externalIPsElement.classList.add("invisible");
978 externalIPsElement.previousElementSibling.classList.add("invisible");
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");
988 dhtElement.classList.add("invisible");
989 dhtElement.previousElementSibling.classList.add("invisible");
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);
1008 switch (serverState.connection_status) {
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]";
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]";
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]";
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");
1039 $("topQueuePosItem").addClass("invisible");
1040 $("increaseQueuePosItem").addClass("invisible");
1041 $("decreaseQueuePosItem").addClass("invisible");
1042 $("bottomQueuePosItem").addClass("invisible");
1043 $("queueingButtons").addClass("invisible");
1044 $("queueingMenuItems").addClass("invisible");
1048 if (alternativeSpeedLimits !== serverState.use_alt_speed_limits) {
1049 alternativeSpeedLimits = serverState.use_alt_speed_limits;
1050 updateAltSpeedIcon(alternativeSpeedLimits);
1053 if (useSubcategories !== serverState.use_subcategories) {
1054 useSubcategories = serverState.use_subcategories;
1055 updateCategoryList();
1058 serverSyncMainDataInterval = Math.max(serverState.refresh_interval, 500);
1061 const updateAltSpeedIcon = (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]";
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]";
1074 $("alternativeSpeedLimits").addEventListener("click", () => {
1075 // Change icon immediately to give some feedback
1076 updateAltSpeedIcon(!alternativeSpeedLimits);
1079 url: "api/v2/transfer/toggleSpeedLimitsMode",
1082 alternativeSpeedLimits = !alternativeSpeedLimits;
1086 // Restore icon in case of failure
1087 updateAltSpeedIcon(alternativeSpeedLimits);
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");
1103 $("showTopToolbarLink").firstChild.style.opacity = "0";
1104 $("mochaToolbar").addClass("invisible");
1106 MochaUI.Desktop.setDesktopSize();
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");
1117 $("showStatusBarLink").firstChild.style.opacity = "0";
1118 $("desktopFooterWrapper").addClass("invisible");
1120 MochaUI.Desktop.setDesktopSize();
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]");
1128 alert("QBT_TR(Your browser does not support this feature)QBT_TR[CONTEXT=MainWindow]");
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");
1143 $("registerMagnetHandlerLink").addEventListener("click", (e) => {
1144 registerMagnetHandler();
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");
1156 $("showFiltersSidebarLink").firstChild.style.opacity = "0";
1157 $("filtersColumn").addClass("invisible");
1158 $("filtersColumn_handle").addClass("invisible");
1160 MochaUI.Desktop.setDesktopSize();
1163 $("speedInBrowserTitleBarLink").addEventListener("click", (e) => {
1164 speedInTitle = !speedInTitle;
1165 LocalPreferences.set("speed_in_browser_title_bar", speedInTitle.toString());
1167 $("speedInBrowserTitleBarLink").firstChild.style.opacity = "1";
1169 $("speedInBrowserTitleBarLink").firstChild.style.opacity = "0";
1170 processServerState();
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());
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());
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());
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)
1200 $("showRssReaderLink").firstChild.style.opacity = "0";
1201 $("rssTabLink").addClass("invisible");
1202 if ($("rssTabLink").hasClass("selected"))
1203 $("transfersTabLink").click();
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)
1214 $("showSearchEngineLink").firstChild.style.opacity = "0";
1215 $("searchTabLink").addClass("invisible");
1216 if ($("searchTabLink").hasClass("selected"))
1217 $("transfersTabLink").click();
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)
1228 $("showLogViewerLink").firstChild.style.opacity = "0";
1229 $("logTabLink").addClass("invisible");
1230 if ($("logTabLink").hasClass("selected"))
1231 $("transfersTabLink").click();
1235 if (!window.qBittorrent.Client.isShowRssReader() && !window.qBittorrent.Client.isShowSearchEngine() && !window.qBittorrent.Client.isShowLogViewer())
1236 $("mainWindowTabs").addClass("invisible");
1239 $("StatisticsLink").addEventListener("click", () => { StatisticsLinkFN(); });
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");
1249 $("mainColumn").removeClass("invisible");
1250 $("torrentsFilterToolbar").removeClass("invisible");
1252 customSyncMainDataInterval = null;
1259 LocalPreferences.set("selected_window_tab", "transfers");
1262 const hideTransfersTab = () => {
1263 $("filtersColumn").addClass("invisible");
1264 $("filtersColumn_handle").addClass("invisible");
1265 $("mainColumn").addClass("invisible");
1266 $("torrentsFilterToolbar").addClass("invisible");
1267 MochaUI.Desktop.resizePanels();
1270 const showSearchTab = (() => {
1271 let searchTabInitialized = false;
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) {
1284 if (!searchTabInitialized) {
1285 window.qBittorrent.Search.init();
1286 searchTabInitialized = true;
1289 $("searchTabColumn").removeClass("invisible");
1290 customSyncMainDataInterval = 30000;
1295 LocalPreferences.set("selected_window_tab", "search");
1299 const hideSearchTab = () => {
1300 $("searchTabColumn").addClass("invisible");
1301 MochaUI.Desktop.resizePanels();
1304 const showRssTab = (() => {
1305 let rssTabInitialized = false;
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) {
1318 if (!rssTabInitialized) {
1319 window.qBittorrent.Rss.init();
1320 rssTabInitialized = true;
1323 window.qBittorrent.Rss.load();
1326 $("rssTabColumn").removeClass("invisible");
1327 customSyncMainDataInterval = 30000;
1332 LocalPreferences.set("selected_window_tab", "rss");
1336 const hideRssTab = () => {
1337 $("rssTabColumn").addClass("invisible");
1338 window.qBittorrent.Rss && window.qBittorrent.Rss.unload();
1339 MochaUI.Desktop.resizePanels();
1342 const showLogTab = (() => {
1343 let logTabInitialized = false;
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) {
1356 if (!logTabInitialized) {
1357 window.qBittorrent.Log.init();
1358 logTabInitialized = true;
1361 window.qBittorrent.Log.load();
1364 $("logTabColumn").removeClass("invisible");
1365 customSyncMainDataInterval = 30000;
1370 LocalPreferences.set("selected_window_tab", "log");
1374 const hideLogTab = () => {
1375 $("logTabColumn").addClass("invisible");
1376 MochaUI.Desktop.resizePanels();
1377 window.qBittorrent.Log && window.qBittorrent.Log.unload();
1380 const addSearchPanel = () => {
1392 contentURL: "views/search.html",
1394 js: ["scripts/search.js"],
1396 isSearchPanelLoaded = true;
1400 column: "searchTabColumn",
1405 const addRssPanel = () => {
1417 contentURL: "views/rss.html",
1418 onContentLoaded: () => {
1419 isRssPanelLoaded = true;
1422 column: "rssTabColumn",
1427 const addLogPanel = () => {
1439 contentURL: "views/log.html",
1441 css: ["css/vanillaSelectBox.css"],
1442 js: ["scripts/lib/vanillaSelectBox.js"],
1444 isLogPanelLoaded = true;
1447 tabsURL: "views/logTabs.html",
1449 MochaUI.initializeTabs("panelTabs");
1451 $("logMessageLink").addEventListener("click", (e) => {
1452 window.qBittorrent.Log.setCurrentTab("main");
1455 $("logPeerLink").addEventListener("click", (e) => {
1456 window.qBittorrent.Log.setCurrentTab("peer");
1461 column: "logTabColumn",
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)
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]);
1489 contentURL: "views/transferlist.html",
1490 onContentLoaded: () => {
1491 handleDownloadParam();
1494 column: "mainColumn",
1495 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1500 let prop_h = LocalPreferences.get("properties_height_rel");
1501 if (prop_h !== null)
1502 prop_h = prop_h.toFloat() * Window.getSize().y;
1504 prop_h = Window.getSize().y / 2.0;
1506 id: "propertiesPanel",
1514 contentURL: "views/properties.html",
1516 js: ["scripts/prop-general.js", "scripts/prop-trackers.js", "scripts/prop-peers.js", "scripts/prop-webseeds.js", "scripts/prop-files.js"],
1518 updatePropertiesPanel = () => {
1519 switch (LocalPreferences.get("selected_properties_tab")) {
1520 case "propGeneralLink":
1521 window.qBittorrent.PropGeneral.updateData();
1523 case "propTrackersLink":
1524 window.qBittorrent.PropTrackers.updateData();
1526 case "propPeersLink":
1527 window.qBittorrent.PropPeers.updateData();
1529 case "propWebSeedsLink":
1530 window.qBittorrent.PropWebseeds.updateData();
1532 case "propFilesLink":
1533 window.qBittorrent.PropFiles.updateData();
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());
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);
1562 if (isAlreadySelected || this.isCollapsed)
1563 togglePropertiesPanel();
1566 const lastUsedTab = LocalPreferences.get("selected_properties_tab", "propGeneralLink");
1567 selectTab(lastUsedTab);
1569 const startCollapsed = LocalPreferences.get("properties_panel_collapsed", "false") === "true";
1571 togglePropertiesPanel();
1573 this.panelHeaderContentEl.addEventListener("click", (e) => {
1574 const selectedTab = e.target.closest("li");
1578 selectTab(selectedTab.id);
1579 updatePropertiesPanel();
1581 const showFilesFilter = (selectedTab.id === "propFilesLink") && !this.isCollapsed;
1582 document.getElementById("torrentFilesFilterToolbar").classList.toggle("invisible", !showFilesFilter);
1585 const showFilesFilter = (lastUsedTab === "propFilesLink") && !this.isCollapsed;
1586 if (showFilesFilter)
1587 document.getElementById("torrentFilesFilterToolbar").classList.remove("invisible");
1589 column: "mainColumn",
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);
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(); });
1611 const registerDragAndDrop = () => {
1612 $("desktop").addEventListener("dragover", (ev) => {
1613 if (ev.preventDefault)
1614 ev.preventDefault();
1617 $("desktop").addEventListener("dragenter", (ev) => {
1618 if (ev.preventDefault)
1619 ev.preventDefault();
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)
1638 const id = "uploadPage";
1639 new MochaUI.Window({
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
1649 paddingHorizontal: 0,
1650 width: loadWindowWidth(id, 500),
1651 height: loadWindowHeight(id, 460),
1652 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1655 onContentLoaded: () => {
1656 const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect");
1657 fileInput.files = droppedFiles;
1662 const droppedText = ev.dataTransfer.getData("text");
1663 if (droppedText.length > 0) {
1666 const urls = droppedText.split("\n")
1667 .map((str) => str.trim())
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
1677 if (urls.length <= 0)
1680 const id = "downloadPage";
1681 const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|"));
1682 new MochaUI.Window({
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
1693 paddingHorizontal: 0,
1694 width: loadWindowWidth(id, 500),
1695 height: loadWindowHeight(id, 600),
1696 onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => {
1703 registerDragAndDrop();
1706 defaultEventType: "keydown",
1708 "ctrl+a": function(event) {
1709 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1711 if (event.target.isContentEditable)
1713 torrentsTable.selectAll();
1714 event.preventDefault();
1716 "delete": function(event) {
1717 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1719 if (event.target.isContentEditable)
1721 deleteSelectedTorrentsFN();
1722 event.preventDefault();
1724 "shift+delete": (event) => {
1725 if ((event.target.nodeName === "INPUT") || (event.target.nodeName === "TEXTAREA"))
1727 if (event.target.isContentEditable)
1729 deleteSelectedTorrentsFN(true);
1730 event.preventDefault();
1735 new ClipboardJS(".copyToClipboard", {
1736 text: (trigger) => {
1737 switch (trigger.id) {
1739 return copyNameFN();
1740 case "copyInfohash1":
1741 return copyInfohashFN(1);
1742 case "copyInfohash2":
1743 return copyInfohashFN(2);
1744 case "copyMagnetLink":
1745 return copyMagnetLinkFN();
1749 return copyCommentFN();
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")));
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) {
1780 if (window.qBittorrent.Client.isShowSearchEngine())
1781 $("searchTabLink").click();
1784 if (window.qBittorrent.Client.isShowRssReader())
1785 $("rssTabLink").click();
1788 if (window.qBittorrent.Client.isShowLogViewer())
1789 $("logTabLink").click();
1792 $("transfersTabLink").click();
1795 console.error(`Unexpected 'selected_window_tab' value: ${previouslyUsedTab}`);
1796 $("transfersTabLink").click();