mirror of
https://github.com/fergalmoran/picard.git
synced 2026-01-06 00:23:58 +00:00
Provide realistic progress and estimated time
This commit is contained in:
committed by
Philipp Wolfer
parent
5b80c72d40
commit
c88273ee24
37482
picard/resources.py
37482
picard/resources.py
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,8 @@
|
||||
#
|
||||
# Copyright (C) 2013, 2018, 2020 Laurent Monin
|
||||
# Copyright (C) 2016-2017 Sambhav Kothari
|
||||
# Copyright (C) 2019 Philipp Wolfer
|
||||
# Copyright (C) 2019, 2021 Philipp Wolfer
|
||||
# Copyright (C) 2021 Gabriel Ferreira
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
@@ -21,6 +22,8 @@
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
|
||||
import time
|
||||
|
||||
from PyQt5 import (
|
||||
QtCore,
|
||||
QtGui,
|
||||
@@ -28,6 +31,7 @@ from PyQt5 import (
|
||||
)
|
||||
|
||||
from picard.util import icontheme
|
||||
from picard.util.time import get_timestamp
|
||||
|
||||
from picard.ui.ui_infostatus import Ui_InfoStatus
|
||||
|
||||
@@ -43,25 +47,31 @@ class InfoStatus(QtWidgets.QWidget, Ui_InfoStatus):
|
||||
self._create_icons()
|
||||
self._init_labels()
|
||||
|
||||
self.reset_counters()
|
||||
|
||||
def _init_labels(self):
|
||||
size = self._size
|
||||
self.label1.setPixmap(self.icon_file.pixmap(size))
|
||||
self.label2.setPixmap(self.icon_cd.pixmap(size))
|
||||
self.label3.setPixmap(self.icon_file_pending.pixmap(size))
|
||||
self.label4.setPixmap(self.icon_download.pixmap(size, QtGui.QIcon.Disabled))
|
||||
self.label1.setPixmap(self.icon_eta.pixmap(size))
|
||||
self.label1.hide()
|
||||
self.label2.setPixmap(self.icon_file.pixmap(size))
|
||||
self.label3.setPixmap(self.icon_cd.pixmap(size))
|
||||
self.label4.setPixmap(self.icon_file_pending.pixmap(size))
|
||||
self.label5.setPixmap(self.icon_download.pixmap(size, QtGui.QIcon.Disabled))
|
||||
self._init_tooltips()
|
||||
|
||||
def _create_icons(self):
|
||||
self.icon_eta = QtGui.QIcon(':/images/22x22/hourglass.png')
|
||||
self.icon_cd = icontheme.lookup('media-optical')
|
||||
self.icon_file = QtGui.QIcon(":/images/file.png")
|
||||
self.icon_file_pending = QtGui.QIcon(":/images/file-pending.png")
|
||||
self.icon_download = QtGui.QIcon(":/images/16x16/action-go-down-16.png")
|
||||
|
||||
def _init_tooltips(self):
|
||||
t1 = _("Files")
|
||||
t2 = _("Albums")
|
||||
t3 = _("Pending files")
|
||||
t4 = _("Pending requests")
|
||||
t1 = _("Estimated Time")
|
||||
t2 = _("Files")
|
||||
t3 = _("Albums")
|
||||
t4 = _("Pending files")
|
||||
t5 = _("Pending requests")
|
||||
self.val1.setToolTip(t1)
|
||||
self.label1.setToolTip(t1)
|
||||
self.val2.setToolTip(t2)
|
||||
@@ -70,26 +80,98 @@ class InfoStatus(QtWidgets.QWidget, Ui_InfoStatus):
|
||||
self.label3.setToolTip(t3)
|
||||
self.val4.setToolTip(t4)
|
||||
self.label4.setToolTip(t4)
|
||||
self.val5.setToolTip(t5)
|
||||
self.label5.setToolTip(t5)
|
||||
|
||||
def update(self, files=0, albums=0, pending_files=0, pending_requests=0):
|
||||
def update(self, files=0, albums=0, pending_files=0, pending_requests=0, progress=0):
|
||||
self.set_files(files)
|
||||
self.set_albums(albums)
|
||||
self.set_pending_files(pending_files)
|
||||
self.set_pending_requests(pending_requests)
|
||||
|
||||
def set_files(self, num):
|
||||
self.val1.setText(str(num))
|
||||
# estimate eta
|
||||
total_pending = pending_files + pending_requests
|
||||
last_pending = self._last_pending_files + self._last_pending_requests
|
||||
|
||||
def set_albums(self, num):
|
||||
if self._max_pending_files == 0 or (self._max_pending_files > 0
|
||||
and self._last_pending_files == 0
|
||||
and pending_files == 0):
|
||||
# update starting timestamp when pending_files appear in order to discard the idle time
|
||||
# and when all previous file requests already finished but network requests still ongoing
|
||||
self.reset_file_counters()
|
||||
|
||||
previous_done_files = max(0, self._max_pending_files - self._last_pending_files)
|
||||
previous_done_requests = max(0, self._max_pending_requests - self._last_pending_requests)
|
||||
self._max_pending_files = max(self._max_pending_files, previous_done_files + pending_files)
|
||||
self._max_pending_requests = max(self._max_pending_requests, previous_done_requests + pending_requests)
|
||||
self._last_pending_files = pending_files
|
||||
self._last_pending_requests = pending_requests
|
||||
|
||||
if total_pending == 0 or (self._max_pending_files + self._max_pending_requests <= 1):
|
||||
self.reset_counters()
|
||||
self.hide_eta()
|
||||
return
|
||||
|
||||
if total_pending != last_pending:
|
||||
current_time = time.time()
|
||||
|
||||
# time since we started processing this batch
|
||||
diff_time = max(0.1, current_time - self._prev_time) # denominator can't be 0
|
||||
previous_done_files = max(1, previous_done_files) # denominator can't be 0
|
||||
|
||||
# we estimate based on the time per file * number of pending files + 1 second per additional request
|
||||
file_eta_seconds = (diff_time / previous_done_files) * pending_files + pending_requests
|
||||
|
||||
# we assume additional network requests based on the ratio of requests/files * pending files
|
||||
# to estimate an upper bound (e.g. fetch cover, lookup, scan)
|
||||
network_eta_seconds = pending_requests + (previous_done_requests / previous_done_files) * pending_files
|
||||
|
||||
# general eta (biased towards whatever takes longer)
|
||||
eta_seconds = max(network_eta_seconds, file_eta_seconds)
|
||||
|
||||
# estimate progress
|
||||
self._last_progress = diff_time / (diff_time + eta_seconds)
|
||||
self.set_eta(eta_seconds)
|
||||
|
||||
def reset_counters(self):
|
||||
self._last_progress = 0
|
||||
self._max_pending_requests = 0
|
||||
self._last_pending_requests = 0
|
||||
self.reset_file_counters()
|
||||
|
||||
def reset_file_counters(self):
|
||||
self._max_pending_files = 0
|
||||
self._last_pending_files = 0
|
||||
self._prev_time = time.time()
|
||||
|
||||
def get_progress(self):
|
||||
return self._last_progress
|
||||
|
||||
def set_eta(self, eta_seconds):
|
||||
if eta_seconds > 0:
|
||||
self.val1.setText(get_timestamp(eta_seconds))
|
||||
self.val1.show()
|
||||
self.label1.show()
|
||||
else:
|
||||
self.hide_eta()
|
||||
|
||||
def hide_eta(self):
|
||||
self.val1.hide()
|
||||
self.label1.hide()
|
||||
|
||||
def set_files(self, num):
|
||||
self.val2.setText(str(num))
|
||||
|
||||
def set_pending_files(self, num):
|
||||
def set_albums(self, num):
|
||||
self.val3.setText(str(num))
|
||||
|
||||
def set_pending_files(self, num):
|
||||
self.val4.setText(str(num))
|
||||
|
||||
def set_pending_requests(self, num):
|
||||
if num <= 0:
|
||||
enabled = QtGui.QIcon.Disabled
|
||||
else:
|
||||
enabled = QtGui.QIcon.Normal
|
||||
self.label4.setPixmap(self.icon_download.pixmap(self._size, enabled))
|
||||
self.val4.setText(str(num))
|
||||
self.label5.setPixmap(self.icon_download.pixmap(self._size, enabled))
|
||||
self.val5.setText(str(num))
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
# Copyright (C) 2018 virusMac
|
||||
# Copyright (C) 2018, 2021 Bob Swift
|
||||
# Copyright (C) 2019 Timur Enikeev
|
||||
# Copyright (C) 2020 Gabriel Ferreira
|
||||
# Copyright (C) 2020-2021 Gabriel Ferreira
|
||||
# Copyright (C) 2021 Petit Minion
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
@@ -383,6 +383,7 @@ class MainWindow(QtWidgets.QMainWindow, PreserveGeometry):
|
||||
"""Creates a new status bar."""
|
||||
self.statusBar().showMessage(_("Ready"))
|
||||
infostatus = InfoStatus(self)
|
||||
self._progress = infostatus.get_progress
|
||||
self.listening_label = QtWidgets.QLabel()
|
||||
self.listening_label.setVisible(False)
|
||||
self.listening_label.setToolTip("<qt/>" + _(
|
||||
@@ -404,10 +405,9 @@ class MainWindow(QtWidgets.QMainWindow, PreserveGeometry):
|
||||
total_albums = len(self.tagger.albums)
|
||||
pending_files = File.num_pending_files
|
||||
pending_requests = self.tagger.webservice.num_pending_web_requests
|
||||
|
||||
for indicator in self.status_indicators:
|
||||
indicator.update(files=total_files, albums=total_albums,
|
||||
pending_files=pending_files, pending_requests=pending_requests)
|
||||
pending_files=pending_files, pending_requests=pending_requests, progress=self._progress())
|
||||
|
||||
def update_statusbar_listen_port(self, listen_port):
|
||||
if listen_port:
|
||||
|
||||
@@ -36,13 +36,11 @@ class AbstractProgressStatusIndicator:
|
||||
self._max_pending = 0
|
||||
self._last_pending = 0
|
||||
|
||||
def update(self, files=0, albums=0, pending_files=0, pending_requests=0):
|
||||
def update(self, files=0, albums=0, pending_files=0, pending_requests=0, progress=0):
|
||||
if not self.is_available:
|
||||
return
|
||||
|
||||
# Weight pending network requests higher as they are slower then file loads
|
||||
total_pending = pending_files + 10 * pending_requests
|
||||
|
||||
total_pending = pending_files + pending_requests
|
||||
if total_pending == self._last_pending:
|
||||
return # No changes, avoid update
|
||||
|
||||
@@ -55,7 +53,6 @@ class AbstractProgressStatusIndicator:
|
||||
self.hide_progress()
|
||||
return
|
||||
|
||||
progress = 1 - (total_pending / self._max_pending)
|
||||
self.set_progress(progress)
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
# Automatically generated - don't edit.
|
||||
# Use `python setup.py build_ui` to update it.
|
||||
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class Ui_InfoStatus(object):
|
||||
def setupUi(self, InfoStatus):
|
||||
InfoStatus.setObjectName("InfoStatus")
|
||||
InfoStatus.resize(350, 24)
|
||||
InfoStatus.resize(683, 145)
|
||||
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
@@ -31,6 +33,7 @@ class Ui_InfoStatus(object):
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.label1.sizePolicy().hasHeightForWidth())
|
||||
self.label1.setSizePolicy(sizePolicy)
|
||||
self.label1.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.label1.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.label1.setTextFormat(QtCore.Qt.AutoText)
|
||||
self.label1.setScaledContents(False)
|
||||
@@ -82,6 +85,18 @@ class Ui_InfoStatus(object):
|
||||
self.label4.setScaledContents(False)
|
||||
self.label4.setObjectName("label4")
|
||||
self.horizontalLayout.addWidget(self.label4)
|
||||
self.val5 = QtWidgets.QLabel(InfoStatus)
|
||||
self.val5.setMinimumSize(QtCore.QSize(40, 0))
|
||||
self.val5.setText("")
|
||||
self.val5.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
|
||||
self.val5.setObjectName("val5")
|
||||
self.horizontalLayout.addWidget(self.val5)
|
||||
self.label5 = QtWidgets.QLabel(InfoStatus)
|
||||
self.label5.setMinimumSize(QtCore.QSize(0, 0))
|
||||
self.label5.setText("")
|
||||
self.label5.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
|
||||
self.label5.setObjectName("label5")
|
||||
self.horizontalLayout.addWidget(self.label5)
|
||||
|
||||
self.retranslateUi(InfoStatus)
|
||||
QtCore.QMetaObject.connectSlotsByName(InfoStatus)
|
||||
@@ -89,4 +104,3 @@ class Ui_InfoStatus(object):
|
||||
def retranslateUi(self, InfoStatus):
|
||||
_translate = QtCore.QCoreApplication.translate
|
||||
InfoStatus.setWindowTitle(_("Form"))
|
||||
|
||||
|
||||
49
picard/util/time.py
Normal file
49
picard/util/time.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Picard, the next-generation MusicBrainz tagger
|
||||
#
|
||||
# Copyright (C) 2021 Laurent Monin
|
||||
# Copyright (C) 2021 Gabriel Ferreira
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
|
||||
SECS_IN_DAY = 86400
|
||||
SECS_IN_HOUR = 3600
|
||||
SECS_IN_MINUTE = 60
|
||||
|
||||
|
||||
def euclidian_div(a, b):
|
||||
return a // b, a % b
|
||||
|
||||
|
||||
def seconds_to_dhms(seconds):
|
||||
days, seconds = euclidian_div(seconds, SECS_IN_DAY)
|
||||
hours, seconds = euclidian_div(seconds, SECS_IN_HOUR)
|
||||
minutes, seconds = euclidian_div(seconds, SECS_IN_MINUTE)
|
||||
return days, hours, minutes, seconds
|
||||
|
||||
|
||||
def get_timestamp(seconds):
|
||||
d, h, m, s = seconds_to_dhms(seconds)
|
||||
if d > 0:
|
||||
return _("%.2dd %.2dh") % (d, h)
|
||||
if h > 0:
|
||||
return _("%.2dh %.2dm") % (h, m)
|
||||
if m > 0:
|
||||
return _("%.2dm %.2ds") % (m, s)
|
||||
if s > 0:
|
||||
return _("%.2ds") % s
|
||||
return ''
|
||||
BIN
resources/images/16x16/hourglass.png
Normal file
BIN
resources/images/16x16/hourglass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 826 B |
BIN
resources/images/16x16/hourglass@2x.png
Normal file
BIN
resources/images/16x16/hourglass@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
BIN
resources/images/22x22/hourglass.png
Normal file
BIN
resources/images/22x22/hourglass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 983 B |
BIN
resources/images/22x22/hourglass@2x.png
Normal file
BIN
resources/images/22x22/hourglass@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -36,6 +36,8 @@
|
||||
<file>images/16x16/go-previous@2x.png</file>
|
||||
<file>images/16x16/go-up.png</file>
|
||||
<file>images/16x16/go-up@2x.png</file>
|
||||
<file>images/16x16/hourglass.png</file>
|
||||
<file>images/16x16/hourglass@2x.png</file>
|
||||
<file>images/16x16/list-remove.png</file>
|
||||
<file>images/16x16/list-remove@2x.png</file>
|
||||
<file>images/16x16/lookup-musicbrainz.png</file>
|
||||
@@ -97,6 +99,8 @@
|
||||
<file>images/22x22/fingerprint@2x.png</file>
|
||||
<file>images/22x22/folder.png</file>
|
||||
<file>images/22x22/folder@2x.png</file>
|
||||
<file>images/22x22/hourglass.png</file>
|
||||
<file>images/22x22/hourglass@2x.png</file>
|
||||
<file>images/22x22/list-remove.png</file>
|
||||
<file>images/22x22/list-remove@2x.png</file>
|
||||
<file>images/22x22/lookup-musicbrainz.png</file>
|
||||
|
||||
58
test/test_util_time.py
Normal file
58
test/test_util_time.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Picard, the next-generation MusicBrainz tagger
|
||||
#
|
||||
# Copyright (C) 2021 Gabriel Ferreira
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
|
||||
from test.picardtestcase import PicardTestCase
|
||||
|
||||
from picard.util.time import (
|
||||
get_timestamp,
|
||||
seconds_to_dhms,
|
||||
)
|
||||
|
||||
|
||||
class UtilTimeTest(PicardTestCase):
|
||||
|
||||
def test_seconds_to_dhms(self):
|
||||
self.assertTupleEqual(seconds_to_dhms(0), (0, 0, 0, 0))
|
||||
self.assertTupleEqual(seconds_to_dhms(1), (0, 0, 0, 1))
|
||||
self.assertTupleEqual(seconds_to_dhms(60), (0, 0, 1, 0))
|
||||
self.assertTupleEqual(seconds_to_dhms(61), (0, 0, 1, 1))
|
||||
self.assertTupleEqual(seconds_to_dhms(120), (0, 0, 2, 0))
|
||||
self.assertTupleEqual(seconds_to_dhms(3599), (0, 0, 59, 59))
|
||||
self.assertTupleEqual(seconds_to_dhms(3600), (0, 1, 0, 0))
|
||||
self.assertTupleEqual(seconds_to_dhms(3601), (0, 1, 0, 1))
|
||||
self.assertTupleEqual(seconds_to_dhms(3660), (0, 1, 1, 0))
|
||||
self.assertTupleEqual(seconds_to_dhms(3661), (0, 1, 1, 1))
|
||||
self.assertTupleEqual(seconds_to_dhms(86399), (0, 23, 59, 59))
|
||||
self.assertTupleEqual(seconds_to_dhms(86400), (1, 0, 0, 0))
|
||||
|
||||
def test_get_timestamp(self):
|
||||
self.assertEqual(get_timestamp(0), "")
|
||||
self.assertEqual(get_timestamp(1), "01s")
|
||||
self.assertEqual(get_timestamp(60), "01m 00s")
|
||||
self.assertEqual(get_timestamp(61), "01m 01s")
|
||||
self.assertEqual(get_timestamp(120), "02m 00s")
|
||||
self.assertEqual(get_timestamp(3599), "59m 59s")
|
||||
self.assertEqual(get_timestamp(3600), "01h 00m")
|
||||
self.assertEqual(get_timestamp(3601), "01h 00m")
|
||||
self.assertEqual(get_timestamp(3660), "01h 01m")
|
||||
self.assertEqual(get_timestamp(3661), "01h 01m")
|
||||
self.assertEqual(get_timestamp(86399), "23h 59m")
|
||||
self.assertEqual(get_timestamp(86400), "01d 00h")
|
||||
@@ -6,8 +6,8 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>350</width>
|
||||
<height>24</height>
|
||||
<width>683</width>
|
||||
<height>145</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
@@ -29,7 +29,16 @@
|
||||
<property name="spacing">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
@@ -56,6 +65,12 @@
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::NoFrame</enum>
|
||||
</property>
|
||||
@@ -160,6 +175,41 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="val5">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label5">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
|
||||
Reference in New Issue
Block a user