aboutsummaryrefslogtreecommitdiffstats
path: root/hypervideo_gui.py
diff options
context:
space:
mode:
authorJesús <heckyel@hyperbola.info>2019-12-09 15:01:12 -0500
committerJesús <heckyel@hyperbola.info>2019-12-09 15:01:12 -0500
commitfc8cb7ad9cbfd9fb5b9dd0d60eaeb9c1d4b4316b (patch)
treec566bfb4e7620b3f162b497fef646b3c43fa66a7 /hypervideo_gui.py
downloadhypervideo-gui-fc8cb7ad9cbfd9fb5b9dd0d60eaeb9c1d4b4316b.tar.lz
hypervideo-gui-fc8cb7ad9cbfd9fb5b9dd0d60eaeb9c1d4b4316b.tar.xz
hypervideo-gui-fc8cb7ad9cbfd9fb5b9dd0d60eaeb9c1d4b4316b.zip
first commit
Diffstat (limited to 'hypervideo_gui.py')
-rw-r--r--hypervideo_gui.py403
1 files changed, 403 insertions, 0 deletions
diff --git a/hypervideo_gui.py b/hypervideo_gui.py
new file mode 100644
index 0000000..3c9c009
--- /dev/null
+++ b/hypervideo_gui.py
@@ -0,0 +1,403 @@
+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&amp;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("<p align='center'>Written with Python3 and PyQt5<br>"
+ "Version: %s <br> License: %s </p>" % (__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!')
+
+ # 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': '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,
+ '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':
+ return os.path.join(os.path.expanduser('~'), 'Downloads')
+
+if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ app.setStyle('Fusion')
+ ex = App()
+ sys.exit(app.exec_())