├── __init__.py
├── utils
├── __init__.py
├── task.py
├── filehandler.py
└── utilities.py
├── GUI
├── Alert.ico
├── Grabber.ico
├── Icon_checked.ico
├── down-arrow2.ico
├── Icon_unchecked.ico
└── down-arrow2-clicked.ico
├── Modules
├── __init__.py
├── dropdown_widget.py
├── main_window.py
├── lineedit.py
├── text_manager.py
├── dialog.py
├── about_tab.py
├── parameter_tab.py
├── download_manager.py
├── download_tab.py
├── parameter_tree.py
└── download_element.py
├── __main__.py
├── README.md
├── LICENSE
└── core.py
/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/GUI/Alert.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Thomasedv/Grabber/HEAD/GUI/Alert.ico
--------------------------------------------------------------------------------
/GUI/Grabber.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Thomasedv/Grabber/HEAD/GUI/Grabber.ico
--------------------------------------------------------------------------------
/GUI/Icon_checked.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Thomasedv/Grabber/HEAD/GUI/Icon_checked.ico
--------------------------------------------------------------------------------
/GUI/down-arrow2.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Thomasedv/Grabber/HEAD/GUI/down-arrow2.ico
--------------------------------------------------------------------------------
/GUI/Icon_unchecked.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Thomasedv/Grabber/HEAD/GUI/Icon_unchecked.ico
--------------------------------------------------------------------------------
/GUI/down-arrow2-clicked.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Thomasedv/Grabber/HEAD/GUI/down-arrow2-clicked.ico
--------------------------------------------------------------------------------
/utils/task.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QRunnable
2 |
3 |
4 | class Task(QRunnable):
5 | """Set up to be able to run a function with args. For use with threadpool """
6 | def __init__(self, func, *args):
7 | super(Task, self).__init__()
8 | self.fn = func
9 | self.args = args
10 |
11 | def run(self):
12 | self.fn(*self.args)
13 |
--------------------------------------------------------------------------------
/Modules/__init__.py:
--------------------------------------------------------------------------------
1 | from Modules.about_tab import AboutTab
2 | from Modules.about_tab import AboutTab
3 | from Modules.dialog import Dialog
4 | from Modules.download_element import Download
5 | from Modules.download_manager import Downloader
6 | from Modules.download_tab import MainTab
7 | from Modules.main_window import MainWindow
8 | from Modules.parameter_tab import ParameterTab
9 | from Modules.parameter_tree import ParameterTree
10 | from Modules.text_manager import TextTab
11 |
--------------------------------------------------------------------------------
/Modules/dropdown_widget.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import Qt, pyqtSignal
2 | from PyQt5.QtGui import QCursor
3 | from PyQt5.QtWidgets import QAction, QMenu, QComboBox
4 |
5 |
6 | class DropDown(QComboBox):
7 | """Dropdown for selecting profiles"""
8 | deleteItem = pyqtSignal()
9 |
10 | def __init__(self, parent=None):
11 | super(DropDown, self).__init__(parent=parent)
12 |
13 | self.setDuplicatesEnabled(False)
14 | self.setContextMenuPolicy(Qt.CustomContextMenu)
15 | self.customContextMenuRequested.connect(self.contextMenu)
16 |
17 | def contextMenu(self, event):
18 | menu = QMenu(self)
19 |
20 | delete_action = QAction('Delete profile')
21 | delete_action.triggered.connect(self.delete_option)
22 | menu.addAction(delete_action)
23 |
24 | menu.exec(QCursor.pos())
25 |
26 | def delete_option(self):
27 | self.deleteItem.emit()
28 |
--------------------------------------------------------------------------------
/Modules/main_window.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import pyqtSlot, pyqtSignal, QEvent
2 | from PyQt5.QtWidgets import QMainWindow
3 |
4 |
5 | class MainWindow(QMainWindow):
6 | """Top level window, custom close method to ensure safe exit."""
7 | gotfocus = pyqtSignal()
8 | resizedByUser = pyqtSignal()
9 | onclose = pyqtSignal()
10 |
11 | def __init__(self, parent=None):
12 | super(MainWindow, self).__init__(parent=parent)
13 | self.let_close = False
14 |
15 | self.installEventFilter(self)
16 |
17 | def eventFilter(self, widget, event):
18 | if event.type() == QEvent.WindowActivate:
19 | self.gotfocus.emit()
20 | return True
21 | return False
22 |
23 | def resizeEvent(self, resize_event):
24 | self.resizedByUser.emit()
25 | super().resizeEvent(resize_event)
26 |
27 | def closeEvent(self, event):
28 | self.onclose.emit()
29 | if self.let_close:
30 | event.accept()
31 | else:
32 | event.ignore()
33 |
34 | @pyqtSlot()
35 | def closeE(self):
36 | self.let_close = True
37 | self.close()
38 |
--------------------------------------------------------------------------------
/Modules/lineedit.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from PyQt5.QtCore import QTimer, Qt
4 | from PyQt5.QtWidgets import QLineEdit
5 |
6 |
7 | class LineEdit(QLineEdit):
8 | def __init__(self, parent=None):
9 | super(LineEdit, self).__init__(parent)
10 | self.text_focus = False
11 | # Clicking automatically selects all text, this allows clicks and drag
12 | # to highlight part of a url better
13 | self.clicklength = QTimer()
14 | self.clicklength.setSingleShot(True)
15 | self.clicklength.setTimerType(Qt.PreciseTimer)
16 |
17 | def mousePressEvent(self, e):
18 | if not self.text_focus:
19 | self.clicklength.start(120)
20 | self.text_focus = True
21 | else:
22 | super(LineEdit, self).mousePressEvent(e)
23 |
24 | def mouseReleaseEvent(self, e):
25 | if self.clicklength.isActive():
26 | self.selectAll()
27 | super(LineEdit, self).mouseReleaseEvent(e)
28 |
29 | def focusOutEvent(self, e):
30 | super(LineEdit, self).focusOutEvent(e)
31 | self.text_focus = False
32 |
33 |
34 | if __name__ == '__main__':
35 | from PyQt5.QtWidgets import QApplication
36 |
37 | app = QApplication(sys.argv)
38 | Checkbox = LineEdit()
39 | Checkbox.show()
40 |
41 | app.exec_()
42 |
--------------------------------------------------------------------------------
/Modules/text_manager.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtGui import QKeySequence
2 | from PyQt5.QtWidgets import QPushButton, QHBoxLayout, QVBoxLayout, QTextEdit, QLabel, QWidget, QShortcut
3 |
4 | from utils.utilities import FONT_CONSOLAS
5 |
6 |
7 | class TextTab(QWidget):
8 |
9 | def __init__(self, parent=None):
10 | """Handles text files for batch downloads from a list of links."""
11 |
12 | super().__init__(parent=parent)
13 |
14 | # Denotes if the textfile is saved.
15 | self.SAVED = True
16 |
17 | self.textedit = QTextEdit()
18 | self.textedit.setObjectName('TextFileEdit')
19 | self.textedit.setFont(FONT_CONSOLAS)
20 | self.textedit.setAcceptRichText(False)
21 | self.textedit.verticalScrollBar().setObjectName('main')
22 |
23 | # Create load button and label.
24 | self.label = QLabel('Add videos to textfile:')
25 | self.loadButton = QPushButton('Load file')
26 | self.saveButton = QPushButton('Save file')
27 | self.saveButton.setDisabled(True)
28 |
29 | self.textedit.textChanged.connect(self.enable_saving)
30 |
31 | # Other functionality.
32 | self.shortcut = QShortcut(QKeySequence("Ctrl+S"), self.textedit)
33 | self.shortcut.activated.connect(self.saveButton.click)
34 |
35 | # Layout
36 | # Create horizontal layout.
37 | self.QH = QHBoxLayout()
38 |
39 | # Filling horizontal layout
40 | self.QH.addWidget(self.label)
41 | self.QH.addStretch(1)
42 | self.QH.addWidget(self.loadButton)
43 | self.QH.addWidget(self.saveButton)
44 |
45 | # Horizontal layout with a textedit and a button.
46 | self.VB = QVBoxLayout()
47 | self.VB.addLayout(self.QH)
48 | self.VB.addWidget(self.textedit)
49 | self.setLayout(self.VB)
50 |
51 | def enable_saving(self):
52 | if not self.textedit.toPlainText():
53 | self.SAVED = True
54 |
55 | else:
56 | self.saveButton.setDisabled(False)
57 | self.SAVED = False
58 |
--------------------------------------------------------------------------------
/Modules/dialog.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from PyQt5.QtWidgets import QPushButton, QLabel, QLineEdit, \
4 | QGridLayout, QDialog
5 |
6 | from utils.utilities import color_text
7 |
8 |
9 | class Dialog(QDialog):
10 |
11 | def __init__(self, parent=None, name: str = '', description: str = '', allow_empty=False, password=False):
12 | super(Dialog, self).__init__(parent)
13 | self.option = QLineEdit()
14 | if password:
15 | self.option.setEchoMode(QLineEdit.Password)
16 | self.allow_empty = allow_empty
17 |
18 | self.label = QLabel(color_text('Insert option:', 'limegreen'))
19 | self.name_label = QLabel(color_text(name + ':', 'limegreen'))
20 | self.tooltip = QLabel(description)
21 | self.ok_button = QPushButton('Ok', self)
22 | self.ok_button.setFixedSize(self.ok_button.sizeHint())
23 | self.ok_button.setDisabled(not allow_empty)
24 | self.ok_button.clicked.connect(self.accept)
25 |
26 | self.cancel_button = QPushButton('Cancel', self)
27 | self.cancel_button.setFixedSize(self.cancel_button.sizeHint())
28 | self.cancel_button.clicked.connect(self.reject)
29 |
30 | layout = QGridLayout(self)
31 | layout.addWidget(self.name_label, 0, 0, 1, 3)
32 | layout.addWidget(self.tooltip, 1, 0, 1, 3)
33 |
34 | layout.addWidget(self.label, 2, 0, 1, 3)
35 | layout.addWidget(self.option, 3, 0, 1, 3)
36 |
37 | layout.setColumnStretch(0, 1)
38 | layout.setColumnStretch(1, 0)
39 | layout.setColumnStretch(2, 0)
40 | layout.addWidget(self.ok_button, 4, 1)
41 | layout.addWidget(self.cancel_button, 4, 2)
42 |
43 | self.option.textChanged.connect(self.input_check)
44 | self.setFixedHeight(self.sizeHint().height())
45 | self.setFixedWidth(self.sizeHint().width())
46 | self.option.setFocus()
47 |
48 | def input_check(self):
49 | if not self.allow_empty:
50 | test = re.match(r'(^ *$)', self.option.text())
51 | if test is not None:
52 | self.ok_button.setDisabled(True)
53 | else:
54 | self.ok_button.setDisabled(False)
55 |
--------------------------------------------------------------------------------
/__main__.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 |
5 | # If Grabber is run from start menu, working directory is set to system32, this changes to correct working directory.
6 | if os.getcwd().lower() == r'c:\windows\system32'.lower():
7 |
8 | # Check if running as script, or executable.
9 | if getattr(sys, 'frozen', False):
10 | application_path = os.path.dirname(sys.executable)
11 | else:
12 | application_path = os.path.dirname(__file__)
13 | os.chdir(os.path.realpath(application_path))
14 |
15 | from PyQt5.QtCore import Qt
16 | from PyQt5.QtWidgets import QApplication, QMessageBox
17 |
18 | from core import GUI
19 | from utils.filehandler import FileHandler
20 | from utils.utilities import SettingsError, ProfileLoadError
21 |
22 |
23 | def main():
24 | # Main loop
25 | EXIT_CODE = 1
26 |
27 | while True:
28 | try:
29 | app = QApplication(sys.argv)
30 | program = GUI()
31 |
32 | EXIT_CODE = app.exec_()
33 | app = None # Required! Clears memory. Crashes on restart without it.
34 |
35 | if EXIT_CODE == GUI.EXIT_CODE_REBOOT:
36 | continue
37 |
38 | # If corrupt or wrong settings or profile files
39 | except (SettingsError, ProfileLoadError, json.decoder.JSONDecodeError) as e:
40 | if isinstance(e, ProfileLoadError):
41 | file = 'profiles file'
42 | else:
43 | file = 'settings file'
44 |
45 | warning = QMessageBox.warning(None,
46 | f'Corruption of {file}!',
47 | ''.join([str(e), '\nRestore to defaults?']),
48 | buttons=QMessageBox.Yes | QMessageBox.No)
49 |
50 | # If yes (to reset), do settings or profile reset.
51 | if warning == QMessageBox.Yes:
52 | filehandler = FileHandler()
53 | if isinstance(e, ProfileLoadError):
54 | filehandler.save_profiles({})
55 | else:
56 | setting = filehandler.load_settings(reset=True)
57 | filehandler.save_settings(setting.settings_data)
58 |
59 | app = None # Ensures the app instance is properly cleared!
60 | continue
61 |
62 | sys.exit(EXIT_CODE)
63 |
64 |
65 | if __name__ == '__main__':
66 | QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
67 | QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
68 | main()
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Grabber
2 | A wrapper for Youtube-dl for Windows.
3 |
4 | 
5 |
6 | **Requires you to have youtube-dl.exe in the same folder as this program(Grabber), or in PATH.
7 | If you want to convert the videos, or otherwise use features of youtube-dl that require ffmpeg,
8 | that also has to be in the same folder (or path) as Grabber.**
9 |
10 | #### Installation guide
11 |
12 | 1. Download Grabber.exe and place in a folder.
13 | 2. Download youtube-dl.exe and *either* place it in the same folder as above, or in PATH, if you know how.
14 | 3. If you want to convert to audio, you need to also put ffmpeg.exe and the other included executables that follow into the same folder as Grabber.exe or in PATH.
15 | 4. If nothing happens when you try to start a download, youtube-dl likely fails because you **need** this installed:
16 | https://www.microsoft.com/en-US/download/details.aspx?id=5555
17 |
18 | Youtube-dl download:
19 | https://ytdl-org.github.io/youtube-dl/download.html
20 |
21 |
22 | If you don't put anything in path, this is what your folder should have:
23 | - Grabber.exe
24 | - ffmpeg.exe
25 | - ffprobe.exe
26 | - ffplay.exe (is included in ffmpeg bin folder, but not actually needed)
27 | - youtube-dl.exe (if you use youtube-dlp, just renamed it to youtube-dl and it will work)
28 |
29 | **Remember, if nothing happens when you try a download, to install "Microsoft Visual C++ 2010 Redistributable Package (x86)" from the microsoft link above!** This is required by youtube-dl to run.
30 |
31 | ______
32 |
33 | ### Features
34 |
35 | The core of Grabber is to let you use Youtube-dl more easily on a regular basis. It has easy checkboxes for adding the parameters you'd normally use.
36 |
37 | Some core highlights:
38 | * Serial downloads, or parallel downloads (up to 4, currently hard coded to that, make an issue if u want it changed to more!)
39 | * You can queue up as many downlaods as you need, regardless of serial download or parallel mode. The first ones in are the first ones to be started.
40 | * Automatically highlight the URL text when the window get's focus or when you click the url box.
41 |
42 | This means you copy any URL, alt-tab(go to Grabber), Paste the ULR (Ctrl+V), and press Enter. No clicking with the mouse needed!
43 | * Built in super simple textfile editor, and the option to let youtube-dl use the textfile for downloading url.
44 | * Profiles, so when you want to change something around, it's not too many clicks away!
45 | * Favorite parameters, so they are up and front, to easier tweak often used parameters.
46 | * Right-click to add or remove options to a parameter, or a favorite the parameter.
47 | * Right click the folder path at the top of the param tab to go to the folder.
48 | * Pro-tip: Many sites change often, and causes youtube-dl to break, so update often using the Update button in the About tab.
49 |
50 | ______
51 |
52 | Requirements to use source code:
53 |
54 | * Python 3.6+
55 | * PyQt5 5.9 (Earlier version might work too, worked fine with 5.8 before i upgraded.)
56 |
57 | Made with PyQt5 https://www.riverbankcomputing.com/software/pyqt/intro
58 |
59 |
60 |  
61 |  
62 |
63 |
64 | Current updates, if desired, that I could implement:
65 | * Custom UI colors
66 |
67 |
68 |
--------------------------------------------------------------------------------
/Modules/about_tab.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import QPushButton, QHBoxLayout, QVBoxLayout, QLabel, QWidget, QTextBrowser, QLineEdit
2 |
3 |
4 | class AboutTab(QWidget):
5 |
6 | def __init__(self, settings, parent=None):
7 | super(AboutTab, self).__init__(parent=parent)
8 |
9 | # Indicates if license is shown.
10 | self.license_shown = False
11 |
12 | # Select folder for textfile to use as download list
13 | self.location_btn = QPushButton('Browse\nTextfile')
14 | self.location_btn.setMinimumHeight(30)
15 |
16 | self.update_btn = QPushButton('Update\nYoutube-dl')
17 | self.update_btn.setMinimumHeight(30)
18 |
19 | self.license_btn = QPushButton('License')
20 |
21 | # Debugging
22 | self.dirinfo_btn = QPushButton('Dirinfo')
23 | self.debug_info = QPushButton('Debug:\nFalse')
24 | self.debug_info.setMinimumHeight(30)
25 |
26 | # Parallel / Series toggle for youtube instances
27 | self.dl_mode_btn = QPushButton(('Singular' if not settings.user_options['parallel'] else 'Parallel')
28 | + '\nDownloads')
29 | self.dl_mode_btn.setMinimumHeight(30)
30 |
31 | # Reset settings, (requires restart)
32 | self.reset_btn = QPushButton('Reset\n settings')
33 | self.reset_btn.setMinimumHeight(30)
34 |
35 | # Lineedit to show path to text file. (Can be changed later to use same path naming as other elements.)
36 | self.textfile_url = QLineEdit()
37 | self.textfile_url.setReadOnly(True) # Read only
38 | self.textfile_url.setText(settings.user_options['multidl_txt']) # Path from settings.
39 |
40 | self.txt_label = QLabel('Textfile:')
41 |
42 | # Textbrowser to adds some info about Grabber.
43 | self.textbrowser = QTextBrowser()
44 | self.textbrowser.setObjectName('AboutText')
45 | self.textbrowser.setOpenExternalLinks(True)
46 |
47 | # Sets textbroswer content at startup
48 | self.set_standard_text()
49 |
50 | self.QH = QHBoxLayout()
51 | self.QV = QVBoxLayout()
52 |
53 | self.QH.addWidget(self.textbrowser)
54 |
55 | self.QV.addWidget(self.dl_mode_btn)
56 | self.QV.addWidget(self.update_btn)
57 | self.QV.addSpacing(15)
58 | self.QV.addWidget(self.debug_info)
59 | self.QV.addWidget(self.dirinfo_btn)
60 | self.QV.addWidget(self.license_btn)
61 | self.QV.addWidget(self.reset_btn)
62 |
63 | self.QV.addStretch(1)
64 |
65 | self.QH.addLayout(self.QV)
66 |
67 | self.topQH = QHBoxLayout()
68 | self.topQH.addWidget(self.txt_label)
69 | self.topQH.addWidget(self.textfile_url)
70 | self.topQH.addWidget(self.location_btn)
71 |
72 | self.topQV = QVBoxLayout()
73 | self.topQV.addLayout(self.topQH)
74 | self.topQV.addLayout(self.QH)
75 |
76 | self.license_btn.clicked.connect(self.read_license)
77 |
78 | self.setLayout(self.topQV)
79 |
80 | def read_license(self):
81 | if not self.license_shown:
82 | content = self.window().file_handler.read_textfile(self.window().license_path)
83 | if content is None:
84 | self.parent().alert_message('Error!', 'Failed to find/read license!')
85 | return
86 | self.textbrowser.clear()
87 | self.textbrowser.setText(content)
88 | self.license_shown = True
89 |
90 | else:
91 | self.set_standard_text()
92 | self.license_shown = False
93 |
94 | def set_standard_text(self):
95 | self.textbrowser.setText('In-development (on my free time) version of a Youtube-dl GUI. \n'
96 | 'I\'m just a developer for fun.\nThis is licensed under GPL 3.\n')
97 | self.textbrowser.append('Source on Github: '
98 | ''
100 | 'Website'
101 | '')
102 | self.textbrowser.append('
PyQt5 use for making this: '
103 | ''
105 | 'Website'
106 | '')
107 |
--------------------------------------------------------------------------------
/Modules/parameter_tab.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import Qt
2 | from PyQt5.QtWidgets import QWidget, QPushButton, QHBoxLayout, QVBoxLayout, QLabel, QLineEdit, \
3 | QAction, QFrame
4 |
5 | from Modules.parameter_tree import ParameterTree, DATA_SLOT
6 | from utils.utilities import SettingsError
7 |
8 |
9 | class ParameterTab(QWidget):
10 |
11 | def __init__(self, options, favorites, settings, parent=None):
12 | super().__init__(parent=parent)
13 |
14 | # Building widget tab 2.
15 |
16 | # Button for selecting download location.
17 | self.browse_btn = QPushButton('Browse')
18 |
19 | self.save_profile_btn = QPushButton('Save Profile')
20 | self.save_profile_btn.resize(self.save_profile_btn.sizeHint())
21 |
22 | self.download_label = QLabel('Download to:')
23 |
24 | self.favlabel = QLabel('Favorites:')
25 | self.optlabel = QLabel('All settings:')
26 |
27 | # LineEdit for download location.
28 | self.download_lineedit = QLineEdit()
29 | self.download_lineedit.setReadOnly(True)
30 | self.download_lineedit.setFocusPolicy(Qt.NoFocus)
31 |
32 | if settings.is_activate('Download location'):
33 | self.download_lineedit.setText('')
34 | self.download_lineedit.setToolTip(settings.get_active_setting('Download location'))
35 | else:
36 | self.download_lineedit.setText('DL')
37 | self.download_lineedit.setToolTip('Default download location.')
38 | self.download_lineedit.setContextMenuPolicy(Qt.ActionsContextMenu)
39 |
40 | # Sets up the parameter tree.
41 | self.options = ParameterTree(options, self)
42 | self.favorites = ParameterTree(favorites, self)
43 | self.favorites.favorite = True
44 |
45 | # Can only be toggled in settings manually
46 | if settings.user_options['show_collapse_arrows']:
47 | self.options.setRootIsDecorated(True)
48 | self.favorites.setRootIsDecorated(True)
49 | else:
50 | self.options.setRootIsDecorated(False)
51 | self.favorites.setRootIsDecorated(False)
52 |
53 | self.open_folder_action = QAction('Open location', parent=self.download_lineedit)
54 | self.copy_action = QAction('Copy', parent=self.download_lineedit)
55 |
56 | self.download_lineedit.addAction(self.open_folder_action)
57 | self.download_lineedit.addAction(self.copy_action)
58 |
59 | # Layout tab 2.
60 |
61 | self.QH = QHBoxLayout()
62 |
63 | # Adds widgets to the horizontal layout. label, lineedit and button.
64 | self.QH.addWidget(self.download_label)
65 | self.QH.addWidget(self.download_lineedit)
66 | self.QH.addWidget(self.browse_btn)
67 | self.QH.addWidget(self.save_profile_btn)
68 |
69 | self.QV = QVBoxLayout()
70 | self.QV.addLayout(self.QH, stretch=0)
71 |
72 | self.fav_frame = QFrame()
73 | self.opt_frame2 = QFrame()
74 |
75 | self.opt_frame2.setFrameShape(QFrame.HLine)
76 | self.fav_frame.setFrameShape(QFrame.HLine)
77 |
78 | self.fav_frame.setLineWidth(2)
79 | self.opt_frame2.setLineWidth(2)
80 |
81 | self.fav_frame.setObjectName('line')
82 | self.opt_frame2.setObjectName('line')
83 |
84 | self.fav_layout = QVBoxLayout()
85 | self.opt_layout = QVBoxLayout()
86 |
87 | self.fav_layout.setSizeConstraint(QVBoxLayout.SetMinimumSize)
88 | self.fav_layout.addWidget(self.favlabel, stretch=0)
89 | self.fav_layout.addWidget(self.fav_frame, stretch=0)
90 | self.fav_layout.addWidget(self.favorites, stretch=1, alignment=Qt.AlignTop)
91 |
92 | self.opt_layout.addWidget(self.optlabel, stretch=0)
93 | self.opt_layout.addWidget(self.opt_frame2, stretch=0)
94 | self.opt_layout.addWidget(self.options, stretch=1, alignment=Qt.AlignTop)
95 |
96 | self.parameter_layout = QHBoxLayout()
97 | self.parameter_layout.addLayout(self.fav_layout)
98 | self.parameter_layout.addLayout(self.opt_layout)
99 |
100 | self.QV.addLayout(self.parameter_layout)
101 |
102 | self.setLayout(self.QV)
103 |
104 | self.download_option = self.find_download_widget()
105 |
106 | def enable_favorites(self, enable):
107 | if not enable:
108 | self.favorites.hide()
109 | self.fav_frame.hide()
110 | self.favlabel.hide()
111 | else:
112 | self.favorites.show()
113 | self.fav_frame.show()
114 | self.favlabel.show()
115 |
116 | def find_download_widget(self):
117 | """ Finds the download widget. """
118 | # TODO: Refactor to check the settings file/object, not the parameterTrees.
119 | for item in self.favorites.topLevelItems():
120 | if item.data(0, DATA_SLOT) == 'Download location':
121 | self.download_option = item
122 | return item
123 | for item in self.options.topLevelItems():
124 | if item.data(0, DATA_SLOT) == 'Download location':
125 | self.download_option = item
126 | return item
127 | raise SettingsError('No download item found in settings.')
128 |
--------------------------------------------------------------------------------
/Modules/download_manager.py:
--------------------------------------------------------------------------------
1 | from collections import deque
2 |
3 | from PyQt5.QtCore import pyqtSignal, QProcess, QObject
4 |
5 | from Modules.download_element import Download
6 |
7 |
8 | class Downloader(QObject):
9 | """ Handles Download objects, can run in two states, parallel or singular"""
10 | stateChanged = pyqtSignal()
11 |
12 | output = pyqtSignal(str)
13 | clearOutput = pyqtSignal()
14 |
15 | def __init__(self, file_handler, mode=0):
16 | super(Downloader, self).__init__()
17 | self.active_download: Download = None
18 | self._queue = deque()
19 | self.mode = mode
20 |
21 | self.RUNNING = False
22 | self.error_count = 0
23 | self.file_handler = file_handler
24 |
25 | # Download mode
26 | if mode:
27 | self.queue_handler = self._parallel_queue_handler
28 | self.active_download = []
29 | else:
30 | self.queue_handler = self._single_queue_handler
31 | self.active_download = None
32 |
33 | # For parallel downloads
34 | self.active = 0
35 |
36 | def set_mode(self, parallel=False):
37 | if parallel:
38 | self.queue_handler = self._parallel_queue_handler
39 | self.active_download = []
40 | self.active = 0
41 | else:
42 | self.queue_handler = self._single_queue_handler
43 | self.active_download = None
44 |
45 | def restart_current_download(self):
46 | # TODO: Trigger this make trigger for restarting download!
47 | # Only works in single mode!
48 | if self.active_download is not None:
49 | if isinstance(self.active_download, Download) and self.active_download.state() == QProcess.Running:
50 | self.active_download.kill()
51 |
52 | self.active_download.start()
53 | elif isinstance(self.active_download, list):
54 | for i in self.active_download:
55 | i.kill()
56 | i.start()
57 |
58 | def _parallel_queue_handler(self, process_finished=False):
59 | if not self.RUNNING:
60 | self.clearOutput.emit()
61 | # TODO: Error count currently not used. Remove later?
62 | if not self.RUNNING or process_finished or self.active < 4:
63 | if self._queue:
64 | download = self._queue.popleft()
65 | self.active_download.append(download)
66 | try:
67 | download.start_dl()
68 | self.RUNNING = True
69 | self.stateChanged.emit()
70 | except TypeError as e:
71 | self.error_count += 1
72 |
73 | return self.queue_handler(process_finished=True)
74 | self.active += 1
75 | else:
76 |
77 | if not self.active:
78 | self.RUNNING = False
79 | self.stateChanged.emit()
80 |
81 | self.error_count = 0
82 |
83 | def _single_queue_handler(self, process_finished=False):
84 | if not self.RUNNING or process_finished:
85 | # TODO: Detect crash when redistributable C++ is not present, if possible. Needs research.
86 |
87 | if self._queue:
88 | download = self._queue.popleft()
89 | self.active_download = download
90 | try:
91 | download.start_dl()
92 | self.RUNNING = True
93 | self.stateChanged.emit()
94 | except TypeError as e:
95 | self.error_count += 1
96 | return self.queue_handler(process_finished=True)
97 |
98 | else:
99 | self.active_download = None
100 | self.RUNNING = False
101 | self.stateChanged.emit()
102 |
103 | self.error_count = 0
104 |
105 | def program_state_changed(self, program: Download):
106 | """ When a Download stops, this triggers, and callbacks to the queue handler"""
107 | new_state = program.state()
108 | if new_state == QProcess.NotRunning:
109 | program.disconnect()
110 | if self.mode:
111 | self.active -= 1
112 | self.queue_handler(process_finished=True)
113 |
114 | return
115 |
116 | def has_pending(self):
117 | return bool(self._queue)
118 |
119 | def many_active(self):
120 | return self.active > 1 or self.has_pending()
121 |
122 | def stop_download(self, all_dls=False):
123 | if all_dls:
124 | for download in self._queue:
125 | download.set_status_killed()
126 | download.disconnect()
127 | self._queue.clear()
128 |
129 | if self.active_download is not None:
130 | if isinstance(self.active_download, Download):
131 | if self.active_download.state() == QProcess.Running:
132 | self.active_download.kill()
133 | self.active_download.set_status_killed()
134 | elif isinstance(self.active_download, list):
135 | for i in self.active_download:
136 | i.kill()
137 | i.set_status_killed()
138 |
139 | def queue_dl(self, download: Download):
140 | """ Adds element to queue, calls queue handler """
141 | download.stateChanged.connect(lambda x, dl=download: self.program_state_changed(dl))
142 | self._queue.append(download)
143 | self.queue_handler()
144 |
145 |
--------------------------------------------------------------------------------
/Modules/download_tab.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | from PyQt5.QtCore import Qt, QTimer
4 | from PyQt5.QtWidgets import QWidget, QPushButton, QLabel, QCheckBox, \
5 | QHBoxLayout, QVBoxLayout, QListWidget
6 |
7 | from Modules.download_element import ProcessListItem
8 | from Modules.dropdown_widget import DropDown
9 | from Modules.lineedit import LineEdit
10 | from utils.utilities import SettingsClass
11 |
12 |
13 | class ProcessList(QListWidget):
14 | """ Subclass to tweak resizing of widget. Holds ProcessListItems. """
15 |
16 | def __init__(self, *args, **kwargs):
17 | super(ProcessList, self).__init__(*args, **kwargs)
18 | self.verticalScrollBar().setObjectName('main')
19 |
20 | def resizeEvent(self, a0):
21 | super(ProcessList, self).resizeEvent(a0)
22 |
23 | # Ensure the length of long labels are not too long at small window sizes
24 | for item in self.iter_items():
25 | if self.verticalScrollBar().isVisible():
26 | padding = self.verticalScrollBar().width()
27 | else:
28 | padding = 0
29 | item.info_label.setFixedWidth(self.width() - 18 - padding)
30 |
31 | def iter_items(self) -> typing.Iterator[ProcessListItem]:
32 | yield from [self.itemWidget(self.item(i)) for i in range(self.count())]
33 |
34 | def clear(self) -> None:
35 | """ Only removed finished downloads from display"""
36 | for item in self.iter_items():
37 | if not item.is_running():
38 | if item._open_window is not None:
39 | item._open_window.close()
40 | self.takeItem(self.indexFromItem(item.slot).row())
41 |
42 |
43 | class MainTab(QWidget):
44 | """ QWidget for starting downloads, swapping profiles, and showing progress"""
45 | def __init__(self, settings: SettingsClass, parent=None):
46 | super().__init__(parent=parent)
47 |
48 | # Queue download
49 | self.start_btn = QPushButton('Download')
50 | # Enables start btn after some time
51 | self.start_btn.clicked.connect(self.start_button_timer)
52 | # Stops one or all downloads
53 | self.stop_btn = QPushButton('Abort')
54 | # Closes the program
55 | self.close_btn = QPushButton('Close')
56 |
57 | self.clear_btn = QPushButton('Clear')
58 | self.label = QLabel("Url: ")
59 |
60 | self.url_input = LineEdit()
61 |
62 | self.profile_label = QLabel('Current profile:')
63 |
64 | self.profile_dropdown = DropDown(self)
65 | self.profile_dropdown.setFixedWidth(100)
66 |
67 | # Setup for startbutton timer
68 | self.timer = QTimer(self)
69 | self.timer.setInterval(100)
70 | self.timer.setSingleShot(True)
71 | self.timer.timeout.connect(lambda: self.start_btn.setDisabled(False))
72 |
73 | # Populate profile selector
74 | if settings.profiles:
75 | for profile in settings.profiles:
76 | self.profile_dropdown.addItem(profile)
77 | current_profile = settings.user_options['current_profile']
78 | if current_profile:
79 | self.profile_dropdown.setCurrentText(current_profile)
80 | else:
81 | self.profile_dropdown.addItem('Custom')
82 | self.profile_dropdown.setCurrentText('Custom')
83 | else:
84 | self.profile_dropdown.setDisabled(True)
85 | self.profile_dropdown.addItem('None')
86 |
87 | # Holds entries for with queue downloads
88 | self.process_list = ProcessList(self)
89 |
90 | self.process_list.setSelectionMode(QListWidget.NoSelection)
91 | self.process_list.setFocusPolicy(Qt.NoFocus)
92 | self.process_list.setVerticalScrollMode(self.process_list.ScrollPerPixel)
93 | self.process_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
94 |
95 | # Start making checkbutton for selecting downloading from text file mode.
96 | self.checkbox = QCheckBox('Download from text file.')
97 |
98 | # Contains, start, abort, close buttons, and a stretch to make buttons stay on the correct side on rezise.
99 | self.button_bar = QHBoxLayout()
100 |
101 | self.button_bar.addWidget(self.clear_btn)
102 | self.button_bar.addStretch(1)
103 | self.button_bar.addWidget(self.start_btn)
104 | self.button_bar.addWidget(self.stop_btn)
105 | self.button_bar.addWidget(self.close_btn)
106 |
107 | self.url_bar = QHBoxLayout()
108 | self.url_bar.addWidget(self.label)
109 | self.url_bar.addWidget(self.url_input)
110 |
111 | self.profile_bar = QHBoxLayout()
112 | self.profile_bar.addWidget(self.checkbox)
113 | self.profile_bar.addStretch(1)
114 | self.profile_bar.addWidget(self.profile_label)
115 | self.profile_bar.addWidget(self.profile_dropdown)
116 |
117 | self.vertical_layout = QVBoxLayout()
118 | self.vertical_layout.addLayout(self.url_bar)
119 | self.vertical_layout.addLayout(self.profile_bar)
120 | self.vertical_layout.addWidget(self.process_list)
121 | self.vertical_layout.addLayout(self.button_bar)
122 |
123 | self.setLayout(self.vertical_layout)
124 |
125 | self.clear_btn.clicked.connect(self.process_list.clear)
126 |
127 | def start_button_timer(self, state):
128 | """ Disables start button for a second. Prevents double queueing. """
129 | self.start_btn.setDisabled(True)
130 |
131 | if not state:
132 | self.timer.start(1000)
133 |
134 |
135 | if __name__ == '__main__':
136 | # Only visuals work
137 | import sys
138 | from PyQt5.QtWidgets import QApplication
139 | from utils.filehandler import FileHandler
140 |
141 | app = QApplication(sys.argv)
142 | gui = MainTab(FileHandler().load_settings())
143 | gui.show()
144 | app.exec_()
145 |
--------------------------------------------------------------------------------
/utils/filehandler.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 | from functools import wraps, partial
5 |
6 | from PyQt5.QtCore import QThreadPool, QTimer, Qt
7 |
8 | from utils.task import Task
9 | from utils.utilities import get_base_settings, SettingsClass, ProfileLoadError
10 |
11 |
12 | def threaded_cooldown(func):
13 | """ A decorator that makes it so the decorate function will run
14 | in a thread, but prevents the same function from being rerun for a given time.
15 | After give time, the last call not performed will be executed.
16 |
17 | Purpose of this is to ensure writing to disc does not happen all too often,
18 | avoid IO operations reducing GUI smoothness.
19 |
20 | A drawback is that if a user "queues" a save, but reloads the file before the last save,
21 | they will load a version that is not up to date. This is not a problem for Grabber, as the
22 | settings are only read on startup. However, it's a drawback that prevents a more general use.
23 |
24 | This decorator requires being used in an instance which has a threadpool instance.
25 | """
26 |
27 | timer = QTimer()
28 | timer.setInterval(10000)
29 | timer.setSingleShot(True)
30 | timer.setTimerType(Qt.VeryCoarseTimer)
31 |
32 | def wrapper(self, *args, **kwargs):
33 |
34 | if not hasattr(self, 'threadpool'):
35 | raise AttributeError(f'{self.__class__.__name__} instance does not have a threadpool attribute.')
36 |
37 | if not hasattr(self, 'force_save'):
38 | raise AttributeError(f'{self.__class__.__name__} instance does not have a force_save attribute.')
39 |
40 | worker = Task(func, self, *args, **kwargs)
41 |
42 | if timer.receivers(timer.timeout):
43 | timer.disconnect()
44 |
45 | if self.force_save:
46 | timer.stop()
47 | self.threadpool.start(worker)
48 | self.threadpool.waitForDone()
49 | return
50 |
51 | if timer.isActive():
52 | timer.timeout.connect(partial(self.threadpool.start, worker))
53 | timer.start()
54 | return
55 |
56 | timer.start()
57 | self.threadpool.start(worker)
58 | return
59 |
60 | return wrapper
61 |
62 |
63 | def threaded(func):
64 | """
65 | Gives a function to a Task object, and then gives it to a thraed for execuution,
66 | to avoid using the main loop.
67 | """
68 |
69 | @wraps(func)
70 | def wrapper(self, *args, **kwargs):
71 | if not hasattr(self, 'threadpool'):
72 | raise AttributeError(f'{self.__class__.__name__} instance does not have a threadpool attribute.')
73 |
74 | worker = Task(func, self, *args, **kwargs)
75 |
76 | self.threadpool.start(worker)
77 | return
78 |
79 | return wrapper
80 |
81 |
82 | class FileHandler:
83 | """
84 | A class to handle finding/loading/saving to files. So, IO operations.
85 | """
86 |
87 | # TODO: Implement logging, since returned values from threaded functions are discarded.
88 | # Need to know if errors hanppen!
89 |
90 | def __init__(self, settings_path='settings.json', profiles_path='profiles.json'):
91 |
92 | self.profile_path = profiles_path
93 | self.settings_path = settings_path
94 | self.work_dir = os.getcwd().replace('\\', '/')
95 |
96 | self.force_save = False
97 |
98 | self.threadpool = QThreadPool()
99 | self.threadpool.setMaxThreadCount(1)
100 |
101 | def __repr__(self):
102 | return f'{__name__}(settings_path={self.settings_path}, profile_path={self.profile_path})'
103 |
104 | @staticmethod
105 | def find_file(relative_path, exist=True):
106 | """ Get absolute path to resource, works for dev and for PyInstaller """
107 | try:
108 | # PyInstaller creates a temp folder and stores path in _MEIPASS
109 | base_path = sys._MEIPASS
110 | except AttributeError:
111 | base_path = os.path.abspath(".")
112 |
113 | path = os.path.join(base_path, relative_path).replace('\\', '/')
114 |
115 | if FileHandler.is_file(path) or not exist:
116 | return path
117 | else:
118 | return None
119 |
120 | @threaded_cooldown
121 | def save_settings(self, settings):
122 | try:
123 | with open(self.settings_path, 'w') as f:
124 | json.dump(settings, f, indent=4, sort_keys=True)
125 | return True
126 | except (OSError, IOError) as e:
127 | # TODO: Logging!
128 | return False
129 |
130 | @threaded_cooldown
131 | def save_profiles(self, profiles):
132 | try:
133 | with open(self.profile_path, 'w') as f:
134 | json.dump(profiles, f, indent=4, sort_keys=True)
135 | return True
136 | except (OSError, IOError) as e:
137 | # TODO: Logging!
138 | return False
139 |
140 | def load_settings(self, reset=False) -> SettingsClass:
141 | """ Reads settings, or writes them if absent, or if instructed to using reset. """
142 |
143 | def get_file(path):
144 | """ """
145 | if FileHandler.is_file(path):
146 | with open(path, 'r') as f:
147 | return json.load(f)
148 | else:
149 | return {}
150 |
151 | try:
152 | profiles = get_file(self.profile_path)
153 | except json.decoder.JSONDecodeError as e:
154 | raise ProfileLoadError(str(e))
155 |
156 | if reset:
157 | return SettingsClass(get_base_settings(), profiles, self)
158 | else:
159 | settings = get_file(self.settings_path)
160 | if settings:
161 | return SettingsClass(settings, profiles, self)
162 | else:
163 | return self.load_settings(reset=True)
164 |
165 | @staticmethod
166 | def is_file(path):
167 | return os.path.isfile(path) and os.access(path, os.X_OK)
168 |
169 | def find_exe(self, program):
170 | """Used to find executables."""
171 | # Possible Windows specific implementation
172 | local_path = os.path.join(self.work_dir, program)
173 | if FileHandler.is_file(local_path):
174 | # print(f'Returning existing isfile exe: {os.path.join(self.work_dir, program)}')
175 | return local_path
176 | for path in os.environ["PATH"].split(os.pathsep):
177 | path = path.strip('"')
178 | exe_file = os.path.join(path, program)
179 | if FileHandler.is_file(exe_file):
180 | # print(f'Returning existing exe: {os.path.abspath(exe_file)}')
181 | return os.path.abspath(exe_file)
182 | return None
183 |
184 | @staticmethod
185 | def read_textfile(path):
186 | if FileHandler.is_file(path):
187 | try:
188 | with open(path, 'r') as f:
189 | content = f.read()
190 | return content
191 | except (OSError, IOError) as e:
192 | return None
193 | else:
194 | return None
195 |
196 | @threaded
197 | def write_textfile(self, path, content):
198 | # TODO: Warn user on error. Need smart simple method to send message from threadpool.
199 | if FileHandler.is_file(path):
200 | try:
201 | with open(path, 'w') as f:
202 | f.write(content)
203 | return True
204 | except (OSError, IOError) as e:
205 | # TODO: Logging error
206 | return False
207 | else:
208 | # TODO: Logging error
209 | return False
210 |
--------------------------------------------------------------------------------
/Modules/parameter_tree.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from typing import Union, Optional
3 |
4 | from PyQt5.QtCore import Qt, pyqtSignal
5 | from PyQt5.QtGui import QCursor
6 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAction, QMenu
7 |
8 |
9 | class TreeWidgetItem(QTreeWidgetItem):
10 | def __lt__(self, other):
11 | """Disables sorting"""
12 | return False
13 |
14 |
15 | # Constants to access data from TreeWidgetItems using the .data(0, DATA_SLOT) method
16 | DISPLAY_NAME_SLOT = 0 # Display name
17 | DATA_SLOT = 32 # Name of widget item
18 | LEVEL_SLOT = 33 # 0 for top level, 1 for child of top levels
19 | CHECKLIST_SLOT = 34 # Items to check
20 | INDEX_SLOT = 35 # Index of child items under parent
21 | DEPENDENT_SLOT = 37 # Items this current item depends on
22 |
23 |
24 | class ParameterTree(QTreeWidget):
25 | """Holds parameters and their respective options"""
26 |
27 | max_size = 400
28 | move_request = pyqtSignal(QTreeWidgetItem, bool)
29 | addOption = pyqtSignal(QTreeWidgetItem)
30 | itemRemoved = pyqtSignal(QTreeWidgetItem, int)
31 |
32 | def __init__(self, profile: dict, parent=None):
33 | """
34 | All data is in column 0. Uses global slot variables to access data .
35 | """
36 | super().__init__(parent=parent)
37 |
38 | self.favorite = False
39 |
40 | self.setExpandsOnDoubleClick(False)
41 | # self.setHeaderHidden(True)
42 | self.setRootIsDecorated(False)
43 | self.setHeaderHidden(True)
44 | # self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
45 | self.setContextMenuPolicy(Qt.CustomContextMenu)
46 | self.customContextMenuRequested.connect(self.contextMenu)
47 |
48 | # self.header().setSectionResizeMode(0,QHeaderView.ResizeToContents)
49 | # self.headerItem().setResizeMode(QHeaderView.ResizeToContents)
50 |
51 | # self.setItemWidget()
52 | if isinstance(profile, dict):
53 | self.load_profile(profile)
54 | else:
55 | raise TypeError(f'Expected dict, not type {type(profile)}')
56 |
57 | self.setSortingEnabled(True)
58 | self.sortByColumn(0, Qt.AscendingOrder)
59 |
60 | self.itemChanged.connect(self.make_exclusive)
61 | self.itemChanged.connect(self.check_dependency)
62 |
63 | def contextMenu(self, event):
64 | """
65 | Create context menu on parameters.
66 |
67 | Usual notation:
68 | parameter = parent
69 | option = child
70 |
71 | Reminder:
72 | LEVEL_SLOT points to parent/child data, 0 for parent, 1 for child.
73 | """
74 | # Right-clicked item
75 | item = self.itemAt(event)
76 |
77 | if item is None:
78 | return None
79 |
80 | menu = QMenu(self)
81 |
82 | # Temp variables for removing options.
83 | remove_option = None
84 |
85 | # Placeholder
86 | move_action = None
87 |
88 | # Temp variables for marking favorite.
89 | take_item = None
90 |
91 | if item.data(0, LEVEL_SLOT) == 0:
92 | take_item = item
93 | elif item.data(0, LEVEL_SLOT) == 1:
94 | take_item = item.parent()
95 |
96 | remove_option = QAction('Remove option')
97 | remove_option.triggered.connect(lambda: self.try_del_option(take_item, item))
98 | else:
99 | # TODO: Log error, shouldn't be reached either way
100 | return
101 |
102 | add_option = QAction('Add option')
103 | add_option.triggered.connect(lambda: self.addOption.emit(take_item))
104 |
105 | if take_item.data(0, LEVEL_SLOT) != 2:
106 | move_action = QAction('Favorite' if not self.favorite else 'Remove favorite')
107 | move_action.triggered.connect(lambda: self.move_widget(take_item))
108 | move_action.setIconVisibleInMenu(False)
109 |
110 | menu.addAction(add_option)
111 |
112 | if remove_option:
113 | menu.addAction(remove_option)
114 |
115 | menu.addSeparator()
116 | menu.addAction(move_action)
117 |
118 | menu.exec_(QCursor.pos())
119 |
120 | def try_del_option(self, parent: QTreeWidgetItem, child: QTreeWidgetItem):
121 | """
122 | Emits signal to remove an option from the parent parameter.
123 | core.py handles settings, and calls del_option method if delete is accepted.
124 | """
125 | self.itemRemoved.emit(parent, parent.indexOfChild(child))
126 |
127 | def del_option(self, parent: QTreeWidgetItem, child: QTreeWidgetItem):
128 | """ Called to remove option from parent. """
129 | self.blockSignals(True)
130 |
131 | parent.removeChild(child)
132 | selected_option = False
133 | for i in range(parent.childCount()):
134 | parent.child(i).setData(0, INDEX_SLOT, i)
135 | if parent.child(i).checkState(0) == Qt.Checked:
136 | selected_option = True
137 |
138 | if parent.childCount() > 0 and not selected_option:
139 | parent.child(0).setCheckState(0, Qt.Checked)
140 |
141 | # Deselects if no options left
142 | if not parent.childCount():
143 | parent.setCheckState(0, Qt.Unchecked)
144 |
145 | self.blockSignals(False)
146 |
147 | self.update_size()
148 |
149 | def move_widget(self, item: QTreeWidgetItem):
150 | """ Takes item from one ParameterTree and emits it. """
151 | self.blockSignals(True)
152 | taken_item = self.takeTopLevelItem(self.indexOfTopLevelItem(item))
153 | self.blockSignals(False)
154 | self.move_request.emit(taken_item, self.favorite)
155 |
156 | def load_profile(self, profile: dict):
157 | """Loads a parameter config from a dictionary. """
158 | self.blockSignals(True)
159 | self.setSortingEnabled(False)
160 | self.clear()
161 |
162 | for name, settings in profile.items():
163 | parent = self.make_option(name, self, settings['state'], 0, settings['tooltip'], settings['dependency'])
164 | if settings['options']:
165 | for number, choice in enumerate(settings['options']):
166 | if settings['active option'] == number:
167 | option = self.make_option(str(choice), parent, True, 1, subindex=number)
168 | option.setFlags(option.flags() ^ Qt.ItemIsUserCheckable)
169 | else:
170 | option = self.make_option(str(choice), parent, False, 1, subindex=number)
171 | self.make_exclusive(parent)
172 |
173 | self.hock_dependency()
174 | self.update_size()
175 | self.setSortingEnabled(True)
176 | self.sortByColumn(0, Qt.AscendingOrder)
177 | self.blockSignals(False)
178 |
179 | def hock_dependency(self):
180 | """
181 |
182 | Overly complicated code that essentially gives parameters a list of other parameters that depend on it
183 | to be enabled.
184 | I.e. thumbnail and audio quality rely on convert to audio conversion to be enabled.
185 | """
186 | top_level_names = []
187 |
188 | for item in self.topLevelItems():
189 |
190 | # Create a list if top level items, their dependent and the QModelIndex of said item.
191 | top_level_names.append([item.data(0, DATA_SLOT), item.data(0, CHECKLIST_SLOT), self.indexFromItem(item)])
192 |
193 | # Locate matches, and store in dict
194 | # TODO: Make readable. THIS IS DARK MAGIC
195 | indices = {t[0]: i for i, t in enumerate(top_level_names)}
196 | for index, (first, second, third) in enumerate(top_level_names):
197 | if second in indices:
198 | # Locate matches and assign dependent reference.
199 | dependent = self.itemFromIndex(third)
200 | # Locate host (that dependent needs to have checked)
201 | host = self.itemFromIndex(top_level_names[indices[second]][2])
202 |
203 | # Check if there is a reference to other dependents.
204 | # If True, make add a new dependency, if not, make a list with a dependency.
205 | if type(host.data(0, DEPENDENT_SLOT)) is list:
206 | host.setData(0, DEPENDENT_SLOT, host.data(0, DEPENDENT_SLOT) + [dependent])
207 | else:
208 | host.setData(0, DEPENDENT_SLOT, [dependent])
209 |
210 | # Ensure dependents are disabled on start!
211 | for item in self.topLevelItems():
212 | self.check_dependency(item)
213 |
214 | def check_dependency(self, item: QTreeWidgetItem):
215 | """
216 | Looks for mentioned dependents, and enables/disables those depending on checkstate.
217 |
218 | :param item: changed item from QTreeWidget (paramTree)
219 | :type item: QTreeWidget
220 | """
221 |
222 | if item.data(0, LEVEL_SLOT) == 0 and item.data(0, DEPENDENT_SLOT):
223 | for i in item.data(0, DEPENDENT_SLOT):
224 | if item.checkState(0) == Qt.Unchecked:
225 | self.blockSignals(True)
226 | i.setDisabled(True)
227 | i.setExpanded(False)
228 | self.blockSignals(False)
229 | i.setCheckState(0, Qt.Unchecked)
230 | else:
231 | self.blockSignals(True)
232 | i.setDisabled(False)
233 | self.blockSignals(False)
234 |
235 | def topLevelItems(self):
236 | """Iterates through toplevelitems and returns them."""
237 | for i in range(self.topLevelItemCount()):
238 | yield self.topLevelItem(i)
239 |
240 | def childrens(self, item: QTreeWidgetItem):
241 | """Iterates through toplevelitems and returns them."""
242 | for i in range(item.childCount()):
243 | yield item.child(i)
244 |
245 | @staticmethod
246 | def make_option(name: str,
247 | parent: Union[QTreeWidget, QTreeWidgetItem],
248 | checkstate: bool,
249 | level: int = 0,
250 | tooltip: Optional[str] = None,
251 | dependency: Optional[list] = None,
252 | subindex: Optional[int] = None) \
253 | -> QTreeWidgetItem:
254 | """
255 | Makes a QTreeWidgetItem and returns it.
256 | """
257 | if level != 1:
258 | widget_item = QTreeWidgetItem(parent, [name])
259 | else:
260 | widget_item = TreeWidgetItem(parent, [name])
261 |
262 | if tooltip:
263 | widget_item.setToolTip(0, tooltip)
264 | if checkstate:
265 | widget_item.setCheckState(0, Qt.Checked)
266 | else:
267 | widget_item.setCheckState(0, Qt.Unchecked)
268 |
269 | widget_item.setData(0, DATA_SLOT, name)
270 | widget_item.setData(0, LEVEL_SLOT, level)
271 |
272 | if level == 1:
273 | widget_item.setData(0, INDEX_SLOT, subindex)
274 | elif level == 0:
275 | if dependency:
276 | widget_item.setData(0, CHECKLIST_SLOT, dependency)
277 |
278 | return widget_item
279 |
280 | def update_size(self):
281 | """Sets widget size. Required to keep consistent."""
282 | # 15 and 20 are arbitrary units for height if each treelevelitem.
283 | child_size = 15 * sum(
284 | [1 for i in range(self.topLevelItemCount()) for _ in range(self.topLevelItem(i).childCount())])
285 | parent_size = 20 * self.topLevelItemCount()
286 |
287 | # Unhandled lengths when the program exceeds the window size. Might implement a max factor, and allow scrolling.
288 | if ParameterTree.max_size < (child_size + parent_size):
289 | self.setFixedHeight(ParameterTree.max_size)
290 | else:
291 | self.setFixedHeight((child_size + parent_size))
292 |
293 | def expand_options(self, item: QTreeWidgetItem):
294 | """Handles if the options should show, depends on checkstate."""
295 | if item.checkState(0) == Qt.Checked:
296 | item.setExpanded(True)
297 | else:
298 | item.setExpanded(False)
299 |
300 | def resizer(self, item: QTreeWidgetItem):
301 | """Handles resize changes when an parameters options are expanded/collapsed."""
302 | # print('Child count', item.childCount())
303 | if item.checkState(0):
304 | if self.height() + 15 * item.childCount() < ParameterTree.max_size:
305 | self.setFixedHeight(self.height() + 15 * item.childCount())
306 | # print('Expanding')
307 | else:
308 | self.update_size()
309 | # print('Collapsing')
310 |
311 | def make_exclusive(self, item: QTreeWidgetItem):
312 | """
313 | Handles changes to self. Ensure options are expand_options, and resizes self when needed.
314 | """
315 | if self.signalsBlocked():
316 | unblock = False
317 | else:
318 | unblock = True
319 |
320 | if item.data(0, LEVEL_SLOT) == 0:
321 | self.expand_options(item)
322 | self.resizer(item)
323 |
324 | elif item.data(0, LEVEL_SLOT) == 1:
325 | self.blockSignals(True)
326 | for i in range(item.parent().childCount()):
327 |
328 | TWI = item.parent().child(i)
329 | try:
330 | if TWI == item:
331 | TWI.setFlags(TWI.flags() ^ Qt.ItemIsUserCheckable)
332 | else:
333 | TWI.setCheckState(0, Qt.Unchecked)
334 | TWI.setFlags(TWI.flags() | Qt.ItemIsUserCheckable)
335 | except Exception as e:
336 | # Log error
337 | print(e)
338 | elif item.data(0, LEVEL_SLOT) == 2:
339 | # DEPRECATED
340 | pass # Custom options should not have options, not now at least.
341 | else:
342 | pass # TODO: Log error: state state not set.
343 |
344 | if unblock:
345 | self.blockSignals(False)
346 |
347 |
348 | if __name__ == '__main__':
349 | from PyQt5.QtWidgets import QApplication
350 |
351 | # TODO: Clean up example code here, uses old style settings.
352 | SampleDict = {
353 | "Other stuff": {
354 | "multidl_txt": "C:/User/Mike Hunt/links.txt"
355 | },
356 | "Settings": {
357 | "Add thumbnail": {
358 | "active option": 0,
359 | "command": "--embed-thumbnail",
360 | "dependency": "Convert to audio",
361 | "options": None,
362 | "state": True,
363 | "tooltip": "Include thumbnail on audio files."
364 | },
365 | "Convert to audio": {
366 | "active option": 0,
367 | "command": "-x --audio-format {} --audio-quality 0",
368 | "dependency": None,
369 | "options": [
370 | "mp3",
371 | "mp4"
372 | ],
373 | "state": True,
374 | "tooltip": "Convert to selected audio format."
375 | },
376 | "Download location": {
377 | "active option": 2,
378 | "command": "-o {}",
379 | "dependency": None,
380 | "options": [
381 | "D:/Music/DL/",
382 | "C:/Users/Clint Oris/Downloads/",
383 | "D:/Music/"
384 | ],
385 | "state": True,
386 | "tooltip": "Select download location."
387 | },
388 | "Ignore errors": {
389 | "active option": 0,
390 | "command": "-i",
391 | "dependency": None,
392 | "options": None,
393 | "state": True,
394 | "tooltip": "Ignores errors, and jumps to next element instead of stopping."
395 | },
396 | "Keep archive": {
397 | "active option": 0,
398 | "command": "--download-archive {}",
399 | "dependency": None,
400 | "options": [
401 | "Archive.txt"
402 | ],
403 | "state": False,
404 | "tooltip": "Saves links to a textfile to avoid duplicates later."
405 | },
406 | "Strict file names": {
407 | "active option": 0,
408 | "command": "--restrict-filenames",
409 | "dependency": None,
410 | "options": None,
411 | "state": False,
412 | "tooltip": "Sets strict naming, to prevent unsupported characters in names."
413 | }
414 | }
415 | }
416 |
417 | app = QApplication(sys.argv)
418 | Checkbox = ParameterTree(SampleDict['Settings'])
419 | Checkbox.__name__ = 'Favorites'
420 | Checkbox.show()
421 |
422 | app.exec_()
423 |
--------------------------------------------------------------------------------
/Modules/download_element.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import traceback
4 | from collections import deque
5 |
6 | from PyQt5.QtCore import QProcess, pyqtSignal, Qt
7 | from PyQt5.QtGui import QCursor
8 | from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QSizePolicy, QMenu, QTextBrowser
9 |
10 | from utils.utilities import FONT_CONSOLAS, color_text, to_clipboard
11 |
12 |
13 | # TODO: Enum with states (because keeping track of strings is messy + possiblity a define a broad "error" states
14 | class Download(QProcess):
15 | getOutput = pyqtSignal()
16 |
17 | def __init__(self, working_dir: str, program_path: str, commands: list, info='', parent=None):
18 | """
19 | Download objects take commands, and will start a process on triggered.
20 | """
21 | super(Download, self).__init__(parent=parent)
22 |
23 | self.program_path = program_path
24 |
25 | self.commands = commands
26 | self.setWorkingDirectory(working_dir)
27 | self.setProcessChannelMode(QProcess.MergedChannels)
28 | self.readyReadStandardOutput.connect(self.process_output)
29 | self.stateChanged.connect(self.program_state_changed)
30 |
31 | self.status = 'In queue'
32 | self.progress = ''
33 | self.eta = ''
34 | self.filesize = ''
35 | self.speed = ''
36 |
37 | self.name = ''
38 | self.file_path = ''
39 | self.playlist = ''
40 | self.error_count = 0
41 |
42 | # Info channel
43 | self.info = info
44 | # Potential error for user
45 | self.potential_error_log = ''
46 | # Raw logs from program. TODO: Maybe add some info here when initializing, plus return code perhaps?
47 | self.debug_log = []
48 | self.debug_log.append(' '.join(commands) + '\n')
49 | self.done = False
50 |
51 | self.program_log = deque(maxlen=3)
52 |
53 | def program_state_changed(self, new_state):
54 | """Triggers actions when the QProcess stops"""
55 | if new_state == QProcess.NotRunning:
56 | self.done = True
57 | self.debug_log.append(f'Program closed with error code: {self.exitCode()}')
58 | if self.status not in ('Aborted', 'ERROR', 'Already Downloaded'):
59 | if self.exitCode() != 0:
60 | self.status = 'ERROR'
61 | self.progress = ''
62 |
63 | if not self.info and self.potential_error_log:
64 | self.info = self.potential_error_log
65 | elif not self.info:
66 | self.info = 'Unknown error'
67 |
68 | else:
69 | self.status = 'Finished'
70 | self.progress = '100%'
71 |
72 | self.eta = ''
73 | self.filesize = ''
74 | self.speed = ''
75 |
76 | self.getOutput.emit()
77 |
78 | def set_status_killed(self):
79 | """ When killed, set this state"""
80 |
81 | self.status = 'Aborted'
82 | self.progress = ''
83 | self.eta = ''
84 | self.filesize = ''
85 | self.speed = ''
86 | self.info = 'Aborted by user'
87 |
88 | self.done = True
89 | self.getOutput.emit()
90 |
91 | def start_dl(self):
92 | """ Starts program, youtube-dl.exe """
93 | if self.program_path is None:
94 | raise TypeError('Can\'t find youtube-dl executable')
95 |
96 | self.start(self.program_path, self.commands)
97 | self.status = 'Started'
98 | self.getOutput.emit()
99 |
100 | def process_output(self):
101 | """
102 | Reference used:
103 | https://github.com/MrS0m30n3/youtube-dl-gui/blob/master/youtube_dl_gui/downloaders.py
104 | """
105 |
106 | output = self.readAllStandardOutput().data().decode('utf-8', 'replace')
107 |
108 | if not output:
109 | return
110 |
111 | try:
112 | for line in output.splitlines():
113 | self.debug_log.append(line)
114 | if not line:
115 | continue
116 |
117 | stdout_with_spaces = line.split(' ')
118 | stdout = line.split()
119 |
120 | if not stdout:
121 | continue
122 |
123 | self.program_log.append(line.strip())
124 |
125 | if stdout[0] == '[download]':
126 | self.status = 'Downloading'
127 |
128 | if stdout[1] == 'Destination:':
129 | path, fullname = os.path.split(' '.join(stdout[2:]).strip("\""))
130 | self.name = fullname
131 | self.file_path = os.path.join(path, fullname)
132 |
133 | # Get progress info
134 | if '%' in stdout[1]:
135 | if stdout[1] == '100%':
136 | self.progress = '100%'
137 | self.eta = ''
138 | self.filesize = stdout[3] if stdout[3] != '~' else stdout[4]
139 | self.speed = ''
140 | else:
141 | self.progress = stdout[1]
142 | self.filesize = stdout[3] if stdout[3] != '~' else stdout[4]
143 | self.speed = stdout[5] if stdout[3] != '~' else stdout[6]
144 | self.eta = stdout[7] if stdout[3] != '~' else stdout[8]
145 |
146 | # Get playlist info (yt-dlp first condition, yt-dl other condition)
147 | if ((stdout[1] == 'Downloading' and stdout[2] == 'item' and stdout[4] == 'of')
148 | or (stdout[1] == 'Downloading' and stdout[2] == 'video')):
149 | self.playlist = stdout[3] + '/' + stdout[5]
150 |
151 | # Remove the 'and merged' part from stdout when using ffmpeg to merge the formats
152 | if stdout[-3] == 'downloaded' and stdout[-1] == 'merged':
153 | stdout = stdout[:-2]
154 | self.progress = '100%'
155 |
156 | # Get file already downloaded status
157 | if stdout[-1] == 'downloaded':
158 | self.status = 'Already Downloaded'
159 | self.info = ' '.join(stdout)
160 | path, fullname = os.path.split(' '.join(stdout_with_spaces[1:-4]).strip("\""))
161 | self.file_path = os.path.join(path, fullname)
162 |
163 | if stdout[-3:] == ['recorded', 'in', 'archive']:
164 | self.status = 'Already Downloaded'
165 | self.info = ' '.join(stdout)
166 | # Path not provided in output, possible manual fix later
167 |
168 | # Get filesize abort status
169 | if stdout[-1] == 'Aborting.':
170 | self.status = 'Filesize Error'
171 |
172 | elif stdout[0] == '[hlsnative]':
173 | # native hls extractor
174 | # see: https://github.com/rg3/youtube-dl/blob/master/youtube_dl/downloader/hls.py#L54
175 | self.status = 'Downloading'
176 |
177 | if len(stdout) == 7:
178 | segment_no = float(stdout[6])
179 | current_segment = float(stdout[4])
180 |
181 | # Get the percentage
182 | percent = '{0:.1f}%'.format(current_segment / segment_no * 100)
183 | self.progress = percent
184 |
185 | elif stdout[0] in ('[ffmpeg]', '[EmbedThumbnail]', '[ThumbnailsConvertor]',
186 | '[ExtractAudio]', '[Metadata]', '[Merger]'):
187 | self.status = 'Post Processing'
188 |
189 | if stdout[1] == 'Merging':
190 | path, fullname = os.path.split(' '.join(stdout_with_spaces[4:]).strip("\""))
191 | self.name = fullname
192 | self.file_path = os.path.join(path, fullname)
193 |
194 | # Get final extension ffmpeg post process simple (not file merge)
195 | if stdout[1] == 'Destination:':
196 | path, fullname = os.path.split(' '.join(stdout_with_spaces[2:]).strip("\""))
197 | self.name = fullname
198 | self.file_path = os.path.join(path, fullname)
199 |
200 | # Get final extension after recoding process
201 | if stdout[1] == 'Converting' and stdout[2] != 'thumbnail':
202 | path, fullname = os.path.split(' '.join(stdout_with_spaces[8:]).strip("\""))
203 | self.name = fullname
204 | self.file_path = os.path.join(path, fullname)
205 |
206 | elif 'frame=' in stdout:
207 | progress_pattern = re.compile(
208 | r'(frame|fps|size|time|bitrate|speed)\s*\=\s*(\S+)'
209 | )
210 |
211 | items = {
212 | key: value for key, value in progress_pattern.findall(line)
213 | }
214 | if not items:
215 | continue
216 |
217 | if not self.info.startswith('[ffmpeg download mode]'):
218 | self.info = '[ffmpeg download mode] Progress shows the frame count' + self.info
219 | self.speed = items.get('bitrate', self.speed)
220 | self.progress = items.get('frame', self.progress)
221 | self.filesize = items.get('size', self.filesize)
222 |
223 | elif stdout[0] == 'ERROR:':
224 | self.status = 'ERROR'
225 | self.error_count += 1
226 | self.info = ' '.join(stdout) + f'\nTotal errors {self.error_count}'
227 |
228 | elif 'youtube-dl.exe: error:' in line:
229 | self.potential_error_log += ' '.join(stdout).replace('youtube-dl.exe: ', '')
230 | self.getOutput.emit()
231 | except IndexError:
232 | traceback.print_exc()
233 | finally:
234 | self.getOutput.emit()
235 |
236 |
237 | class MockDownload(Download):
238 | """Used for showing debug info"""
239 |
240 | def __init__(self, info, parent=None):
241 | super(MockDownload, self).__init__('', '', [], info=info, parent=parent)
242 | self.status = 'Debug Info'
243 | self.done = True
244 |
245 | def process_output(self):
246 | pass
247 |
248 |
249 | class ProcessListItem(QWidget):
250 | """ ListItem that shows attached process state"""
251 |
252 | def __init__(self, process: Download, slot, debug=False, parent=None, url=None):
253 | super(ProcessListItem, self).__init__(parent=parent)
254 | self.process = process
255 | self.slot = slot
256 | self.url = url
257 | self.process.getOutput.connect(self.stat_update)
258 | self.line = QHBoxLayout()
259 | self.setFocusPolicy(Qt.NoFocus)
260 | # self.setStyleSheet()
261 | self._open_window = None
262 |
263 | self.status_box = QLabel(color_text(self.process.status, color='lawngreen'))
264 | self.status_box.setContextMenuPolicy(Qt.CustomContextMenu)
265 | self.status_box.customContextMenuRequested.connect(self.open_info_menu)
266 |
267 | self.progress = QLabel(parent=self)
268 | self.progress.setAlignment(Qt.AlignCenter)
269 | self.eta = QLabel('', parent=self)
270 | self.eta.setAlignment(Qt.AlignCenter)
271 | self.speed = QLabel(parent=self)
272 | self.speed.setAlignment(Qt.AlignCenter)
273 | self.filesize = QLabel(parent=self)
274 | self.filesize.setAlignment(Qt.AlignCenter)
275 | self.playlist = QLabel(parent=self)
276 | self.playlist.setAlignment(Qt.AlignCenter)
277 | font_size_pixels = FONT_CONSOLAS.pixelSize()
278 |
279 | self.status_box.setBaseSize(20 * font_size_pixels, self.sizeHint().height())
280 |
281 | self.progress.setFixedWidth(5 * font_size_pixels)
282 | self.eta.setFixedWidth(4 * font_size_pixels)
283 | self.speed.setFixedWidth(6 * font_size_pixels)
284 | self.filesize.setFixedWidth(6 * font_size_pixels)
285 | self.playlist.setFixedWidth(6 * font_size_pixels)
286 |
287 | self.line.addWidget(self.progress, 0)
288 | self.line.addWidget(self.eta, 0)
289 | self.line.addWidget(self.speed, 0)
290 | self.line.addWidget(self.filesize, 0)
291 | self.line.addWidget(self.playlist, 0)
292 |
293 | self.line2 = QHBoxLayout()
294 | self.line2.addWidget(self.status_box, 0)
295 | self.line2.addStretch(1)
296 | self.line2.addLayout(self.line, 1)
297 |
298 | self.info_label_in_layout = False
299 | self.info_label = QLabel('', parent=self)
300 | if url is not None:
301 | self.info_label.setToolTip(f'URL: {url} (Right-click to copy)')
302 | self.info_label.setWordWrap(True)
303 | self.info_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
304 | self.info_label.hide()
305 | self.info_label.setContextMenuPolicy(Qt.CustomContextMenu)
306 | self.info_label.customContextMenuRequested.connect(self.open_file_menu)
307 |
308 | self.vline = QVBoxLayout()
309 | self.vline.addLayout(self.line2, 0)
310 | self.vline.addWidget(self.info_label, 1)
311 | self.setLayout(self.vline)
312 |
313 | self.progress.setStyleSheet(f'background: {"#484848" if self.process.progress else "#303030"}')
314 | self.eta.setStyleSheet(f'background: {"#484848" if self.process.eta else "#303030"}')
315 | self.speed.setStyleSheet(f'background: {"#484848" if self.process.speed else "#303030"}')
316 | self.filesize.setStyleSheet(f'background: {"#484848" if self.process.filesize else "#303030"}')
317 | self.playlist.setStyleSheet(f'background: {"#484848" if self.process.playlist else "#303030"}')
318 |
319 | self._debug = debug
320 |
321 | def open_file(self):
322 | if self.is_running() or not self.process.file_path or self.process.status == 'ERROR':
323 | old_text = self.info_label.text()
324 | if old_text.endswith('\nNo file to open!'):
325 | return
326 |
327 | self.info_label.setText(old_text + '\nNo file to open!')
328 | self.adjust()
329 | return
330 |
331 | try:
332 | # Windows specific implementation
333 | QProcess.startDetached('explorer', ['/select,', self.process.file_path])
334 | except:
335 | self.info_label.setText(self.info_label.text() + '\nFailed to open in explorer')
336 | # Print failed to open file to user!
337 |
338 | def open_file_menu(self, event):
339 | menu = QMenu(self.sender())
340 |
341 | if self.process.status not in ('ERROR', 'Aborted', 'Filesize Error'):
342 | menu.addAction('Open folder', self.open_file)
343 | if self.url is not None:
344 | menu.addAction('Copy URL', lambda: to_clipboard(self.url))
345 | if not menu.actions():
346 | return
347 | menu.exec(QCursor.pos())
348 |
349 | def open_info_menu(self, event):
350 | menu = QMenu(self.sender())
351 | menu.addAction('Show complete log', self.open_log)
352 | if self.url is not None:
353 | menu.addAction('Copy URL', lambda: to_clipboard(self.url))
354 | menu.exec(QCursor.pos())
355 |
356 | def open_log(self):
357 | info_log = QTextBrowser()
358 | info_log.setObjectName('TextFileEdit')
359 | info_log.setStyleSheet(self.window().styleSheet())
360 | info_log.setWindowTitle('Raw output')
361 | # Sets value to None when closed
362 | info_log.closeEvent = lambda _, inflog=info_log: setattr(self, '_open_window', None)
363 |
364 | # If a window already has been opened
365 | if self._open_window is not None:
366 | try:
367 | self._open_window.close()
368 | except:
369 | pass
370 | self._open_window = info_log
371 |
372 | info_log.setText('\n'.join(self.process.debug_log))
373 | info_log.show()
374 |
375 | def toggle_debug(self, debug_state):
376 | self._debug = debug_state
377 | self.stat_update()
378 |
379 | def adjust(self):
380 | self.setFixedHeight(self.sizeHint().height())
381 | self.info_label.setFixedWidth(self.parent().width() - 18)
382 | self.slot.setSizeHint(self.sizeHint())
383 |
384 | def resizeEvent(self, event) -> None:
385 | super(ProcessListItem, self).resizeEvent(event)
386 | self.adjust()
387 |
388 | def is_running(self):
389 | return not self.process.done
390 |
391 | def stat_update(self):
392 | def show_infolabel():
393 | nonlocal self
394 | if not self.info_label_in_layout:
395 | self.info_label.show()
396 | self.info_label_in_layout = True
397 |
398 | if self._open_window is not None:
399 | self._open_window.setText('\n'.join(self.process.debug_log))
400 |
401 | self.status_box.setText(color_text(self.process.status, color='lawngreen'))
402 | self.progress.setText(color_text(self.process.progress, color='lawngreen'))
403 | self.eta.setText(self.process.eta)
404 | self.speed.setText(self.process.speed)
405 | self.filesize.setText(self.process.filesize)
406 | self.playlist.setText(self.process.playlist)
407 |
408 | self.progress.setStyleSheet(f'background: {"#484848" if self.process.progress else "#303030"}')
409 | self.eta.setStyleSheet(f'background: {"#484848" if self.process.eta else "#303030"}')
410 | self.speed.setStyleSheet(f'background: {"#484848" if self.process.speed else "#303030"}')
411 | self.filesize.setStyleSheet(f'background: {"#484848" if self.process.filesize else "#303030"}')
412 | self.playlist.setStyleSheet(f'background: {"#484848" if self.process.playlist else "#303030"}')
413 |
414 | if self.process.status == 'ERROR':
415 | show_infolabel()
416 |
417 | self.status_box.setText(color_text(self.process.status))
418 | self.info_label.setText(f'{self.process.name if self.process.name else "Process"}'
419 | f' failed with message:\n{self.process.info.replace("ERROR:", "").lstrip()}')
420 | elif self.process.status == 'Aborted':
421 | self.status_box.setText(color_text(self.process.status))
422 | show_infolabel()
423 | name = ' | ' + self.process.name if self.process.name else ''
424 | self.info_label.setText(self.process.info + name)
425 |
426 | elif self.process.info or self._debug or self.process.name:
427 | # Shows the info label if there is debug info, or if any other field has info
428 | if self._debug and not any((self.process.info, self.process.name)):
429 | if self.process.program_log:
430 | show_infolabel()
431 | else:
432 | show_infolabel()
433 |
434 | content = []
435 |
436 | if self.process.name:
437 | content.append(self.process.name.strip())
438 |
439 | if self.process.info:
440 | content.append(self.process.info.replace("[download] ", ""))
441 |
442 | if self._debug:
443 | content += ['
Debug info:
'] + list(self.process.program_log)
444 |
445 | self.info_label.setText('
'.join(content))
446 |
447 | self.adjust()
448 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate settings_data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 | {one line to give the program's name and a brief idea of what it does.}
635 | Copyright (C) {year} {name of author}
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | {project} Copyright (C) {year} {fullname}
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/utils/utilities.py:
--------------------------------------------------------------------------------
1 | """
2 | Utilities for Grabber.
3 | """
4 | import copy
5 | import logging
6 | import re
7 | import sys
8 |
9 | from traceback import format_exception
10 | from winreg import ConnectRegistry, OpenKey, QueryValueEx, HKEY_CURRENT_USER
11 |
12 | LOG_FILE = 'Grabber_error.log'
13 |
14 | log = logging.getLogger('Grabber')
15 | log.setLevel(logging.DEBUG)
16 |
17 | formatter = logging.Formatter('{name:<15}:{levelname:<7}:{lineno:4d}: {message}', style="{")
18 | filehandler = logging.FileHandler(LOG_FILE, encoding='utf-8')
19 | filehandler.setFormatter(formatter)
20 | filehandler.setLevel(logging.CRITICAL)
21 | log.addHandler(filehandler)
22 |
23 |
24 | def get_logger(string):
25 | """ Ensure logger is configured before use. """
26 | return logging.getLogger(string)
27 |
28 |
29 | def except_hook(cls, exception, traceback):
30 | """ If frozen, catch error and exit. Dev, prints error but may continue if possible. """
31 | critical_log = get_logger('Grabber')
32 | error = "".join(format_exception(cls, exception, traceback))
33 | critical_log.critical(f'Encountered fatal error:\n\n{error}')
34 |
35 | # If imports fail below, the error, the error is still printed!
36 | try:
37 | if getattr(sys, 'frozen', False):
38 | warn_user(error)
39 | else:
40 | sys.__excepthook__(cls, exception, traceback)
41 | except:
42 | pass
43 |
44 |
45 | # Override PyQt exception handling
46 | sys.excepthook = except_hook
47 |
48 | from PyQt5.QtCore import QMimeData
49 | from PyQt5.QtGui import QFont, QColor, QGuiApplication, QClipboard
50 | from PyQt5.QtWidgets import QApplication, QMessageBox
51 |
52 | FONT_CONSOLAS = QFont()
53 | FONT_CONSOLAS.setFamily('Consolas')
54 | FONT_CONSOLAS.setPixelSize(13)
55 |
56 |
57 | def warn_user(error):
58 | app = QApplication.instance()
59 | if not app:
60 | app = QApplication(sys.argv)
61 | QMessageBox.warning(None,
62 | 'ERROR!',
63 | 'An critical error happened running the program. Please forward this error to developer:\n\n'
64 | f'{error}', QMessageBox.Ok)
65 | QApplication.exit(1)
66 |
67 |
68 | def to_clipboard(text):
69 | mime = QMimeData()
70 | mime.setText(text)
71 | board = QGuiApplication.clipboard()
72 | board.setMimeData(mime, mode=QClipboard.Clipboard)
73 |
74 |
75 | def path_shortener(full_path: str):
76 | """ Formats a path to a shorter version, for cleaner UI."""
77 |
78 | # Convert to standard style, use / not \\
79 | full_path = full_path.replace('\\', '/')
80 |
81 | if full_path[-1] != '/':
82 | full_path = full_path + '/'
83 |
84 | if len(full_path) > 20:
85 | times = 0
86 | for integer, letter in enumerate(reversed(full_path)):
87 | if letter == '/':
88 | split = -integer - 1
89 | times += 1
90 | if times == 3 and full_path.count('/') >= 4:
91 | short_path = ''.join([full_path[:full_path.find('/') + 1], '...', full_path[split:]])
92 |
93 | break
94 | elif times == 3:
95 | split = full_path.find('/', split)
96 | short_path = ''.join([full_path[:full_path.find('/') + 1], '...', full_path[split:]])
97 | break
98 | else:
99 | short_path = full_path
100 | else:
101 | short_path = full_path
102 |
103 | return short_path
104 |
105 |
106 | def color_text(text: str, color: str = 'darkorange', weight: str = 'bold', sections: tuple = None) -> str:
107 | """
108 | Formats a piece of string to be colored/bolded.
109 | Also supports having a section of the string colored.
110 | """
111 | text = text.replace('\n', '
')
112 |
113 | if not sections:
114 | string = ''.join(['""", text,
117 | ""]
118 | )
119 | else:
120 | work_text = text[sections[0]:sections[1]]
121 | string = ''.join([text[:sections[0]],
122 | '""", work_text,
125 | "",
126 | text[sections[1]:]]
127 | )
128 | return string
129 |
130 |
131 | def format_in_list(command, option):
132 | com = re.compile(r'{.+\}')
133 | split_command = command.split()
134 | for index, item in enumerate(split_command):
135 | if '{}' in item and com.search(item) is None:
136 | split_command[index] = item.format(option)
137 | return split_command
138 | return split_command
139 |
140 |
141 | def get_win_accent_color():
142 | """
143 | Return the Windows 10 accent color used by the user in a HEX format
144 | Windows specific
145 | """
146 | # Open the registry
147 | registry = ConnectRegistry(None, HKEY_CURRENT_USER)
148 | key = OpenKey(registry, r'Software\Microsoft\Windows\DWM')
149 | key_value = QueryValueEx(key, 'AccentColor')
150 | accent_int = key_value[0]
151 | accent_hex = hex(accent_int) # Remove FF offset and convert to HEX again
152 | accent_hex = str(accent_hex)[4:] # Remove prefix
153 |
154 | accent = accent_hex[4:6] + accent_hex[2:4] + accent_hex[0:2]
155 |
156 | return '#' + accent
157 |
158 |
159 | class LessNiceDict(dict):
160 | def values(self):
161 | return
162 |
163 | def items(self):
164 | return
165 |
166 | def __str__(self):
167 | return 'No printing'
168 |
169 | def __repr__(self):
170 | return 'No printing'
171 |
172 |
173 | class SettingsError(Exception):
174 | pass
175 |
176 |
177 | class ProfileLoadError(Exception):
178 | pass
179 |
180 |
181 | class SettingsClass:
182 | """Holds settings and handles manipulation"""
183 |
184 | def __init__(self, settings, profiles, filehandler=None):
185 | if not settings:
186 | raise SettingsError('Empty settings file!')
187 |
188 | if any((i in settings for i in ('Profiles', 'Other stuff', 'Settings'))):
189 | settings, old_profiles = self._upgrade_settings(settings)
190 | # Check for older settings files.
191 | if not profiles:
192 | profiles = old_profiles
193 | else:
194 | old_profiles.update(profiles)
195 | profiles = copy.deepcopy(old_profiles)
196 | try:
197 | self._userdefined = settings['default']
198 | self._parameters = settings['parameters']
199 | except KeyError as e:
200 | raise SettingsError(f'Missing section {e}')
201 |
202 | self._profiles = profiles
203 | self._filehandler = filehandler
204 |
205 | self.need_parameters = []
206 |
207 | self._validate_settings()
208 |
209 | def __enter__(self):
210 | return self._parameters
211 |
212 | def __exit__(self, exc_type, exc_val, exc_tb):
213 | if self._filehandler is not None:
214 | self._filehandler.save_settings(self.settings_data)
215 |
216 | def __getitem__(self, item):
217 | return self._parameters[item]
218 |
219 | @property
220 | def user_options(self):
221 | return self._userdefined
222 |
223 | def get_favorites(self):
224 | return self._userdefined['favorites']
225 |
226 | def is_activate(self, parameter):
227 | return self._parameters[parameter]['state']
228 |
229 | def get_active_setting(self, parameter):
230 | param = self._parameters[parameter]
231 | if '{}' in param['command']:
232 | active = param['active option']
233 | return param['options'][active]
234 | else:
235 | return ''
236 |
237 | @property
238 | def parameters(self) -> dict:
239 | return self._parameters
240 |
241 | @property
242 | def settings_data(self):
243 | return {'default': self._userdefined, 'parameters': self._parameters}
244 |
245 | @property
246 | def current_profile(self):
247 | return self._userdefined['current_profile']
248 |
249 | @property
250 | def profiles(self):
251 | return list(self._profiles.keys())
252 |
253 | @property
254 | def profiles_data(self):
255 | return self._profiles
256 |
257 | def create_profile(self, profile):
258 | self._profiles[profile] = copy.deepcopy(self._parameters)
259 | self._userdefined['current_profile'] = profile
260 |
261 | def delete_profile(self, profile):
262 | del self._profiles[profile]
263 | self._userdefined['current_profile'] = ''
264 |
265 | def remove_parameter_option(self, parameter, index):
266 | del self._parameters[parameter]['options'][index]
267 | option = self._parameters[parameter]['active option']
268 | option -= 1 if option > 0 else 0
269 | self._parameters[parameter]['active option'] = option
270 | if not option:
271 | self.need_parameters.append(parameter)
272 | # TODO: Remove options from profiles too. At least download options. Except when it's the selected option!!!
273 |
274 | def add_parameter_option(self, parameter, option):
275 | self._parameters[parameter]['options'].insert(0, option)
276 |
277 | def change_profile(self, profile):
278 | if self.current_profile == profile:
279 | return True
280 |
281 | if profile not in self._profiles:
282 | return False
283 |
284 | for param, data in self._profiles[profile].items():
285 | if param not in self._parameters:
286 | self._parameters[param] = data
287 | continue
288 |
289 | if '{}' in data['command'] and self._parameters[param]['options'] != data['options']:
290 | self._parameters[param]['options'] = data['options'] + \
291 | [i for i in self._parameters[param]['options'] if
292 | i not in data['options']]
293 | new_data = {k: v for k, v in data.items() if k != 'options'}
294 | self._parameters[param].update(new_data)
295 | else:
296 | self._parameters[param].update(data)
297 |
298 | self._userdefined['current_profile'] = profile
299 | return True
300 |
301 | @staticmethod
302 | def _upgrade_settings(old_settings):
303 | # print('Upgrading settings!!')
304 | settings = {}
305 | try:
306 | settings['default'] = copy.deepcopy(old_settings['Other stuff'])
307 | if old_settings['Favorites']:
308 | settings['default']['favorites'] = copy.deepcopy(old_settings['Favorites'])
309 | settings['parameters'] = copy.deepcopy(old_settings['Settings'])
310 | except KeyError:
311 | pass
312 |
313 | try:
314 | profiles = copy.deepcopy(old_settings['Profiles'])
315 | except KeyError:
316 | profiles = {}
317 |
318 | return settings, profiles
319 |
320 | def _validate_settings(self):
321 | # User defined part
322 | global base_settings
323 | missing_settings = {}
324 |
325 | # TODO: Validated that each settings is a dict before checking for keys.
326 |
327 | keys = base_settings['default'].keys()
328 | for key in keys:
329 | if key not in self._userdefined:
330 | self._userdefined[key] = get_base_setting('default', key)
331 |
332 | keys = base_settings['parameters']
333 | for profile in self.profiles:
334 | for key in keys:
335 | if key not in self._profiles[profile]:
336 | self._profiles[profile][key] = get_base_setting('parameters', key)
337 |
338 | # Parameters
339 |
340 | keys = ['command',
341 | 'dependency',
342 | 'options',
343 | 'state',
344 | 'tooltip']
345 |
346 | for setting in {i for i in base_settings['parameters'] if i not in self._parameters}:
347 | self._parameters[setting] = copy.deepcopy(base_settings['parameters'][setting])
348 |
349 | for setting, option in self._parameters.items():
350 | # setting: The name of the setting, like "Ignore errors"
351 | # option: The dict which contains the base keys.
352 | # key (Define below): is a key in the base settings
353 |
354 | for key in keys:
355 | # Check if all base keys are in the options.
356 |
357 | if key not in option.keys():
358 | # Check if the current setting has already logged a missing key
359 | # If it hasn't, create an entry in the missing_settings dict, as a list.
360 | # If it's there, then add the key to the missing list.
361 |
362 | if setting not in missing_settings.keys():
363 | missing_settings[setting] = [key]
364 | else:
365 | missing_settings[setting].append(key)
366 |
367 | # Check if the current setting is missing options for the command, when needed.
368 | # Disable the setting by default. Possibly alert the user.
369 | elif key == 'command':
370 | if '{}' in option[key]:
371 | if not option['options']:
372 | # print(f'{setting} currently lacks any valid options!')
373 | if 'state' in option.keys() and setting != 'Download location':
374 | self._parameters[setting]['state'] = False
375 | # Add to a list over options to add setting to.
376 | self.need_parameters.append(setting)
377 |
378 | if missing_settings:
379 | raise SettingsError('\n'.join(['Settings file is corrupt/missing:',
380 | '-' * 20,
381 | *[f'{key}:\n - {", ".join(value)}' if value
382 | else f"{key}" for key, value in missing_settings.items()],
383 | '-' * 20]))
384 |
385 | if not self._parameters['Download location']['options']:
386 | # Checks for a download setting, set the current path to that.
387 | path = self._filehandler.work_dir + '/DL/'
388 | self._parameters['Download location']['options'] = [path]
389 |
390 | try:
391 | # Checks if the active option is valid, if not reset to the first item.
392 | for setting in self._parameters:
393 | options = self._parameters[setting]['options']
394 | if options is not None:
395 | # Check if active option is a valid number.
396 | if not (0 <= self._parameters[setting]['active option'] < len(options)):
397 | self._parameters[setting]['active option'] = 0
398 | # Catch if the setting is missing for needed options.
399 | except KeyError as error:
400 | raise SettingsError(f'{setting} is missing a needed option {error}.')
401 | # Catches multiple type errors.
402 | except TypeError as error:
403 | raise SettingsError(f'An unexpected type was encountered for setting:\n - {setting}\n -- {error}')
404 |
405 | self._filehandler.save_profiles(self.profiles_data)
406 | self._filehandler.save_settings(self.settings_data)
407 |
408 |
409 | stylesheet = """
410 | QWidget {{
411 | background-color: {background_light};
412 | color: {text_normal};
413 | }}
414 | QMainWindow {{
415 | background-color: {background_dark};
416 | color: red;
417 | }}
418 | QMenu::separator {{
419 | height: 2px;
420 | }}
421 | QFrame#line {{
422 | color: {background_dark};
423 | }}
424 | QLabel {{
425 | background: #484848;
426 | padding: 2px;
427 | border-radius: 2px;
428 | outline: 0;
429 | }}
430 |
431 | QTabWidget::pane {{
432 | border: none;
433 | }}
434 |
435 | QMenu::item {{
436 | border: none;
437 | padding: 3px 20px 3px 5px
438 | }}
439 |
440 | QMenu {{
441 | border: 1px solid {background_dark};
442 | }}
443 |
444 | QMenu::item:selected {{
445 | background-color: {background_dark};
446 | }}
447 |
448 | QMenu::item:disabled {{
449 | color: #808080;
450 | }}
451 |
452 | QTabWidget {{
453 | background-color: {background_dark};
454 | }}
455 |
456 | QTabBar {{
457 | color: {background_dark};
458 | background: {background_dark};
459 | }}
460 |
461 | QTabBar::tab {{
462 | color: {text_shaded};
463 | background-color: {background_lightest};
464 | border-bottom: none;
465 | border-left: 1px solid #00000000;
466 | min-width: 15ex;
467 | min-height: 7ex;
468 | }}
469 |
470 | QTabBar::tab:selected {{
471 | color: white;
472 | background-color: {background_light};
473 | }}
474 | QTabBar::tab:!selected {{
475 | margin-top: 6px;
476 | background-color: {background_lightest}
477 | }}
478 |
479 | QTabWidget::tab-bar {{
480 | border-top: 1px solid {background_dark};
481 | }}
482 |
483 | QLineEdit {{
484 | background-color: {background_dark};
485 | color: {text_shaded};
486 | border-radius: 0px;
487 | padding: 0 3px;
488 | }}
489 |
490 | QLineEdit:disabled {{
491 | background-color: {background_dark};
492 | color: #505050;
493 | border-radius: none;
494 | }}
495 |
496 | QTextEdit {{
497 | background-color: {background_light};
498 | color: {text_shaded};
499 | border: red solid 1px;
500 | }}
501 |
502 | QTextEdit#TextFileEdit {{
503 | background-color: {background_dark};
504 | color: {text_shaded};
505 | border: red solid 1px;
506 | border-radius: 2px;
507 | }}
508 |
509 | QListWidget {{
510 | outline: none;
511 | outline-width: 0px;
512 | background: {background_dark};
513 | border: 1px solid {background_dark};
514 | border-radius: 2px;
515 | }}
516 |
517 | QScrollBar::vertical {{
518 | border: none;
519 | background-color: transparent;
520 | width: 10px;
521 | margin: 0px 0px 0px 0px;
522 | }}
523 | QScrollBar::vertical#main {{
524 | background-color: {background_dark};
525 | }}
526 | QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical {{
527 | border: none;
528 | background: none;
529 | width: 0px;
530 | height: 0px;
531 | }}
532 |
533 | QScrollBar::handle:vertical {{
534 | background: {background_dark};
535 | min-height: 20px;
536 | border-radius: 5px;
537 | }}
538 |
539 | QScrollBar::handle:vertical#main {{
540 | background: {background_darkest};
541 | }}
542 |
543 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
544 | background: none;
545 | }}
546 |
547 | QPushButton {{
548 | background-color: {background_dark};
549 | color: white;
550 | border: 1px solid transparent;
551 | border-radius: none;
552 | width: 60px;
553 | height: 20px;
554 | }}
555 |
556 | QPushButton:disabled {{
557 | border: 1px solid {background_dark};
558 | background-color: transparent;
559 | color: #757575;
560 | }}
561 |
562 | QPushButton:pressed {{
563 | background-color: #101010;
564 | color: white;
565 | }}
566 |
567 | QTreeWidget {{
568 | selection-color: red;
569 | border: none;
570 | outline: none;
571 | outline-width: 0px;
572 | selection-background-color: blue;
573 | }}
574 |
575 | QTreeWidget::item {{
576 | height: 16px;
577 | }}
578 |
579 | QTreeWidget::item:disabled {{
580 | color: grey;
581 | }}
582 |
583 | QTreeWidget::item:hover, QTreeWidget::item:selected {{
584 | background-color: transparent;
585 | color: white;
586 | }}
587 |
588 | QComboBox {{
589 | border: 1px solid {background_dark};
590 | border-radius: 0px;
591 | background-color: {background_dark};
592 | color: {text_shaded};
593 | padding-right: 5px;
594 | padding-left: 5px;
595 | }}
596 |
597 |
598 | QComboBox::drop-down {{
599 | border: 0px;
600 | background: none;
601 | }}
602 |
603 | QComboBox::disabled {{
604 | color: {background_light};
605 | }}
606 | """
607 |
608 | base_settings = dict()
609 | base_settings['profiles'] = {}
610 | base_settings['parameters'] = {}
611 | base_settings['default'] = {
612 | 'multidl_txt': '',
613 | "parallel": False,
614 | 'current_profile': '',
615 | 'select_on_focus': True,
616 | 'favorites': [],
617 | 'show_collapse_arrows': False,
618 | 'use_win_accent': False,
619 | 'custom': {
620 | "command": "Custom",
621 | "state": False,
622 | "tooltip": "Custom option, double click to edit."
623 | }
624 | }
625 | base_settings['parameters']['Convert to audio'] = {
626 | "active option": 0,
627 | "command": "-x --audio-format {}",
628 | "dependency": None,
629 | "options": ['mp3'],
630 | "state": False,
631 | "tooltip": "Convert video files to audio-only files\n"
632 | "Requires ffmpeg, avconv and ffprobe or avprobe."
633 | }
634 | base_settings['parameters']["Add thumbnail"] = {
635 | "active option": 0,
636 | "command": "--embed-thumbnail",
637 | "dependency": 'Convert to audio',
638 | "options": None,
639 | "state": False,
640 | "tooltip": "Include thumbnail on audio files."
641 | }
642 | base_settings['parameters']['Audio quality'] = {
643 | "active option": 0,
644 | "command": "--audio-quality {}",
645 | "dependency": 'Convert to audio',
646 | "options": ['0', '5', '9'],
647 | "state": False,
648 | "tooltip": "Specify ffmpeg/avconv audio quality.\ninsert"
649 | "a value between\n0 (better) and 9 (worse)"
650 | "for VBR\nor a specific bitrate like 128K"
651 | }
652 | base_settings['parameters']['Ignore errors'] = {
653 | "active option": 0,
654 | "command": "-i",
655 | "dependency": None,
656 | "options": None,
657 | "state": False,
658 | "tooltip": "Ignores errors, and jumps to next element instead of stopping."
659 | }
660 | base_settings['parameters']['Download location'] = {
661 | "active option": 0,
662 | "command": "-o {}",
663 | "dependency": None,
664 | "options": None,
665 | "state": False,
666 | "tooltip": "Select download location."
667 | }
668 | base_settings['parameters']['Strict file names'] = {
669 | "active option": 0,
670 | "command": "--restrict-filenames",
671 | "dependency": None,
672 | "options": None,
673 | "state": False,
674 | "tooltip": "Sets strict naming, to prevent unsupported characters in names."
675 | }
676 | base_settings['parameters']['Keep archive'] = {
677 | "active option": 0,
678 | "command": "--download-archive {}",
679 | "dependency": None,
680 | "options": ['Archive.txt'],
681 | "state": False,
682 | "tooltip": "Saves links to a textfile to avoid duplicate downloads later."
683 | }
684 | base_settings['parameters']['Force generic extractor'] = {
685 | "active option": 0,
686 | "command": "--force-generic-extractor",
687 | "dependency": None,
688 | "options": None,
689 | "state": False,
690 | "tooltip": "Force extraction to use the generic extractor"
691 | }
692 | base_settings['parameters']['Use proxy'] = {
693 | "active option": 0,
694 | "command": "--proxy {}",
695 | "dependency": None,
696 | "options": [],
697 | "state": False,
698 | "tooltip": "Use the specified HTTP/HTTPS/SOCKS proxy."
699 | }
700 | base_settings['parameters']['Socket timeout'] = {
701 | "active option": 0,
702 | "command": "--socket-timeout {}",
703 | "dependency": None,
704 | "options": [10, 60, 300],
705 | "state": False,
706 | "tooltip": "Time to wait before giving up, in seconds."
707 | }
708 | base_settings['parameters']['Source IP'] = {
709 | "active option": 0,
710 | "command": "--source-address {}",
711 | "dependency": None,
712 | "options": [],
713 | "state": False,
714 | "tooltip": "Client-side IP address to bind to."
715 | }
716 | base_settings['parameters']['Force ipv4/6'] = {
717 | "active option": 0,
718 | "command": "--{}",
719 | "dependency": None,
720 | "options": ['force-ipv4', 'force-ipv6'],
721 | "state": False,
722 | "tooltip": "Make all connections via ipv4/6."
723 | }
724 | base_settings['parameters']['Geo bypass URL'] = {
725 | "active option": 0,
726 | "command": "--geo-verification-proxy {}",
727 | "dependency": None,
728 | "options": [],
729 | "state": False,
730 | "tooltip": "Use this proxy to verify the IP address for some geo-restricted sites.\n"
731 | "The default proxy specified by"
732 | " --proxy (or none, if the options is not present)\nis used for the actual downloading."
733 | }
734 | base_settings['parameters']['Geo bypass country CODE'] = {
735 | "active option": 0,
736 | "command": "--geo-bypass-country {}",
737 | "dependency": None,
738 | "options": [],
739 | "state": False,
740 | "tooltip": "Force bypass geographic restriction with explicitly provided\n"
741 | "two-letter ISO 3166-2 country code (experimental)."
742 | }
743 | base_settings['parameters']['Playlist start'] = {
744 | "active option": 0,
745 | "command": "--playlist-start {}",
746 | "dependency": None,
747 | "options": [],
748 | "state": False,
749 | "tooltip": "Playlist video to start at (default is 1)."
750 | }
751 | base_settings['parameters']['Playlist end'] = {
752 | "active option": 0,
753 | "command": "--playlist-end {}",
754 | "dependency": None,
755 | "options": [],
756 | "state": False,
757 | "tooltip": "Playlist video to end at (default is last)."
758 | }
759 | base_settings['parameters']['Playlist items'] = {
760 | "active option": 0,
761 | "command": "--playlist-items {}",
762 | "dependency": None,
763 | "options": [],
764 | "state": False,
765 | "tooltip": "Playlist video items to download.\n"
766 | "Specify indices of the videos in the playlist "
767 | "separated by commas like:\n\"1,2,5,8\" if you want to download videos "
768 | "indexed 1, 2, 5, 8 in the playlist.\nYou can specify range:"
769 | "\"1-3,7,10-13\"\nwill download the videos at index:\n1, 2, 3, 7, 10, 11, 12 and 13."
770 | }
771 | base_settings['parameters']['Match titles'] = {
772 | "active option": 0,
773 | "command": "--match-title {}",
774 | "dependency": None,
775 | "options": [],
776 | "state": False,
777 | "tooltip": "Download only matching titles (regex or caseless sub-string)."
778 | }
779 | base_settings['parameters']['Reject titles'] = {
780 | "active option": 0,
781 | "command": "--reject-title {}",
782 | "dependency": None,
783 | "options": [],
784 | "state": False,
785 | "tooltip": "Skip download for matching titles (regex or caseless sub-string)."
786 | }
787 | base_settings['parameters']['Max downloads'] = {
788 | "active option": 0,
789 | "command": "--max-downloads {}",
790 | "dependency": None,
791 | "options": [],
792 | "state": False,
793 | "tooltip": "Abort after downloading a certain number of files."
794 | }
795 | base_settings['parameters']['Minimum size'] = {
796 | "active option": 0,
797 | "command": "--min-filesize {}",
798 | "dependency": None,
799 | "options": [],
800 | "state": False,
801 | "tooltip": "Do not download any videos smaller than SIZE (e.g. 50k or 44.6m)."
802 | }
803 | base_settings['parameters']['Maximum size'] = {
804 | "active option": 0,
805 | "command": "--max-filesize {}",
806 | "dependency": None,
807 | "options": [],
808 | "state": False,
809 | "tooltip": "Do not download any videos bigger than SIZE (e.g. 50k or 44.6m)."
810 | }
811 | base_settings['parameters']['No playlist'] = {
812 | "active option": 0,
813 | "command": "--no-playlist ",
814 | "dependency": None,
815 | "options": None,
816 | "state": False,
817 | "tooltip": "Download only the video, if the URL refers to a video and a playlist."
818 | }
819 | base_settings['parameters']['Download speed limit'] = {
820 | "active option": 0,
821 | "command": "--limit-rate {}",
822 | "dependency": None,
823 | "options": [],
824 | "state": False,
825 | "tooltip": "Maximum download rate in bytes per second (e.g. 50K or 4.2M)."
826 | }
827 | base_settings['parameters']['Retry rate'] = {
828 | "active option": 0,
829 | "command": "--retries {}",
830 | "dependency": None,
831 | "options": [10, 15],
832 | "state": False,
833 | "tooltip": "Number of retries (default is 10), or \"infinite\"."
834 | }
835 | base_settings['parameters']['Download order'] = {
836 | "active option": 0,
837 | "command": "--playlist-{}",
838 | "dependency": None,
839 | "options": ['reverse', 'random'],
840 | "state": False,
841 | "tooltip": "Download playlist videos in reverse/random order."
842 | }
843 | base_settings['parameters']['Prefer native/ffmpeg'] = {
844 | "active option": 0,
845 | "command": "--hls-prefer-{}",
846 | "dependency": None,
847 | "options": ['ffmpeg', 'native'],
848 | "state": False,
849 | "tooltip": "Use the native HLS downloader instead of ffmpeg, or vice versa."
850 | }
851 | base_settings['parameters']['Don\'t overwrite files'] = {
852 | "active option": 0,
853 | "command": "--no-overwrites",
854 | "dependency": None,
855 | "options": None,
856 | "state": False,
857 | "tooltip": "Do not overwrite files"
858 | }
859 | base_settings['parameters']['Don\'t continue files'] = {
860 | "active option": 0,
861 | "command": "--no-continue",
862 | "dependency": None,
863 | "options": None,
864 | "state": False,
865 | "tooltip": "Do not resume partially downloaded files."
866 | }
867 | base_settings['parameters']['Don\'t use .part files'] = {
868 | "active option": 0,
869 | "command": "--no-part",
870 | "dependency": None,
871 | "options": None,
872 | "state": False,
873 | "tooltip": "Do not use .part files - write directly into output file."
874 | }
875 | base_settings['parameters']['Verbose'] = {
876 | "active option": 0,
877 | "command": "--verbose",
878 | "dependency": None,
879 | "options": None,
880 | "state": False,
881 | "tooltip": "Print various debugging information."
882 | }
883 | base_settings['parameters']['Custom user agent'] = {
884 | "active option": 0,
885 | "command": "--user-agent {}",
886 | "dependency": None,
887 | "options": [],
888 | "state": False,
889 | "tooltip": "Specify a custom user agent."
890 | }
891 | base_settings['parameters']['Custom referer'] = {
892 | "active option": 0,
893 | "command": "--referer {}",
894 | "dependency": None,
895 | "options": [],
896 | "state": False,
897 | "tooltip": "Specify a custom referer, use if the video access is restricted to one domain."
898 | }
899 | base_settings['parameters']['Min sleep interval'] = {
900 | "active option": 0,
901 | "command": "--sleep-interval {}",
902 | "dependency": None,
903 | "options": [],
904 | "state": False,
905 | "tooltip": "Number of seconds to sleep before each download;\nwhen used "
906 | "alone or a lower bound of a range for randomized sleep before each\n"
907 | "download when used along with max sleep interval."
908 | }
909 | base_settings['parameters']['Max sleep interval'] = {
910 | "active option": 0,
911 | "command": "--max-sleep-interval {}",
912 | "dependency": "Min sleep interval",
913 | "options": [],
914 | "state": False,
915 | "tooltip": "Upper bound of a range for randomized sleep before each download\n"
916 | "(maximum possible number of seconds to sleep).\n"
917 | "Must only be used along with --min-sleep-interval."
918 | }
919 | base_settings['parameters']['Video format'] = {
920 | "active option": 0,
921 | "command": "--format {}",
922 | "dependency": None,
923 | "options": [],
924 | "state": False,
925 | "tooltip": "Video format code."
926 | }
927 | base_settings['parameters']['Write subtitle file'] = {
928 | "active option": 0,
929 | "command": "--write-sub",
930 | "dependency": None,
931 | "options": None,
932 | "state": False,
933 | "tooltip": "Write subtitle file."
934 | }
935 | base_settings['parameters']['Recode video'] = {
936 | "active option": 0,
937 | "command": "--recode-video {}",
938 | "dependency": None,
939 | "options": ['mp4', 'flv', 'ogg', 'webm', 'mkv', 'avi'],
940 | "state": False,
941 | "tooltip": "Encode the video to another format if necessary.\n"
942 | "Currently supported: mp4|flv|ogg|webm|mkv|avi."
943 | }
944 | base_settings['parameters']['No post overwrite'] = {
945 | "active option": 0,
946 | "command": "--no-post-overwrites",
947 | "dependency": None,
948 | "options": None,
949 | "state": False,
950 | "tooltip": "Do not overwrite post-processed files;\n"
951 | "the post-processed files are overwritten by default."
952 | }
953 | base_settings['parameters']['Embed subs'] = {
954 | "active option": 0,
955 | "command": "--embed-subs",
956 | "dependency": None,
957 | "options": None,
958 | "state": False,
959 | "tooltip": "Embed subtitles in the video (only for mp4, webm and mkv videos)"
960 | }
961 | base_settings['parameters']['Add metadata'] = {
962 | "active option": 0,
963 | "command": "--add-metadata",
964 | "dependency": None,
965 | "options": None,
966 | "state": False,
967 | "tooltip": "Write metadata to the video file."
968 | }
969 | base_settings['parameters']['Metadata from title'] = {
970 | "active option": 0,
971 | "command": "--metadata-from-title {}",
972 | "dependency": None,
973 | "options": [],
974 | "state": False,
975 | "tooltip": "Parse additional metadata like song title /"
976 | "artist from the video title.\nThe format"
977 | "syntax is the same as --output.\nRegular "
978 | "expression with named capture groups may"
979 | "also be used.\nThe parsed parameters replace "
980 | "existing values.\n\n"
981 | "Example:\n\"%(artist)s - %(title)s\" matches a"
982 | "title like \"Coldplay - Paradise\".\nExample"
983 | "(regex):\n\"(?P.+?) - (?P.+)\""
984 | }
985 | base_settings['parameters']['Merge output format'] = {
986 | "active option": 0,
987 | "command": "--merge-output-format {}",
988 | "dependency": None,
989 | "options": ["mp4", "mkv", "ogg", "webm", "flv"],
990 | "state": False,
991 | "tooltip": "If a merge is required (e.g. bestvideo+bestaudio),"
992 | "\noutput to given container format."
993 | "\nOne of mkv, mp4, ogg, webm, flv."
994 | "\nIgnored if no merge is required"
995 | }
996 | base_settings['parameters']['Username'] = {
997 | "active option": 0,
998 | "command": "--username {}",
999 | "dependency": None,
1000 | "options": [],
1001 | "state": False,
1002 | "tooltip": "Username of your account. Password will be asked for on run."
1003 | }
1004 | base_settings['parameters']['Best video quality'] = {
1005 | "active option": 0,
1006 | "command": "--format (bestvideo[height>2160][vcodec^=av01]/bestvideo[height>2160][vcodec=vp9]/bestvideo[height>1440][vcodec^=av01]/bestvideo[height>1440][vcodec^=vp9][fps>30]/bestvideo[height>1440][vcodec^=vp9]/bestvideo[height>1080][vcodec^=av01]/bestvideo[height>1080][vcodec^=vp9][fps>30]/bestvideo[height>1080][vcodec^=vp9]/bestvideo[height>720][vcodec^=av01]/bestvideo[height>720][vcodec^=vp9][fps>30]/bestvideo[height>720][vcodec^=vp9]/bestvideo[height>240][vcodec^=av01]/bestvideo[vcodec^=vp9][fps>30]/bestvideo[height>240][vcodec^=vp9]/best[height>240]/bestvideo[vcodec^=av01]/bestvideo[vcodec^=vp9])+bestaudio[asr=48000]/bestvideo+bestaudio/bestaudio[ext=opus]/best",
1007 | "dependency": None,
1008 | "options": None,
1009 | "state": False,
1010 | "tooltip": "This is a special Youtube only command that uses --format"
1011 | "\nThis picks best possible video quality (at the time of addition)"
1012 | "\nDo not use when converting to audio!"
1013 | }
1014 |
1015 |
1016 | def get_base_settings() -> dict:
1017 | settings = copy.deepcopy(base_settings)
1018 | return settings
1019 |
1020 |
1021 | def get_base_setting(section, setting):
1022 | return copy.deepcopy(base_settings[section][setting])
1023 |
1024 |
1025 | def darken(color: QColor):
1026 | return color.darker(150)
1027 |
1028 |
1029 | def lighten(color: QColor):
1030 | return color.lighter(150)
1031 |
1032 |
1033 | surface = QColor('#484848')
1034 | text = QColor('white')
1035 |
1036 | default_style = {'background_light': surface,
1037 | 'background_dark': darken(surface),
1038 | 'background_darkest': darken(darken(surface)),
1039 | 'background_lightest': lighten(surface),
1040 | 'text_shaded': darken(text),
1041 | 'text_normal': text}
1042 |
1043 |
1044 | def qcolorToStr(color_map: dict):
1045 | return {k: v.name(QColor.HexRgb) for k, v in color_map.items()}
1046 |
1047 |
1048 | def get_stylesheet(**kwargs):
1049 | global default_style
1050 | styles = default_style.copy()
1051 | styles.update(kwargs)
1052 | return stylesheet.format(**qcolorToStr(styles))
1053 |
1054 |
1055 | if __name__ == '__main__':
1056 | pass
1057 |
1058 | # print(color_text('rests valued', sections=(2, 5)))
1059 | # Testing of settings class:
1060 | # import json
1061 | # with open('..\\settings.json') as f:
1062 | # profile = json.load(f)
1063 | # s = SettingsClass(base_settings, profile['Profiles'])
1064 | # s.change_profile('Music')
1065 | # s.change_profile('Music')
1066 | # s.change_profile('Video')
1067 |
--------------------------------------------------------------------------------
/core.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import sys
4 |
5 | from PyQt5.QtCore import QProcess, pyqtSignal, Qt
6 | from PyQt5.QtGui import QIcon
7 | from PyQt5.QtWidgets import QFileDialog, QTreeWidgetItem, qApp, QDialog, QApplication, QMessageBox, \
8 | QTabWidget, QListWidgetItem
9 |
10 | from Modules import Dialog, Download, MainTab, ParameterTree, MainWindow, AboutTab, Downloader, ParameterTab, TextTab
11 | from Modules.download_element import ProcessListItem, MockDownload
12 | from Modules.parameter_tree import DATA_SLOT, LEVEL_SLOT, INDEX_SLOT, DISPLAY_NAME_SLOT
13 | from utils.filehandler import FileHandler
14 | from utils.utilities import path_shortener, color_text, format_in_list, SettingsError, get_stylesheet, \
15 | get_win_accent_color, ProfileLoadError, LessNiceDict, to_clipboard
16 |
17 |
18 | class GUI(MainWindow):
19 | """
20 | Runnable class that makes a wrapper for youtube-dl.
21 | """
22 | sendClose = pyqtSignal()
23 | EXIT_CODE_REBOOT = -123456789
24 |
25 | def __init__(self):
26 | """
27 | GUI that wraps a youtube-dl.exe to download videos and more.
28 | """
29 | super().__init__()
30 |
31 | # startup checks
32 | self.initial_checks()
33 |
34 | # Holds temporary passwords
35 | # TODO: Better password solution
36 | self._temp = LessNiceDict()
37 |
38 | # Builds GUI and everything related to that.
39 | self.build_gui()
40 |
41 | def initial_checks(self):
42 | """Loads settings and finds necessary files. Checks the setting file for errors."""
43 |
44 | self._debug = None
45 | self.file_handler = FileHandler()
46 | self.settings = self.file_handler.load_settings()
47 | self.downloader = Downloader(self.file_handler, self.settings.user_options['parallel'])
48 |
49 | # Find important executables and files
50 | self.youtube_dl_path = self.file_handler.find_exe('youtube-dl.exe')
51 | self.ffmpeg_path = self.file_handler.find_exe('ffmpeg.exe')
52 | self.program_workdir = self.file_handler.work_dir
53 | self.license_path = self.file_handler.find_file('LICENSE')
54 |
55 | # Download destination if not set by user
56 | self.local_dl_path = self.file_handler.work_dir + '/DL/'
57 |
58 | # NB! For stylesheet stuff, the slashes '\' in the path, must be replaced with '/'.
59 | # Using replace('\\', '/') on path. Done by file handler.
60 | self.icon_list = []
61 |
62 | # TODO: Turn into something more practical??
63 | # Find icon paths
64 | self.unchecked_icon = self.file_handler.find_file('GUI\\Icon_unchecked.ico')
65 | self.checked_icon = self.file_handler.find_file('GUI\\Icon_checked.ico')
66 | self.alert_icon = self.file_handler.find_file('GUI\\Alert.ico')
67 | self.window_icon = self.file_handler.find_file('GUI\\Grabber.ico')
68 | self.down_arrow_icon = self.file_handler.find_file('GUI\\down-arrow2.ico')
69 | self.down_arrow_icon_clicked = self.file_handler.find_file('GUI\\down-arrow2-clicked.ico')
70 |
71 | # Adding icons to list. For debug purposes.
72 | self.icon_list.append(self.unchecked_icon)
73 | self.icon_list.append(self.checked_icon)
74 | self.icon_list.append(self.alert_icon)
75 | self.icon_list.append(self.window_icon)
76 | self.icon_list.append(self.down_arrow_icon)
77 | self.icon_list.append(self.down_arrow_icon_clicked)
78 |
79 | # Creating icon objects for use in message windows.
80 | self.alertIcon = QIcon()
81 | self.windowIcon = QIcon()
82 |
83 | # Setting the icons image, using found paths.
84 | self.alertIcon.addFile(self.alert_icon)
85 | self.windowIcon.addFile(self.window_icon)
86 |
87 | def build_gui(self):
88 | """Generates the GUI elements, and hooks everything up."""
89 |
90 | # Removes anim from combobox opening
91 | QApplication.setEffectEnabled(Qt.UI_AnimateCombo, False)
92 |
93 | # Sorts the parameters, so that favorite ones are added to the favorite widget.
94 | favorites = {i: self.settings[i] for i in self.settings.get_favorites()}
95 | options = {k: v for k, v in self.settings.parameters.items() if k not in favorites}
96 |
97 | # Create top level tab widget system for the UI
98 | self.tab_widget = QTabWidget(self)
99 |
100 | # Close signal
101 | self.onclose.connect(self.confirm)
102 | # Confirmation to close signal
103 | self.sendClose.connect(self.closeE)
104 |
105 | # First Tab | Main tab
106 | self.tab1 = MainTab(self.settings, self.tab_widget)
107 |
108 | self.tab1.start_btn.clicked.connect(self.queue_download)
109 | # Stop button stops downloads, and may clear queue.
110 | self.tab1.stop_btn.clicked.connect(self.stop_download)
111 | self.tab1.close_btn.clicked.connect(self.close)
112 |
113 | # Perform check if enough is present to start a download after user actions
114 | self.tab1.checkbox.stateChanged.connect(self.allow_start)
115 | self.tab1.url_input.textChanged.connect(self.allow_start)
116 |
117 | # Queue downloading
118 | self.tab1.url_input.returnPressed.connect(self.tab1.start_btn.click)
119 |
120 | # Change profile
121 | self.tab1.profile_dropdown.currentTextChanged.connect(self.load_profile)
122 | # Delete profile
123 | self.tab1.profile_dropdown.deleteItem.connect(self.delete_profile)
124 |
125 | # Tab 2 | Parameter Tab
126 | self.tab2 = ParameterTab(options, favorites, self.settings, self)
127 |
128 | # Connecting tab 2.
129 | self.tab2.open_folder_action.triggered.connect(self.open_folder)
130 | self.tab2.copy_action.triggered.connect(self.copy_to_cliboard)
131 |
132 | self.tab2.options.itemChanged.connect(self.parameter_updater)
133 | self.tab2.options.move_request.connect(self.move_item)
134 | self.tab2.options.itemRemoved.connect(self.item_removed)
135 | self.tab2.options.addOption.connect(self.add_option)
136 |
137 | self.tab2.favorites.itemChanged.connect(self.parameter_updater)
138 | self.tab2.favorites.move_request.connect(self.move_item)
139 | self.tab2.favorites.itemRemoved.connect(self.item_removed)
140 | self.tab2.favorites.addOption.connect(self.add_option)
141 |
142 | self.tab2.browse_btn.clicked.connect(self.savefile_dialog)
143 | self.tab2.save_profile_btn.clicked.connect(self.save_profile)
144 |
145 | # Tab 3 | Basic text editor
146 | self.tab3 = TextTab(parent=self)
147 |
148 | # When loadbutton is clicked, launch load textfile.
149 | self.tab3.loadButton.clicked.connect(self.load_text_from_file)
150 | # When savebutton clicked, save text to file.
151 | self.tab3.saveButton.clicked.connect(self.save_text_to_file)
152 |
153 | # Tab 4
154 | # Button to browse for .txt file to download files.
155 | self.tab4 = AboutTab(self.settings, parent=self)
156 |
157 | # Connect buttons to functions
158 | self.tab4.update_btn.clicked.connect(self.update_youtube_dl)
159 | self.tab4.dirinfo_btn.clicked.connect(self.dir_info)
160 | self.tab4.reset_btn.clicked.connect(self.reset_settings)
161 | self.tab4.location_btn.clicked.connect(self.textfile_dialog)
162 | self.tab4.debug_info.clicked.connect(self.toggle_debug)
163 | self.tab4.dl_mode_btn.clicked.connect(self.toggle_modes)
164 |
165 | # Future tab creation here! Currently 4 tabs
166 | # TODO: Move stylesheet applying to method, make color picking dialog to customize in realtime
167 |
168 | # Windows specific, only triggered in settings manually
169 | if self.settings.user_options['use_win_accent']:
170 | try:
171 | color = get_win_accent_color()
172 | bg_color = f"""
173 | QMainWindow {{
174 | background-color: {color};
175 | }}
176 | QTabBar {{
177 | background-color: {color};
178 | }}"""
179 | except (OSError, PermissionError):
180 | bg_color = ''
181 | else:
182 | bg_color = ''
183 |
184 | self.style_with_options = bg_color + f"""
185 | QCheckBox::indicator:unchecked {{
186 | image: url({self.unchecked_icon});
187 | }}
188 |
189 | QCheckBox::indicator:checked {{
190 | image: url({self.checked_icon});
191 | }}
192 | QComboBox::down-arrow {{
193 | border-image: url({self.down_arrow_icon});
194 | height: {self.tab1.profile_dropdown.iconSize().height()}px;
195 | width: {self.tab1.profile_dropdown.iconSize().width()}px;
196 | }}
197 |
198 | QComboBox::down-arrow::on {{
199 | image: url({self.down_arrow_icon_clicked});
200 | height: {self.tab1.profile_dropdown.iconSize().height()}px;
201 | width: {self.tab1.profile_dropdown.iconSize().width()}px;
202 |
203 | }}
204 |
205 | QTreeWidget::indicator:checked {{
206 | image: url({self.checked_icon});
207 | }}
208 |
209 | QTreeWidget::indicator:unchecked {{
210 | image: url({self.unchecked_icon});
211 | }}"""
212 |
213 | if not self.settings.user_options['show_collapse_arrows']:
214 | self.style_with_options += """
215 | QTreeWidget::branch {{
216 | image: none;
217 | border-image: none;
218 | }}
219 |
220 | QTreeWidget::branch:has-siblings:!adjoins-item {{
221 | image: none;
222 | border-image: none;
223 | }}
224 |
225 | QTreeWidget::branch:has-siblings:adjoins-item {{
226 | border-image: none;
227 | image: none;
228 | }}
229 |
230 | QTreeWidget::branch:!has-children:!has-siblings:adjoins-item {{
231 | border-image: none;
232 | image: none;
233 | }}
234 |
235 | """
236 |
237 | # Adds tabs to the tab widget
238 | self.tab_widget.addTab(self.tab1, 'Main')
239 | self.tab_widget.addTab(self.tab2, 'Param')
240 | self.tab_widget.addTab(self.tab3, 'List')
241 | self.tab_widget.addTab(self.tab4, 'About')
242 |
243 | # Sets the styling for GUI
244 | self.setStyleSheet(get_stylesheet() + self.style_with_options)
245 |
246 | self.setWindowTitle('Grabber')
247 | self.setWindowIcon(self.windowIcon)
248 |
249 | # Set base size.
250 | self.setMinimumWidth(340)
251 | self.setMinimumHeight(400)
252 |
253 | # Selects the text in the URL box when focus is gained
254 | if self.settings.user_options['select_on_focus']:
255 | self.gotfocus.connect(self.window_focus_event)
256 | else:
257 | self.tab1.url_input.setFocus()
258 |
259 | # Check for youtube
260 | if self.youtube_dl_path is None:
261 | self.tab4.update_btn.setDisabled(True)
262 | self.alert_message('Warning!', '\nNo youtube-dl.exe found! Add to path, '
263 | 'or make sure it\'s in the same folder as this program. '
264 | 'Then close and reopen this program.', '')
265 | # Sets the download items tooltips to the full file path.
266 | self.download_name_handler()
267 |
268 | # Ensures widets are in correct state at startup and when tab1.lineedit is changed.
269 | self.allow_start()
270 | # Shows the main window.
271 | self.setCentralWidget(self.tab_widget)
272 |
273 | self.show()
274 |
275 | # Below NEEDS to connect after show!!
276 |
277 | # Ensures size of parameter tab is right
278 | self.resizedByUser.connect(self.resize_contents)
279 |
280 | # Called after show, to ensure base size is consistent.
281 | self.tab2.enable_favorites(bool(favorites))
282 |
283 | # State changed
284 | self.downloader.stateChanged.connect(self.allow_start)
285 | self.tab_widget.currentChanged.connect(self.resize_contents)
286 |
287 | def toggle_debug(self):
288 | """ Can be triggered to enable/disable debugging information """
289 | self._debug = not self._debug
290 |
291 | self.tab4.debug_info.setText(f'Debug:\n{self._debug}')
292 | for i in self.tab1.process_list.iter_items():
293 | i.toggle_debug(self._debug)
294 |
295 | def toggle_modes(self):
296 | """ Swap between parallel and sequential download mode. """
297 | if self.downloader.RUNNING or self.downloader.has_pending():
298 | self.alert_message('Action failed',
299 | 'Mode was not changed',
300 | "Unable to toggle between download mode with active downloads!")
301 | return
302 |
303 | else:
304 | parallel = not self.settings.user_options['parallel']
305 | self.settings.user_options['parallel'] = parallel
306 | self.tab4.dl_mode_btn.setText(("Singular" if not parallel else "Parallel") + '\nDownloads')
307 | self.downloader.set_mode(parallel=parallel)
308 |
309 | def save_profile(self):
310 | """ Save parameter config to a profile. """
311 | dialog = Dialog(self.tab_widget, 'Name profile', 'Give a name to the profile!')
312 | if dialog.exec() != QDialog.Accepted:
313 | return
314 | elif dialog.option.text() in ('Custom', 'None'):
315 | self.alert_message('Error', 'This profile name is not allowed!', '')
316 |
317 | profile_name = dialog.option.text()
318 |
319 | if profile_name in self.settings.profiles:
320 | result = self.alert_message('Overwrite profile?',
321 | f'Do you want to overwrite profile:',
322 | f'{profile_name}'.center(45),
323 | True)
324 | if result != QMessageBox.Yes:
325 | return
326 |
327 | self.tab1.profile_dropdown.blockSignals(True)
328 | self.tab1.profile_dropdown.setDisabled(False)
329 |
330 | if self.tab1.profile_dropdown.findText(profile_name) == -1:
331 | self.tab1.profile_dropdown.addItem(profile_name)
332 | self.tab1.profile_dropdown.setCurrentText(profile_name)
333 | self.tab1.profile_dropdown.removeItem(self.tab1.profile_dropdown.findText('None'))
334 | self.tab1.profile_dropdown.removeItem(self.tab1.profile_dropdown.findText('Custom'))
335 | self.tab1.profile_dropdown.blockSignals(False)
336 |
337 | self.settings.create_profile(profile_name)
338 |
339 | self.file_handler.save_settings(self.settings.settings_data)
340 | self.file_handler.save_profiles(self.settings.profiles_data)
341 |
342 | def load_profile(self):
343 | """ Loads a profile and sets the GUI to that configuration """
344 | try:
345 | profile_name = self.tab1.profile_dropdown.currentText()
346 |
347 | if profile_name in ('None', 'Custom'):
348 | return
349 |
350 | success = self.settings.change_profile(profile_name)
351 |
352 | if not success:
353 | self.alert_message('Error',
354 | 'Failed to find profile',
355 | f'The profile "{profile_name}" was not found!')
356 | return
357 |
358 | favorites = {i: self.settings[i] for i in self.settings.get_favorites()}
359 | options = {k: v for k, v in self.settings.parameters.items() if k not in favorites}
360 |
361 | self.tab2.options.load_profile(options)
362 | self.tab2.favorites.load_profile(favorites)
363 | self.tab2.find_download_widget()
364 | self.download_name_handler()
365 |
366 | self.tab1.profile_dropdown.blockSignals(True)
367 | self.tab1.profile_dropdown.removeItem(self.tab1.profile_dropdown.findText('None'))
368 | self.tab1.profile_dropdown.removeItem(self.tab1.profile_dropdown.findText('Custom'))
369 | self.tab1.profile_dropdown.blockSignals(False)
370 |
371 | self.file_handler.save_settings(self.settings.settings_data)
372 | except Exception as e:
373 | # TODO: Replace this with logging
374 | import traceback
375 | traceback.print_exc()
376 |
377 | def delete_profile(self):
378 | """ Removes a profile """
379 | index = self.tab1.profile_dropdown.currentIndex()
380 | text = self.tab1.profile_dropdown.currentText()
381 | if text in ('Custom', 'None'):
382 | return
383 |
384 | self.settings.delete_profile(text)
385 |
386 | self.tab1.profile_dropdown.blockSignals(True)
387 | self.tab1.profile_dropdown.removeItem(index)
388 | self.tab1.profile_dropdown.addItem('Custom')
389 | self.tab1.profile_dropdown.setCurrentText('Custom')
390 | self.tab1.profile_dropdown.blockSignals(False)
391 |
392 | self.file_handler.save_settings(self.settings.settings_data)
393 |
394 | def item_removed(self, item: QTreeWidgetItem, index):
395 | """
396 | Parent who had child removed. Updates settings and numbering of settings_data 35
397 | TODO: Apply named constant variables to data entries instead of numbers.
398 | Constants defined in parameter_tree.py
399 | """
400 |
401 | def get_index(data, option_name):
402 | try:
403 | return data[parameter_name]['options'].index(option_name)
404 | except KeyError:
405 | # TODO: Logging
406 | # print(f'Profile {profile} has no parameter {parameter_name}, skipping')
407 | return None
408 | except ValueError:
409 | # print(f'Profile {profile} does not have the option {option_name}, skipping')
410 | return None
411 |
412 | parameter_name = item.data(0, DISPLAY_NAME_SLOT)
413 | option_name = self.settings.parameters[parameter_name]['options'][index]
414 |
415 | has_option = []
416 | for profile, data in self.settings.profiles_data.items():
417 | new_index = get_index(data, option_name)
418 |
419 | if new_index is not None and data[parameter_name]['active option'] == new_index:
420 | has_option.append(profile)
421 |
422 | if has_option:
423 | self.alert_message('Error',
424 | f'One or more profiles has this as the selected option:',
425 | f'\t{", ".join(has_option)}')
426 | return
427 |
428 | item.treeWidget().del_option(item, item.child(index))
429 |
430 | self.settings.remove_parameter_option(item.data(0, DISPLAY_NAME_SLOT), index)
431 |
432 | # Deletes from profiles
433 | for profile, data in self.settings.profiles_data.items():
434 | new_index = get_index(data, option_name)
435 | if new_index is None:
436 | continue
437 | del data[parameter_name]['options'][new_index]
438 | new_index = data[parameter_name]['active option']
439 | new_index -= 1 if new_index > 0 else 0
440 | data[parameter_name]['active option'] = new_index
441 |
442 | self.file_handler.save_profiles(self.settings.profiles_data)
443 | self.file_handler.save_settings(self.settings.settings_data)
444 |
445 | def design_option_dialog(self, name, description):
446 | """
447 | Creates dialog for user input
448 | # TODO: Use this method where possible
449 | """
450 | dialog = Dialog(self.tab_widget, name, description)
451 | if dialog.exec_() == QDialog.Accepted:
452 | return dialog.option.text()
453 | return None
454 |
455 | def add_option(self, item: QTreeWidgetItem):
456 | """
457 | Check if parameter has a possible option parameter, and lets the user add on if one exist.
458 | """
459 | if item.data(0, DATA_SLOT) == 'Download location':
460 | self.alert_message('Error!', 'Please use the browse button\nto select download location!', None)
461 | return
462 |
463 | if item.data(0, LEVEL_SLOT) == 2:
464 | self.alert_message('Error!', 'Custom option does not take a command!', None)
465 | return
466 |
467 | # TODO: Standardise setting an parameter to checked, and updating to expanded state.
468 | elif '{}' in self.settings[item.data(0, DATA_SLOT)]['command']:
469 |
470 | item.treeWidget().blockSignals(True)
471 | option = self.design_option_dialog(item.text(0), item.toolTip(0))
472 |
473 | if option:
474 | if option in self.settings[item.data(0, DATA_SLOT)]['options']:
475 | self.alert_message('Error', 'That option already exists!', '')
476 | item.treeWidget().blockSignals(False)
477 | return True
478 |
479 | new_option = ParameterTree.make_option(option.strip(),
480 | item,
481 | True,
482 | 1,
483 | None,
484 | None,
485 | 0)
486 |
487 | move = item.takeChild(item.indexOfChild(new_option))
488 | item.insertChild(0, move)
489 |
490 | self.settings.add_parameter_option(item.data(0, DATA_SLOT), option)
491 |
492 | for i in range(len(self.settings[item.data(0, DATA_SLOT)]['options'])):
493 | child = item.child(i)
494 | child.setData(0, INDEX_SLOT, i)
495 | if i == 0:
496 | child.setCheckState(0, Qt.Checked)
497 | child.setFlags(child.flags() ^ Qt.ItemIsUserCheckable)
498 | else:
499 | child.setCheckState(0, Qt.Unchecked)
500 | child.setFlags(child.flags() | Qt.ItemIsUserCheckable)
501 |
502 | item.setCheckState(0, Qt.Checked)
503 | item.setExpanded(True)
504 | item.treeWidget().update_size()
505 | try:
506 | self.settings.need_parameters.remove(item.data(0, DATA_SLOT))
507 | except ValueError:
508 | pass
509 |
510 | self.file_handler.save_settings(self.settings.settings_data)
511 |
512 | item.treeWidget().blockSignals(False)
513 | return True
514 | else:
515 | item.treeWidget().blockSignals(False)
516 | return False
517 | else:
518 | self.alert_message('Error!', 'The specified option does not take arguments!', None)
519 | return False
520 | # print('Doesn\'t have an option')
521 |
522 | def move_item(self, item: QTreeWidgetItem, favorite: bool):
523 | """ Move an time to or from the favorites tree. """
524 |
525 | if favorite:
526 | tree = self.tab2.options
527 | self.settings.user_options['favorites'].remove(item.data(0, DISPLAY_NAME_SLOT))
528 | else:
529 | tree = self.tab2.favorites
530 | self.settings.user_options['favorites'].append(item.data(0, DISPLAY_NAME_SLOT))
531 |
532 | self.tab2.enable_favorites(bool(self.settings.user_options['favorites']))
533 | tree.blockSignals(True)
534 | tree.addTopLevelItem(item)
535 |
536 | self.tab2.options.update_size()
537 | self.tab2.favorites.update_size()
538 |
539 | self.file_handler.save_settings(self.settings.settings_data)
540 |
541 | if item.checkState(0) == Qt.Checked:
542 | item.setExpanded(True)
543 | else:
544 | item.setExpanded(False)
545 |
546 | tree.blockSignals(False)
547 |
548 | def resize_contents(self):
549 | """ Resized parameterTree widgets in tab2 to the window."""
550 | if self.tab_widget.currentIndex() == 0:
551 | self.tab1.process_list.setMinimumWidth(self.window().width() - 18)
552 |
553 | elif self.tab_widget.currentIndex() == 1:
554 | size = self.height() - (self.tab2.opt_frame2.height() + self.tab2.download_lineedit.height()
555 | + self.tab2.optlabel.height() + self.tab_widget.tabBar().height() + 40)
556 |
557 | ParameterTree.max_size = size
558 | self.tab2.options.setFixedHeight(size)
559 | self.tab2.favorites.setFixedHeight(size)
560 |
561 | def window_focus_event(self):
562 | """ Selects text in tab1 line edit on window focus. """
563 | # self.tab2.options.max_size =
564 | if self.tab1.url_input.isEnabled():
565 | self.tab1.url_input.setFocus()
566 | self.tab1.url_input.selectAll()
567 |
568 | def copy_to_cliboard(self):
569 | """ Adds text to clipboard. """
570 | if self.settings.is_activate('Download location'):
571 | text = self.settings.get_active_setting('Download location')
572 | else:
573 | text = self.local_dl_path
574 | to_clipboard(text)
575 |
576 | def open_folder(self):
577 | """ Opens a folder at specified location. """
578 | # noinspection PyCallByClass
579 | QProcess.startDetached('explorer {}'.format(self.tab2.download_lineedit.toolTip().replace("/", "\\")))
580 |
581 | def download_name_handler(self):
582 | """ Formats download names and removes the naming string for ytdl. """
583 | item = self.tab2.download_option
584 |
585 | item.treeWidget().blockSignals(True)
586 | for number in range(item.childCount()):
587 | path = self.settings['Download location']['options'][number]
588 | item.child(number).setToolTip(0, path)
589 | item.child(number).setText(0, path_shortener(path))
590 |
591 | if item.checkState(0) == Qt.Checked:
592 | for number in range(item.childCount()):
593 | if item.child(number).checkState(0) == Qt.Checked:
594 | self.tab2.download_lineedit.setText(item.child(number).data(0, DISPLAY_NAME_SLOT))
595 | self.tab2.download_lineedit.setToolTip(item.child(number).data(0, DATA_SLOT))
596 | break
597 | else:
598 | # TODO: Add error handling here
599 | print('WARNING! No selected download item, this should not happen.... ')
600 | print('You messed with the settings... didn\'t you?!')
601 | # raise SettingsError('Error, no active option!')
602 | else:
603 | self.tab2.download_lineedit.setText(path_shortener(self.local_dl_path))
604 | self.tab2.download_lineedit.setToolTip(self.local_dl_path)
605 | item.treeWidget().blockSignals(False)
606 |
607 | def download_option_handler(self, full_path: str):
608 | """ Handles the download options. """
609 | # Adds new dl location to the tree and settings. Removes oldest one, if there is more than 3.
610 | # Remove try/except later.
611 |
612 | item = self.tab2.download_option
613 |
614 | if not full_path.endswith('/'):
615 | full_path += '/'
616 | short_path = path_shortener(full_path)
617 | names = [item.child(i).data(0, DISPLAY_NAME_SLOT) for i in range(item.childCount())]
618 |
619 | if short_path in names and full_path in self.settings['Download location']['options']:
620 | self.alert_message('Warning', 'Option already exists!', '', question=False)
621 | return
622 |
623 | item.treeWidget().blockSignals(True)
624 |
625 | sub = ParameterTree.make_option(name=full_path,
626 | parent=item,
627 | checkstate=False,
628 | level=1,
629 | tooltip=full_path,
630 | dependency=None,
631 | subindex=None)
632 | sub.setData(0, DISPLAY_NAME_SLOT, short_path)
633 | # print('sorting enabled?', item.treeWidget().isSortingEnabled())
634 |
635 | # Take item from one tree and insert in another.
636 | moving_sub = item.takeChild(item.indexOfChild(sub))
637 | item.insertChild(0, moving_sub)
638 |
639 | # Renumber the items, to give then the right index.
640 | for number in range(item.childCount()):
641 | item.child(number).setData(0, INDEX_SLOT, number)
642 |
643 | if self.settings['Download location']['options'] is None:
644 | self.settings['Download location']['options'] = [full_path]
645 | else:
646 | self.settings.add_parameter_option('Download location', full_path)
647 |
648 | item.treeWidget().update_size()
649 |
650 | item.treeWidget().setSortingEnabled(True)
651 | item.treeWidget().blockSignals(False)
652 |
653 | # self.tab2.download_lineedit.setText(location)
654 | # self.tab2.download_lineedit.setToolTip(tooltip)
655 | try:
656 | self.settings.need_parameters.remove(item.data(0, DATA_SLOT))
657 | except ValueError:
658 | pass
659 |
660 | item.setCheckState(0, Qt.Checked)
661 | sub.setCheckState(0, Qt.Checked)
662 |
663 | self.file_handler.save_settings(self.settings.settings_data)
664 |
665 | # TODO: Show queue option
666 |
667 | def reset_settings(self):
668 | result = self.alert_message('Warning!',
669 | 'Restart required!',
670 | 'To reset the settings, '
671 | 'the program has to be restarted. '
672 | 'Do you want to reset and restart?',
673 | question=True)
674 |
675 | if result == QMessageBox.Yes:
676 | self.settings = self.file_handler.load_settings(reset=True)
677 | qApp.exit(GUI.EXIT_CODE_REBOOT)
678 |
679 | def parameter_updater(self, item: QTreeWidgetItem, col=None, save=True):
680 | """Handles updating the options for a parameter."""
681 | if 'Custom' != self.tab1.profile_dropdown.currentText():
682 | self.tab1.profile_dropdown.addItem('Custom')
683 | self.tab1.profile_dropdown.setCurrentText('Custom')
684 | self.settings.user_options['current_profile'] = ''
685 |
686 | if item.data(0, LEVEL_SLOT) == 0:
687 | if item.data(0, DATA_SLOT) in self.settings.need_parameters:
688 | result = self.alert_message('Warning!', 'This parameter needs an option!', 'There are no options!\n'
689 | 'Would you make one?', True)
690 | if result == QMessageBox.Yes:
691 | success = self.add_option(item)
692 |
693 | if not success:
694 | item.treeWidget().blockSignals(True)
695 | item.setCheckState(0, Qt.Unchecked)
696 | item.treeWidget().blockSignals(False)
697 | item.treeWidget().check_dependency(item)
698 | else:
699 | item.treeWidget().blockSignals(True)
700 | item.setCheckState(0, Qt.Unchecked)
701 | item.treeWidget().blockSignals(False)
702 | item.treeWidget().check_dependency(item)
703 |
704 | if item.checkState(0) == Qt.Checked:
705 | self.settings[item.data(0, DATA_SLOT)]['state'] = True
706 | if item.data(0, DATA_SLOT) == 'Download location':
707 | for i in range(item.childCount()):
708 | self.parameter_updater(item.child(i), save=False)
709 |
710 | else:
711 | self.settings[item.data(0, DATA_SLOT)]['state'] = False
712 | if item.data(0, DATA_SLOT) == 'Download location':
713 | self.tab2.download_lineedit.setText(path_shortener(self.local_dl_path))
714 | self.tab2.download_lineedit.setToolTip(self.local_dl_path)
715 |
716 | elif item.data(0, LEVEL_SLOT) == 1:
717 | # Settings['Settings'][Name of setting]['active option']] = index of child
718 | self.settings[item.parent().data(0, DATA_SLOT)]['active option'] = item.data(0, INDEX_SLOT)
719 | if item.parent().data(0, DATA_SLOT) == 'Download location':
720 | if item.checkState(0) == Qt.Checked:
721 | self.tab2.download_lineedit.setText(item.data(0, DISPLAY_NAME_SLOT))
722 | self.tab2.download_lineedit.setToolTip(item.data(0, DATA_SLOT))
723 |
724 | if save:
725 | self.file_handler.save_settings(self.settings.settings_data)
726 |
727 | def dir_info(self):
728 | # TODO: Print this info to GUI.
729 | file_dir = os.path.dirname(os.path.abspath(__file__)).replace('\\', '/')
730 | debug = [color_text('Youtube-dl.exe path: ') + str(self.youtube_dl_path),
731 | color_text('ffmpeg.exe path: ') + str(self.ffmpeg_path),
732 | color_text('Filedir: ') + str(file_dir),
733 | color_text('Workdir: ') + str(self.file_handler.work_dir),
734 | color_text('Youtube-dl working directory: ') + str(self.program_workdir)]
735 |
736 | debug += [color_text('\nIcon paths:'), *self.icon_list]
737 |
738 | debug += [color_text('\nChecking if icons are in place:', 'darkorange', 'bold')]
739 |
740 | for i in self.icon_list:
741 | if i is not None:
742 | if self.file_handler.is_file(str(i)):
743 | try:
744 | debug.append(f'Found: {os.path.split(i)[1]}')
745 | except IndexError:
746 | debug.append(f'Found: {i}')
747 |
748 | if self.icon_list.count(None):
749 | debug.append(color_text(f'Missing {self.icon_list.count(None)} icon file(s)!'))
750 |
751 | # RichText does not support both the use of \n and
at the same time. Use
752 | debug_info = '
'.join([text.replace('\n', '
') for text in debug if text is not None])
753 | mock_download = MockDownload(info=debug_info)
754 | self.add_download_to_gui(mock_download)
755 |
756 | self.tab_widget.setCurrentIndex(0)
757 |
758 | def update_youtube_dl(self):
759 | # TODO: Require no other downloads active
760 | update = Download(self.program_workdir,
761 | self.youtube_dl_path,
762 | ['-U', '--encoding', 'utf-8'],
763 | info='Youtube-dl update',
764 | parent=self)
765 | self.tab_widget.setCurrentIndex(0) # Go to main
766 | self.add_download_to_gui(update)
767 | self.downloader.queue_dl(update)
768 |
769 | def savefile_dialog(self):
770 | location = QFileDialog.getExistingDirectory(parent=self.tab_widget)
771 |
772 | if location == '':
773 | pass
774 | elif os.path.exists(location):
775 | self.download_option_handler(location)
776 | else:
777 | self.alert_message('Error', 'Could not find the specified folder.'
778 | '\nReport this on githib if it keeps happening.')
779 |
780 | def textfile_dialog(self):
781 | location = \
782 | QFileDialog.getOpenFileName(parent=self.tab_widget, filter='*.txt',
783 | caption='Select textfile with video links')[0]
784 | if location == '':
785 | pass
786 | elif self.file_handler.is_file(location):
787 | if not self.tab3.SAVED:
788 | result = self.alert_message('Warning!',
789 | 'Selecting new textfile,'
790 | ' this will load over the text in the download list tab!',
791 | 'Do you want to load over the unsaved changes?',
792 | question=True)
793 |
794 | if result == QMessageBox.Yes:
795 | self.settings.user_options['multidl_txt'] = location
796 | self.tab4.textfile_url.setText(location)
797 | self.tab3.SAVED = True
798 | self.load_text_from_file()
799 |
800 | self.file_handler.save_settings(self.settings.settings_data)
801 | else:
802 | self.settings.user_options['multidl_txt'] = location
803 | self.tab4.textfile_url.setText(location)
804 | self.tab3.SAVED = True
805 | self.load_text_from_file()
806 |
807 | self.file_handler.save_settings(self.settings.settings_data)
808 | else:
809 | self.alert_message('Error!', 'Could not find file!', '')
810 | # Check if the checkbox is toggled, and disables the line edit if it is.
811 | # Also disables start button if lineEdit is empty and checkbox is not checked
812 |
813 | def queue_download(self):
814 | command = []
815 |
816 | if self.tab1.checkbox.isChecked():
817 | txt = self.settings.user_options['multidl_txt']
818 | url = None
819 | if not txt:
820 | self.alert_message('Error!', 'No textfile selected!', '')
821 | return
822 |
823 | command += ['-a', f'{txt}']
824 | else:
825 | txt = self.tab1.url_input.text()
826 | url = txt
827 | command.append(f'{txt}')
828 |
829 | # for i in range(len(command)):
830 | # command[i] = command[i].format(txt=txt)'
831 | # TODO: Let user pick naming format
832 | file_name_format = '%(title)s.%(ext)s'
833 |
834 | for parameter, options in self.settings.parameters.items():
835 | if parameter == 'Download location':
836 | if options['state']:
837 | add = format_in_list(options['command'],
838 | self.settings.get_active_setting(parameter) + file_name_format)
839 | command += add
840 | else:
841 | command += ['-o', self.local_dl_path + file_name_format]
842 |
843 | elif parameter == 'Keep archive':
844 | if options['state']:
845 | add = format_in_list(options['command'],
846 | os.path.join(os.getcwd(), self.settings.get_active_setting(parameter)))
847 | command += add
848 | elif parameter == 'Username':
849 | if options['state']:
850 | option = self.settings.get_active_setting(parameter)
851 | if hash(option) in self._temp:
852 | _password = self._temp[hash(option)]
853 | else:
854 | dialog = Dialog(self,
855 | 'Password',
856 | f'Input you password for the account "{option}".',
857 | allow_empty=True,
858 | password=True)
859 |
860 | if dialog.exec_() == QDialog.Accepted:
861 | self._temp[hash(option)] = _password = dialog.option.text()
862 | else:
863 | self.alert_message('Error', color_text('ERROR: No password was entered.', sections=(0, 6)),
864 | '')
865 | return
866 |
867 | add = format_in_list(options['command'], option)
868 | add += ['--password', _password]
869 |
870 | command += add
871 |
872 | else:
873 | if options['state']:
874 | option = self.settings.get_active_setting(parameter)
875 | add = format_in_list(options['command'], option)
876 | command += add
877 |
878 | # Sets encoding to utf-8, allowing better character support in output stream.
879 | command += ['--encoding', 'utf-8']
880 |
881 | if self.ffmpeg_path is not None:
882 | command += ['--ffmpeg-location', self.ffmpeg_path]
883 |
884 | download = Download(self.program_workdir, self.youtube_dl_path, command, parent=self)
885 |
886 | self.add_download_to_gui(download, url=url)
887 | self.downloader.queue_dl(download)
888 |
889 | def add_download_to_gui(self, download, url=None):
890 | scrollbar = self.tab1.process_list.verticalScrollBar()
891 | place = scrollbar.sliderPosition()
892 | go_bottom = (place == scrollbar.maximum())
893 |
894 | slot = QListWidgetItem(parent=self.tab1.process_list)
895 | gui_progress = ProcessListItem(download, slot, debug=self._debug, url=url)
896 |
897 | self.tab1.process_list.addItem(slot)
898 | self.tab1.process_list.setItemWidget(slot, gui_progress)
899 |
900 | gui_progress.adjust()
901 | self.tab1.process_list.resize(self.tab1.process_list.size())
902 |
903 | if go_bottom:
904 | self.tab1.process_list.scrollToBottom()
905 |
906 | gui_progress.stat_update()
907 |
908 | def stop_download(self):
909 | parallel = self.settings.user_options['parallel']
910 | if parallel and self.downloader.many_active():
911 | result = self.alert_message('Stop all?',
912 | 'Parallel mode stops all pending and active donwloads!',
913 | 'Do you want to continue?', True, True)
914 | if result == QMessageBox.Yes:
915 | self.downloader.stop_download(True)
916 | elif parallel:
917 | self.downloader.stop_download()
918 |
919 | elif self.downloader.has_pending():
920 | result = self.alert_message('Stop all?', 'Stop all pending downloads too?', '', True, True)
921 | if result == QMessageBox.Cancel:
922 | return
923 | else:
924 | self.downloader.stop_download(all_dls=(result == QMessageBox.Yes))
925 | else:
926 | self.downloader.stop_download()
927 |
928 | for item in self.tab1.process_list.iter_items():
929 | item.is_running()
930 |
931 | def allow_start(self):
932 | """ Adjusts buttons depending on users input and program state """
933 | self.tab1.stop_btn.setDisabled(not self.downloader.RUNNING)
934 | self.tab1.url_input.setDisabled(self.tab1.checkbox.isChecked())
935 | if not self.tab1.timer.isActive():
936 | self.tab1.start_btn.setDisabled(self.tab1.url_input.text() == '' and not self.tab1.checkbox.isChecked())
937 |
938 | # TODO: Move to tab 3?
939 | def load_text_from_file(self):
940 | if self.tab3.SAVED:
941 | content = self.file_handler.read_textfile(self.settings.user_options['multidl_txt'])
942 |
943 | if content is not None:
944 | self.tab3.textedit.clear()
945 | for line in content.split():
946 | self.tab3.textedit.append(line.strip())
947 |
948 | self.tab3.textedit.append('')
949 | self.tab3.textedit.setFocus()
950 | self.tab3.saveButton.setDisabled(True)
951 | self.tab3.SAVED = True
952 |
953 | else:
954 | if self.settings.user_options['multidl_txt']:
955 | warning = 'No textfile selected!\nBrowse for one in the About tab.'
956 | else:
957 | warning = 'Could not find/load file!'
958 | self.alert_message('Error!', warning, '')
959 | else:
960 | result = self.alert_message('Warning',
961 | 'Overwrite?',
962 | 'Do you want to load over the unsaved changes?',
963 | question=True)
964 | if result == QMessageBox.Yes:
965 | self.tab3.SAVED = True
966 | self.load_text_from_file()
967 |
968 | def save_text_to_file(self):
969 | # TODO: Implement Ctrl+L for loading of files.
970 | if self.settings.user_options['multidl_txt']:
971 | self.file_handler.write_textfile(self.settings.user_options['multidl_txt'],
972 | self.tab3.textedit.toPlainText())
973 |
974 | self.tab3.saveButton.setDisabled(True)
975 | self.tab3.SAVED = True
976 |
977 | else:
978 | result = self.alert_message('Warning!',
979 | 'No textfile selected!',
980 | 'Do you want to create one?',
981 | question=True)
982 |
983 | if result == QMessageBox.Yes:
984 | save_path = QFileDialog.getSaveFileName(parent=self.tab_widget, caption='Save as', filter='*.txt')
985 | if save_path[0]:
986 | self.file_handler.write_textfile(save_path[0],
987 | self.tab3.textedit.toPlainText())
988 | self.settings.user_options['multidl_txt'] = save_path[0]
989 | self.file_handler.save_settings(self.settings.settings_data)
990 |
991 | self.tab4.textfile_url.setText(self.settings.user_options['multidl_txt'])
992 | self.tab3.saveButton.setDisabled(True)
993 | self.tab3.SAVED = True
994 |
995 | def alert_message(self, title, text, info_text, question=False, allow_cancel=False):
996 | """ A quick dialog for providing warnings or asking for user questions."""
997 | warning_window = QMessageBox(parent=self)
998 | warning_window.setText(text)
999 | warning_window.setIcon(QMessageBox.Warning)
1000 | warning_window.setWindowTitle(title)
1001 | warning_window.setWindowIcon(self.alertIcon)
1002 |
1003 | if info_text:
1004 | warning_window.setInformativeText(info_text)
1005 | if question and allow_cancel:
1006 | warning_window.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
1007 | elif question:
1008 | warning_window.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
1009 | return warning_window.exec()
1010 |
1011 | def confirm(self):
1012 |
1013 | def do_proper_shutdown():
1014 | """Ensures that the settings are saved properly before exiting!"""
1015 |
1016 | nonlocal self
1017 | for item in self.tab1.process_list.iter_items():
1018 | if item._open_window is not None:
1019 | item._open_window.close()
1020 |
1021 | self.hide()
1022 | self.file_handler.force_save = True
1023 | self.file_handler.save_settings(self.settings.settings_data)
1024 | self.file_handler.save_profiles(self.settings.profiles_data)
1025 |
1026 | self.sendClose.emit()
1027 |
1028 | # If something is still running
1029 | if self.downloader.RUNNING:
1030 | result = self.alert_message('Want to quit?',
1031 | 'Still downloading!',
1032 | 'Do you want to close without letting youtube-dl finish? '
1033 | 'Will likely leave unwanted/incomplete files in the download folder.',
1034 | question=True)
1035 | if result != QMessageBox.Yes:
1036 | return None
1037 |
1038 | # Nothing is unsaved in textbox
1039 | if ((self.tab3.textedit.toPlainText() == '') or (not self.tab3.saveButton.isEnabled())) or self.tab3.SAVED:
1040 | do_proper_shutdown()
1041 |
1042 | else:
1043 | result = self.alert_message('Unsaved changes in list!',
1044 | 'The download list has unsaved changes!',
1045 | 'Do you want to save before exiting?',
1046 | question=True,
1047 | allow_cancel=True)
1048 | if result == QMessageBox.Yes:
1049 | self.save_text_to_file()
1050 |
1051 | elif result == QMessageBox.Cancel:
1052 | return
1053 |
1054 | do_proper_shutdown()
1055 |
1056 |
1057 | if __name__ == '__main__':
1058 | while True:
1059 | try:
1060 | app = QApplication(sys.argv)
1061 | program = GUI()
1062 |
1063 | EXIT_CODE = app.exec_()
1064 | app = None
1065 |
1066 | if EXIT_CODE == GUI.EXIT_CODE_REBOOT:
1067 | continue
1068 |
1069 | except (SettingsError, ProfileLoadError, json.decoder.JSONDecodeError) as e:
1070 | if isinstance(e, ProfileLoadError):
1071 | file = 'profiles file'
1072 | else:
1073 | file = 'settings file'
1074 |
1075 | warning = QMessageBox.warning(None,
1076 | f'Corruption of {file}!',
1077 | f'{e}\nRestore to defaults?',
1078 | buttons=QMessageBox.Yes | QMessageBox.No)
1079 |
1080 | if warning == QMessageBox.Yes:
1081 | filehandler = FileHandler()
1082 | if isinstance(e, ProfileLoadError):
1083 | filehandler.save_profiles({})
1084 | else:
1085 | setting = filehandler.load_settings(reset=True)
1086 | filehandler.save_settings(setting.settings_data)
1087 |
1088 | app = None # Ensures the app instance is properly removed!
1089 | continue
1090 |
1091 | break
1092 |
--------------------------------------------------------------------------------