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,
'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_())