import sys import os import re from PyQt5.QtWidgets import * from PyQt5.QtGui import QIcon from PyQt5.QtCore import pyqtSlot import threading import hypervideo import json __version__ = "0.1" __license__ = "GPL-3" __path__ = os.environ['HOME'] __pathBASE__ = r'%s/.config/hypervideo_gui/' % __path__ if not os.path.exists(__pathBASE__): os.makedirs(__pathBASE__) GUI_STATE_JSON_FILE = '%s/.config/hypervideo_gui/hypervideo_gui_state.json' % __path__ if not GUI_STATE_JSON_FILE: f = open(GUI_STATE_JSON_FILE, "a+") f.close() class App(QMainWindow): """ Simple applicaction for download using hypervideo """ _VALID_URL = r"""(?x)^ ( (?:https?://|//) # http(s):// or protocol-independent URL (?:(?:(?:(?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie|kids)?\.com/| (?:www\.)?deturl\.com/www\.youtube\.com/| (?:www\.)?pwnyoutube\.com/| (?:www\.)?hooktube\.com/| (?:www\.)?yourepeat\.com/| tube\.majestyc\.net/| # Invidious instances taken from https://github.com/omarroth/invidious/wiki/Invidious-Instances (?:(?:www|dev)\.)?invidio\.us/| (?:(?:www|no)\.)?invidiou\.sh/| (?:(?:www|fi|de)\.)?invidious\.snopyta\.org/| (?:www\.)?invidious\.kabi\.tk/| (?:www\.)?invidious\.13ad\.de/| (?:www\.)?invidious\.mastodon\.host/| (?:www\.)?invidious\.nixnet\.xyz/| (?:www\.)?invidious\.drycat\.fr/| (?:www\.)?tube\.poal\.co/| (?:www\.)?vid\.wxzm\.sx/| (?:www\.)?yt\.elukerio\.org/| (?:www\.)?yt\.lelux\.fi/| (?:www\.)?kgg2m7yk5aybusll\.onion/| (?:www\.)?qklhadlycap4cnod\.onion/| (?:www\.)?axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4bzzsg2ii4fv2iid\.onion/| (?:www\.)?c7hqkpkpemu6e7emz5b4vyz7idjgdvgaaa3dyimmeojqbgpea3xqjoid\.onion/| (?:www\.)?fz253lmuao3strwbfbmx46yu7acac2jz27iwtorgmbqlkurlclmancad\.onion/| (?:www\.)?invidious\.l4qlywnpwqsluw65ts7md3khrivpirse744un3x7mlskqauz5pyuzgqd\.onion/| (?:www\.)?owxfohz4kjyv25fvlqilyxast7inivgiktls3th44jhk3ej3i7ya\.b32\.i2p/| youtube\.googleapis\.com/) # the various hostnames, with wildcard subdomains (?:.*?\#/)? # handle anchor (#/) redirect urls (?: # the various things that can precede the ID: (?:(?:v|embed|e)/(?!videoseries)) # v/ or embed/ or e/ |(?: # or the v= param in all its forms (?:(?:watch|movie)(?:_popup)?(?:\.php)?/?)? # preceding watch(_popup|.php) or nothing (like /?v=xxxx) (?:\?|\#!?) # the params delimiter ? or # or #! (?:.*?[&;])?? # any other preceding param (like /?s=tuff&v=xxxx or ?s=tuff&v=V36LpHqtcDY) v= ) )) |(?: youtu\.be| # just youtu.be/xxxx vid\.plus| # or vid.plus/xxxx zwearz\.com/watch| # or zwearz.com/watch/xxxx )/ |(?:www\.)?cleanvideosearch\.com/media/action/yt/watch\?videoId= ) )? # all until now is optional -> you can pass the naked ID ([0-9A-Za-z_-]{11}) # here is it! the YouTube video ID (?(1).+)? # if we found the ID, everything can follow $""" __URL = r"""^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$""" def __init__(self): super().__init__() gui_state = self.loadGUIState() # load GUI state from file, load default state if save file not found self.download_folder_list = gui_state['DownloadFolderList'] self.default_video_formats_menu_items = ['Video/Audio - Best Quality', 'Audio Only - Best Quality', 'Detect All Available Formats'] # initialize window dimensions self.left = 100 self.top = 100 self.width = 640 self.height = 200 self.setFixedSize(self.width, self.height) self.initUI() def on_button_clicked(self): msg = QMessageBox() msg.setWindowTitle('About us') msg.setText("

Written with Python3 and PyQt5
" "Version: %s
License: %s

" % (__version__, __license__)) msg.setIcon(QMessageBox.Information) # msg.setInformativeText("Version: %s" % __version__) x = msg.exec_() def initUI(self): self.setWindowTitle('Simple Hypervideo Downloader GUI') self.setGeometry(self.left, self.top, self.width, self.height) # Initialize status bar self.statusBar().showMessage('Welcome to Simple Hypervideo Downloader GUI!') # Menu mainMenu = self.menuBar() fileMenu = mainMenu.addMenu('File') helpMenu = mainMenu.addMenu('Help') # Exit button exitButton = QAction('Exit', self) exitButton.setShortcut('Ctrl+Q') exitButton.setStatusTip('Exit application') exitButton.triggered.connect(self.close) # About button aboutButton = QAction('About', self) aboutButton.triggered.connect(self.on_button_clicked) # Adding buttons to Menu helpMenu.addAction(aboutButton) fileMenu.addAction(exitButton) # Create URL entry buttons and entry textbox urlEntryLabel = QLabel('Enter URL:') self.urlEntryText = QLineEdit() self.urlEntryText.setPlaceholderText('https://invidio.us/watch?v=8SdPLG-_wtA') # set default video to download # self.urlEntryText.setText('https://invidio.us/watch?v=8SdPLG-_wtA') # set up callback to update video formats when URL is changed self.urlEntryText.textChanged.connect(self.resetVideoFormats) # create output folder button and entry textbox outputFolderButton = QPushButton('Select Output Folder') outputFolderButton.setToolTip('Select output folder') outputFolderButton.clicked.connect(self.updateOutputFolder) self.outputEntryCombobox = QComboBox() self.outputEntryCombobox.setEditable(True) for item in self.download_folder_list: self.outputEntryCombobox.addItem(item) # set default output folder to be downloads folder # self.outputEntryCombobox.editTextChanged[str].connect(self.downloadTextChanged) # add combobox for video download format and detect formats button detectFormatsLabel = QLabel('Download Format:') self.videoFormatCombobox = QComboBox() self.populateVideoFormatCombobox(self.default_video_formats_menu_items) # set default values for format select combobox self.videoFormatCombobox.activated[str].connect(self.videoFormatChange) # add download button downloadButton = QPushButton('Download') downloadButton.clicked.connect(self.downloadVideo_callback) # create grid layout layout = QGridLayout() # add widgets to the layout layout.addWidget(urlEntryLabel, 1, 0) layout.addWidget(self.urlEntryText, 1, 1) layout.addWidget(outputFolderButton, 2, 0) layout.addWidget(self.outputEntryCombobox, 2, 1) layout.addWidget(detectFormatsLabel, 3, 0) layout.addWidget(self.videoFormatCombobox, 3, 1) layout.addWidget(downloadButton, 5, 0) # add grid layout as central widget for main window main_widget = QWidget() main_widget.setLayout(layout) self.setCentralWidget(main_widget) self.show() def url_entry(self): ''' Return URL EntryText''' url = self.urlEntryText.text() return url def downloadVideo_callback(self): ''' Callback for the "Download Video" button ''' def downloadVideo_thread_helper(self, ydl_opts): '''Download the video. Meant to be called in a background daemon thread ''' with hypervideo.YoutubeDL(ydl_opts) as ydl: ydl.download([self.urlEntryText.text()]) self.statusBar().showMessage('Downloading Video... Done!', msecs=0) # make sure a valid output directory was entered if not os.path.isdir(self.outputEntryCombobox.currentText()): self.statusBar().showMessage('Invalid download directory!') return # make sure the Download Folder List combobox is populated with the latest entry # this covers the case where the user uses the edittext portion of the combobox self.addItemToDownloadsCombobox(self.outputEntryCombobox.currentText()) # set output path/format outtmpl = os.path.join(self.outputEntryCombobox.currentText(), r'%(title)s.%(ext)s') # create the youtube downloader options based on video format combobox selection if self.videoFormatCombobox.currentText() == self.default_video_formats_menu_items[0]: # download best video quality ydl_opts = { 'format': 'bestvideo+bestaudio/best', 'outtmpl': outtmpl, } elif self.videoFormatCombobox.currentText() == self.default_video_formats_menu_items[1]: # for downloading best audio and converting to mp3 ydl_opts = { 'format': 'bestaudio/best', 'outtmpl': outtmpl, 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '192', # not actually best quality... }], } else: # grab video format from the dropdown string: ie. "135 - some video metadata here" -> "135" video_format = self.videoFormatCombobox.currentText()[0:self.videoFormatCombobox.currentText().find('-')-1] # set output path/format outformat = os.path.join(self.outputEntryCombobox.currentText(), r'%(title)s.f%(format_id)s.%(ext)s') ydl_opts = { 'format': video_format, 'outtmpl': outformat, } # download the video in daemon thread if re.match(self.__URL, str(self.url_entry())) is None: self.statusBar().showMessage('Please add a URL...') # update status bar else: self.statusBar().showMessage('Downloading Video...') # update status bar thread = threading.Thread(target=downloadVideo_thread_helper, args=(self, ydl_opts, )) thread.daemon = True thread.start() def updateVideoFormats(self): '''Grabs the list of available video formats in background thread and populates video format combobox with results when complete. ''' def getVideoFormats_thread_helper(self, url): ''' Grabs the available video formats. Intended to be run as background thread. ''' self.options = { 'verbose': True, 'format': 'best', 'noplaylist': True, # only download single song, not playlist } with hypervideo.YoutubeDL(self.options) as ydl: meta = ydl.extract_info(url, download=False) formats = meta.get('formats', [meta]) # add formats to combobox item_list = self.default_video_formats_menu_items[0:2] item_list.extend([f['format_id'] + ' - ' + f['ext'] for f in formats]) self.populateVideoFormatCombobox(item_list) self.statusBar().showMessage('Finished Downloading Video Formats') self.videoFormatCombobox.setCurrentIndex(0) # check if is valid url # should probably be reworked to be compatible with non-YouTube websites if re.match(self._VALID_URL, str(self.url_entry())) is None: self.populateVideoFormatCombobox(self.default_video_formats_menu_items) return else: # valid url - fetch the video formats in background daemon thread self.statusBar().showMessage('Downloading Video Formats') thread = threading.Thread(target=getVideoFormats_thread_helper, args=(self, str(self.url_entry()), )) thread.daemon = True thread.start() def videoFormatChange(self, text): if text == self.default_video_formats_menu_items[2]: # detect video formats was selected # update statusbar to let user know something is happening self.statusBar().showMessage('Loading available formats...') # update video formats self.updateVideoFormats() def populateVideoFormatCombobox(self, labels): '''Populate the video format combobox with video formats. Clear the previous labels. labels {list} -- list of strings representing the video format combobox options ''' self.videoFormatCombobox.clear() for label in labels: self.videoFormatCombobox.addItem(label) def resetVideoFormats(self): idx = self.videoFormatCombobox.currentIndex() self.populateVideoFormatCombobox(self.default_video_formats_menu_items) # preserve combobox index if possible if idx > 1: self.videoFormatCombobox.setCurrentIndex(0) else: self.videoFormatCombobox.setCurrentIndex(idx) @pyqtSlot() def updateOutputFolder(self): ''' Callback for "Update Output Folder" button. Allows user to select output directory via standard UI. ''' file = str(QFileDialog.getExistingDirectory(self, "Select Directory")) if len(file) > 0: self.addItemToDownloadsCombobox(file) else: self.statusBar().showMessage('Select a folder!') def downloadTextChanged(self, text): # function not used right now if text not in self.download_folder_list and os.path.isdir(text): self.addItemToDownloadsCombobox(text) def addItemToDownloadsCombobox(self, text): if text not in self.download_folder_list: # if it's not in the list, add it to the list self.download_folder_list = [text]+self.download_folder_list else: # if it's in the list already, move it to the top of the list self.download_folder_list = [text]+[folder for folder in self.download_folder_list if not folder == text] # maximum download folder history of 5 if len(self.download_folder_list) > 5: self.download_folder_list = self.download_folder_list[0:6] # update the combobox self.outputEntryCombobox.clear() for item in self.download_folder_list: self.outputEntryCombobox.addItem(item) self.outputEntryCombobox.setCurrentIndex(0) # reset index - just in case def saveGUIState(self): save_dict = {'DownloadFolderList': self.download_folder_list, } with open(GUI_STATE_JSON_FILE, 'w') as file: json.dump(save_dict, file) def loadGUIState(self): if os.path.isfile(GUI_STATE_JSON_FILE): with open(GUI_STATE_JSON_FILE, 'r') as file: save_data = json.load(file) if isinstance(save_data['DownloadFolderList'], str): # convert to list save_data['DownloadFolderList'] = [save_data['DownloadFolderList']] else: save_data = {'DownloadFolderList': [get_default_download_path()], } return save_data def closeEvent(self, event): '''This function gets called when the user closes the GUI. It saves the GUI state to the json file GUI_STATE_JSON_FILE. ''' self.saveGUIState() close = QMessageBox() close.setIcon(QMessageBox.Question) close.setText('You sure?') close.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) close = close.exec() if close == QMessageBox.Yes: event.accept() else: event.ignore() def get_default_download_path(): """Returns the path for the "Downloads" folder in GNU Used as default directory for videos to be saved to. #From: https://stackoverflow.com/questions/35851281/python-finding-the-users-downloads-folder """ if os.name == 'posix': dpath = '/tmp/hypervideo-gui/' if not os.path.exists(dpath): os.makedirs(dpath) return os.path.join(dpath) if __name__ == '__main__': app = QApplication(sys.argv) app.setStyle('Fusion') ex = App() sys.exit(app.exec_())