├── __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 | ![Main](https://i.imgur.com/Tdd2oHt.png) 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 | ![param](https://i.imgur.com/4jFwhFe.png) ![About](https://i.imgur.com/52Fy75J.png) 61 | ![Option](https://i.imgur.com/ceYwgyS.png) ![List](https://i.imgur.com/L0PL5OH.png) 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 <br> at the same time. Use <br> 752 | debug_info = '<br>'.join([text.replace('\n', '<br>') 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 | --------------------------------------------------------------------------------