Sign up for updates!

Hi! 👋 Please sign up to get notified when I release the source code for this book on GitHub. Till then, I would greatly appreciate if you can submit all the grammatical, prose and code related issues here.

I post about once every couple of months. No spam, I promise

12 A Music/Video GUI Downloader

So far we have made a web API for music/video downloading and implemented a TUI for email checking. However, some people like the simplicity of a usable native GUI. I don’t blame them. I am a big sucker for beautiful and usable GUIs as well. They make a tedious task so much easier. Imagine if you had to do everything Microsoft Word allows you to do in a terminal. Most people would pull their hair out (and the other half would whip out Emacs and claim that Emacs are better than Vim. Pardon me, I just like throwing fuel on this useless flame-war :p).

Enough with the rant. In this chapter, we will try to satisfy this GUI loving class of people. We will be making a beautiful front-end for the music downloader we worked on in the previous chapters.

After going through this chapter you will have better knowledge of how to work with the Qt framework and make GUIs using Python. More specifically, you will learn about:

  • Making a Mockup

  • Requirements of a basic QT GUI

  • Layout Management

  • QThread usage

The final GUI will look something like Fig. 12.1.

Final video downloader GUI

Fig. 12.1 Final video downloader GUI

12.1 Prerequisites

Before you begin reading this chapter, I expect you to know the basics of Qt and how it works in Python. This is because I can write a complete book about Qt using this single downloader project. I will be guiding you through the development of this GUI and will be explaining things as they come along but will not spend a lot of time on every single concept.

Note

For those not familiar with Qt (pronounced “cute”), it is a free and open-source widget library for creating GUIs on Linux, Windows, MacOS, Android, and embedded systems with little-to-no change in the underlying codebase.

I will give you enough details to make sure you know what is going on but I expect you to do some exploration and research of your own if something doesn’t make a lot of sense. If you feel like I have omitted something obvious from my explanation please let me know and I would be more than happy to take a look at it and add it in.

With the prereqs disclaimer out of the way, I am pumped! Let’s get on with this chapter already!

12.2 GUI Mockup

The very first and most important step in any GUI based project is to come up with a mockup. This will guide the creation of our GUI through code and leaves all the guesswork out. If you directly try to code a complex GUI without making a mockup, you will potentially bang your head for countless hours before completing the project. So to make sure you survive this chapter, we will start with the creation of a mockup. You can make this mockup using the traditional pen and paper or you can get fancy and use a vector program (Inkscape) to create this.

In order to make a useful mockup we need to define the requirements for our GUI. In our case we want to give the user the ability to easily:

  • input the url of the music/video they want to download

  • specify the folder where the file will be downloaded

  • figure out which downloads are currently in progress and their progress percentage

You can see the mockup that I came up with in Fig. 12.2.

GUI mockup

Fig. 12.2 GUI mockup

There is nothing fancy in there. We have 8 different components in the GUI.

  • One image (logo)

  • Two labels (url, location)

  • Two text fields (url, location)

  • Two buttons (browse, download)

  • One table

You should try to keep your GUI as clean and simple as possible. This helps with the usability of the GUI. There is a specific thought process behind how I laid out different items in the mockup. There is an order and an alignment between different items. For example, you can clearly see that both labels are taking almost equal space. You can go super crazy and put items without any order but once I explain how the layout in Qt works, you will want to redo the mockup. I will explain how the layout in Qt works in just a bit. But before I do that, let’s see how a basic Qt app is made.

12.3 Basic Qt app

Let’s create a basic app that has a text field and a button. We will start by importing the required libraries and modules.

import sys
from PySide2.QtWidgets import (QWidget, QPushButton,
    QLabel, QLineEdit, QMainWindow,
    QHBoxLayout, QVBoxLayout, QApplication)

We will create the main widget which will be displayed. Widgets are the basic building blocks which make up a graphical user interface. Labels, Buttons, and Images are all widgets and there is a widget for almost everything in Qt. You can place these widgets in a window and display the window or you can display a widget independently.

You can create a widget object directly without subclassing it. This is quick but you lose a lot of control. Most of the time you will want to modify and customize the default widgets and that is when subclassing is required. Ok, that is too much information. Let’s see how to create a basic app by subclassing a widget. First I will show you all the code and then we will try to make sense of it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MainWidget(QWidget):

    def __init__(self, parent):
        super(MainWidget, self).__init__(parent)
        self.initUI()


    def initUI(self):
        self.name_label = QLabel(self)
        self.name_label.setText('Name:')
        self.line_input = QLineEdit(self)
        self.ok_button = QPushButton("OK")

        hbox = QHBoxLayout()
        hbox.addWidget(self.name_label)
        hbox.addWidget(self.line_input)

        vbox = QVBoxLayout()
        vbox.addLayout(hbox)
        vbox.addWidget(self.ok_button)
        vbox.addStretch(1)

        self.setLayout(vbox)

Now we will create the main window which will encapsulate our widget.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        m_widget = MainWidget(self)
        self.setCentralWidget(m_widget)
        self.setGeometry(300, 300, 400, 150)
        self.setWindowTitle('Buttons')


if __name__ == '__main__':

    app = QApplication(sys.argv)
    m_window = MainWindow()
    m_window.show()
    sys.exit(app.exec_())

Save this in an app_gui.py file and run it:

$ python3 app_gui.py

The output should be similar to Fig. 12.3.

Simple input widget

Fig. 12.3 Simple input widget

Let’s understand what is happening in the code. There are a lot of different widgets in Qt. These widgets form our GUI. The normal workflow involves subclassing the QWidget class and adding different widgets within that class.

We can display our subclassed QWidget without any QMainWindow but if we do that we would be missing out on a lot of things which the QMainWindow provides. Namely: status bar, title bar, menu bar, etc. Due to this we will be subclassing QMainWindow and displaying our widget within that.

After subclassing the QMainWindow and QWidget, we need to create an instance of the QApplication which is going to run our app. After creating an instance of that we need to create an instance of the main window which will be shown when the app is run and call its show method. The show method call won’t display the window until we call the exec_ method of the QApplication. That is because exec_ starts the main application loop and without that loop, most of the widgets can’t be shown.

There are some modal widgets like QMessageBox which can be shown without calling exec_ but it is good to conform to the standard practices and call exec_. You don’t want to be the person who is hated for writing non-maintainable code.

12.4 Layout Management

When we subclass a QWidget and add multiple different widgets in it, we need some way to inform Qt where to place the widgets. The naive way to do that is to tell Qt the absolute positioning of the widgets. For example, we can tell Qt to place the QLabel at coordinates (20, 40). These are x,y coordinates. But when the window is resized, these absolute positions will get screwed and the UI will not scale properly.

Luckily, Qt provides us with another way to handle the placement of widgets. It provides us with a QVBoxLayout, QHBoxLayout, and QGridLayout. Let’s understand how these layout classes work.

  • QVBoxLayout allows us to specify the placement of widgets vertically. Think of it like this:

1
2
3
4
5
6
7
 _____________________________
|           widget 1          |
|_____________________________|
|           widget 2          |
|_____________________________|
|           widget 3          |
|_____________________________|
  • QHBoxLayout allows us to specify the placement of widgets horizontally:

 _____________________ _____________________ _____________________
|       widget 1      |       widget 2      |       widget 3      |
|_____________________|_____________________|_____________________|
  • QGridLayout allows us to specify the placement of widgets in a grid-based layout.

1
2
3
4
5
 _____________________ _____________________ _____________________
|       widget 1      |       widget 2      |       widget 3      |
|_____________________|_____________________|_____________________|
|       widget 1      |                  widget 3                 |
|_____________________|___________________________________________|

You have already seen how to create an instance of QHBoxLayout and QVBoxLayout but for the sake of completeness here is how we used a QVBoxLayout in the code sample above:

vbox = QVBoxLayout()
vbox.addLayout(hbox)
vbox.addWidget(self.ok_button)

These layout classes have multiple methods which we can use. In the above code, we are making use of addLayout and addWidget. addLayout allows us to nest multiple layouts. In this case, I am nesting a QHBoxLayout within a QVBoxLayout. Similarly, addWidget allows you to add a widget in your layout. In this case, I am adding a QPushButton in our VBoxLayout.

One last thing to note for now is that these layouts provide us with a addStretch method. This adds a QSpacerItem between our widgets which expands automatically depending on how big the widget size is. This is helpful if we want our buttons to stay near the top of our main widget. Here is how to add it:

vbox.addStretch(1)

This results in a GUI similar to Fig. 12.4.

Input widget with stretch

Fig. 12.4 Input widget with stretch

Without addStretch Qt tries to cover all available space with our child widgets in our main widget which results in a GUI similar to Fig. 12.5.

Input widget without stretch

Fig. 12.5 Input widget without stretch

If this doesn’t make any sense right now, don’t worry. We will use it in our main app later on and it will hopefully make more sense then.

We can also add a horizontal stretch in our QHBoxLayout which will push our widgets to whichever side we want.

We can add multiple stretches in our layout. The argument to addStretch specifies the factor with which the size of the spacer increases. If we have two stretches, first with an argument of 1 and the second with an argument of 2, the latter will increase in size with a factor of two as compared to the first one.

You have already seen that we can nest multiple layouts. This gives us the freedom to create almost any kind of layout. In the code example above I am making a layout like Fig. 12.6.

Simple input layout diagram

Fig. 12.6 Simple input layout diagram

12.5 Coding the layout of Downloader

Now that you understand the basics of how the layout system works in Qt, it’s time to code up the layout of the downloader based on the mockup we have designed.

Before we begin coding the layout, let’s decouple the layout and figure out which widgets we need (Fig. 12.7).

Breaking down the layout

Fig. 12.7 Breaking down the layout

We will code up different parts of the GUI in steps. First, let’s import all of the widgets we will be using in our GUI:

from PySide2.QtWidgets import (QWidget, QPushButton, QFileDialog,
    QLabel, QLineEdit, QMainWindow, QGridLayout, QTableWidget,
    QTableWidgetItem, QHeaderView, QTableView, QHBoxLayout,
    QVBoxLayout, QApplication)

Now, let’s code the logo part:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class MainWidget(QWidget):

    # ...

    def initUI(self):
        self.logo_label = QLabel(self)
        logo = QtGui.QPixmap("logo.png")
        self.logo_label.setPixmap(logo)

        logoBox = QHBoxLayout()
        logoBox.addStretch(1)
        logoBox.addWidget(self.logo_label)
        logoBox.addStretch(1)

We can not display an image directly in Qt. There is no simple to use image display widget. We need to use a label and set a QPixmap. I have added a stretch on both sides in the QHBoxLayout so that the logo stays in the center of the window.

Now we can go ahead and code up the QGridLayout and the respective widgets which it will encapsulate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MainWidget(QWidget):

    # ...

    def initUI(self):
        # ...
        self.url_label = QLabel(self)
        self.url_label.setText('Url:')
        self.url_input = QLineEdit(self)

        self.location_label = QLabel(self)
        self.location_label.setText('Location:')
        self.location_input = QLineEdit(self)

        self.browse_btn = QPushButton("Browse")
        self.download_btn = QPushButton("Download")

        grid = QGridLayout()
        grid.setSpacing(10)
        grid.addWidget(self.url_label, 0, 0)
        grid.addWidget(self.url_input, 0, 1, 1, 2)

        grid.addWidget(self.location_label, 1, 0)
        grid.addWidget(self.location_input, 1, 1)
        grid.addWidget(self.browse_btn, 1, 2)
        grid.addWidget(self.download_btn, 2, 0, 1, 3)

The setSpacing method adds margin between widgets in the layout. The addWidget takes three required arguments:

  1. Widget

  2. row

  3. column

There are 3 optional arguments as well:

  1. row span

  2. column span

  3. alignment

Note

The index of the grid starts from 0.

We need row and column span only when we want a widget to take more than one cell. I have used that for url_input (because it needs to span 2 columns) and download_btn (because it needs to span 3 columns).

Now we need to create the table widget:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class MainWidget(QWidget):
    # ...

    def initUI(self):
        # ...
        self.tableWidget = QTableWidget()
        self.tableWidget.setColumnCount(2)
        self.tableWidget.verticalHeader().setVisible(False)
        self.tableWidget.horizontalHeader().setSectionResizeMode(0, \
            QHeaderView.Stretch)
        self.tableWidget.setColumnWidth(1, 140)
        self.tableWidget.setShowGrid(False)
        self.tableWidget.setSelectionBehavior(QTableView.SelectRows)
        self.tableWidget.setHorizontalHeaderLabels(["Name", "Downloaded"])

I set the column count to 2 because we are only going to show the file name and the download percentage. I am hiding the vertical header because I don’t want to show row index. I am making sure that the second column has a fixed width and the first column takes rest of the available space. I am hiding the grid of the table because this way it looks prettier. I am also making sure that selecting an individual cell selects an entire column because selecting an individual cell is useless in our app. Lastly, I am setting the horizontal header labels to “Name” and “Downloaded”.

The last required step is to put all of this in a vertical layout and set that layout as the default layout of our widget:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MainWidget(QWidget):
    # ...

    def initUI(self):
        # ...
        vbox = QVBoxLayout()
        vbox.addLayout(logoBox)
        vbox.addLayout(grid)
        vbox.addWidget(self.tableWidget)
        self.setLayout(vbox)

We have all of the GUI code now. You can see the complete code below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import sys
from PySide2.QtWidgets import (QWidget, QPushButton,
    QLabel, QLineEdit, QMainWindow, QGridLayout, QTableWidget, QTableWidgetItem,
    QHeaderView, QTableView, QHBoxLayout, QVBoxLayout, QApplication)
from PySide2 import QtGui, QtCore

class MainWidget(QWidget):

    def __init__(self, parent):
        super(MainWidget, self).__init__(parent)
        self.initUI()


    def initUI(self):

        self.logo_label = QLabel(self)

        self.url_label = QLabel(self)
        self.url_label.setText('Url:')
        self.url_input = QLineEdit(self)

        self.location_label = QLabel(self)
        self.location_label.setText('Location:')
        self.location_input = QLineEdit(self)

        self.browse_btn = QPushButton("Browse")
        self.download_btn = QPushButton("Download")

        logo = QtGui.QPixmap("logo.png")
        self.logo_label.setPixmap(logo)

        logoBox = QHBoxLayout()
        logoBox.addStretch(1)
        logoBox.addWidget(self.logo_label)
        logoBox.addStretch(1)

        self.tableWidget = QTableWidget()
        #self.tableWidget.setRowCount(4)
        self.tableWidget.setColumnCount(2)
        self.tableWidget.verticalHeader().setVisible(False)
        self.tableWidget.horizontalHeader().setSectionResizeMode(
            0, QHeaderView.Stretch
        )
        self.tableWidget.setColumnWidth(1, 140)
        self.tableWidget.setShowGrid(False)
        self.tableWidget.setSelectionBehavior(QTableView.SelectRows)
        self.tableWidget.setHorizontalHeaderLabels(["Name", "Downloaded"])

        rowPosition = self.tableWidget.rowCount()
        self.tableWidget.insertRow(rowPosition)
        self.tableWidget.setItem(rowPosition,0, QTableWidgetItem("Cell (1,1)"))
        self.tableWidget.setItem(rowPosition,1, QTableWidgetItem("Cell (1,2)"))

        grid = QGridLayout()
        grid.setSpacing(10)
        grid.addWidget(self.url_label, 0, 0)
        grid.addWidget(self.url_input, 0, 1, 1, 2)

        grid.addWidget(self.location_label, 1, 0)
        grid.addWidget(self.location_input, 1, 1)
        grid.addWidget(self.browse_btn, 1, 2)
        grid.addWidget(self.download_btn, 2, 0, 1, 3)

        vbox = QVBoxLayout()
        vbox.addLayout(logoBox)
        vbox.addLayout(grid)
        vbox.addWidget(self.tableWidget)
        self.setLayout(vbox)

class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        m_widget = MainWidget(self)
        self.setCentralWidget(m_widget)
        self.setGeometry(300, 300, 400, 150)
        self.setWindowTitle('Buttons')


if __name__ == '__main__':

    app = QApplication(sys.argv)
    m_window = MainWindow()
    m_window.show()
    sys.exit(app.exec_())

Save this code in the app_gui.py file and run it. You should see something similar to Fig. 12.8.

YouTube-dl final GUI

Fig. 12.8 YouTube-dl final GUI

12.6 Adding Business Logic

Our GUI is ready but the GUI is incomplete without business logic. Currently, pressing any of the buttons yields nothing. That is because we have not hooked up the buttons to any logic. In this section, we will make sure our useless buttons become relatively useful.

12.6.1 Browse Button

The first thing we will add is the ability to select a folder using the browse button. In Qt, there is a terminology of signals and slots. When you interact with a widget, the widget sends out a signal. It is the programmer’s duty to connect that signal to a function (known as a slot). The function is fired off when the signal is emitted by the widget.

In our case, we need to connect the clicked signal of the browse button to a function which will allow the user to select the destination folder. Here is the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class MainWidget(QWidget):

    def __init__(self, parent):
        # -- truncated --
        self.setup_connections()

    def setup_connections(self):
        self.browse_btn.clicked.connect(self.pick_location)

    def pick_location(self):
        dialog = QFileDialog()
        folder_path = dialog.getExistingDirectory(self, "Select Folder")
        self.location_input.setText(folder_path)

The logic of the code is pretty straight forward. We are handling the clicked signal using the pick_location method. In that method, we are creating a QFileDialog and calling its getExistingDirectory method. We pass self as the parent and “Select Folder” as the caption of the dialog.

12.6.2 Downloading the file

Even though we have created a media downloader in a previous chapter, we will be using youtube-dl in this project. I know, I know. I mentioned at the start of this chapter that we will be using our own downloader which we previously created. Let me try and convince you why youtube-dl is a good option. It supports downloading audio/videos from around 200+ websites! It is a beautiful Python script which expects a media URL and a bunch of optional arguments and downloads the media for us. Installing it is also super easy. We can just use pip to install it:

$ pip install youtube-dl

youtube-dl is a standalone script so you can use it independently after installing as well:

$ youtube-dl https://www.youtube.com/watch?v=BaW_jenozKc

The best part is that apart from being a stand-alone script, youtube-dl provides us an easy way to embed it in our Python program. According to the docs, we just need to

  1. Create a logger class

1
2
3
4
5
6
7
8
9
class MyLogger(object):
    def debug(self, msg):
        pass

    def warning(self, msg):
        pass

    def error(self, msg):
        print(msg)
  1. Create an options dictionary and an update hook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import os
directory = os.getcwd()

def my_hook(data):
    print(data)

ydl_opts = {
    'logger': MyLogger(),
    'outtmpl': os.path.join(directory,'%(title)s.%(ext)s'),
    'progress_hooks': [my_hook],
}
  1. Call youtube_dl

import youtube_dl

with youtube_dl.YoutubeDL(ydl_opts) as ydl:
    ydl.download([url])

This is all we need to download the file but we can not run this code as-is. That’s because we would be running our code on just one thread. If we try downloading the file using the same thread which is running our GUI, we will freeze the GUI and in most cases crash the application.

The solution is to create a second thread for doing the downloading. Qt provides us with a QThread class. We can create an object of that class and use that as our second thread.

Note

If you have been using Python for a while, you might wonder why do we need to use QThread instead of the threading library which comes by default with Python. The reason is that QThread is better integrated with the Qt framework and makes it super easy for us to pass messages between multiple threads. Python’s built-in threading library does not have the message passing feature out of the box, implementing it would require duplicating what QThread already provides.

12.6.3 QThread

Let’s begin by importing QThread and youtube_dl in our app_gui.py file:

from PySide2.QtCore import QThread
import youtube_dl

Now we need to subclass QThread and implement our logic in the __init__ and run methods.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class DownloadThread(QThread):

    def __init__(self, directory, url, row_position):
        super(DownloadThread, self).__init__()
        self.ydl_opts = {
            'logger': MyLogger(),
            'outtmpl': os.path.join(directory,'%(title)s.%(ext)s'),
            'progress_hooks': [self.my_hook],
        }
        self.url = url
        self.row_position = row_position

    def my_hook(self, data):
        filename = data.get('filename').split('/')[-1].split('.')[0]
        print(filename, data.get('_percent_str', '100%'), \
        self.row_position)

    def run(self):
        with youtube_dl.YoutubeDL(self.ydl_opts) as ydl:
            ydl.download([self.url])

Let me explain what is going on here. In the __init__ method, we create the yts_opts instance variable just like how youtube_dl tells us to do. You might be wondering what row_position is. row_position is the index of the next unused row in the QTableWidget. This is the row which will contain information about this new download which we are just starting. It will make more sense when we will make use of it later.

The my_hook method is also similar to the hook which youtube_dl told us to make. It will be called regularly by youtube_dl during downloading and post-processing of a file. We will use this hook to update the information in the QTableWidget.

The run method simply calls youtube_dl.YoutubeDL the way youtube_dl instructs us to do.

In this section, we essentially just took everything which youtube_dl told us to do and dumped that code into the QThread subclass.

12.6.4 Signals

We have been talking about signals for a while now. We have also interfaced with a few signals (clicked signal for a QPushButton). But how do we make our own signal? We need to pass information from the QThread to the parent widget so that the parent widget can update download information in our QTableWidget.

As it turns out, it is fairly simple. We just need to:

  1. import Signal class from PySide2.QtCore

from PySide2.QtCore import Signal
  1. create an object of that class

data_downloaded = Signal(object)
  1. and call it’s connect and emit methods

data_downloaded.connect(some_function)

data = ('this', 'is', 'info', 'tuple', '<3')
data_downloaded.emit(data)

some_function will be called with the data emitted by data_downloaded signal as an argument. We can implement this in our QThread like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class DownloadThread(QThread):

    data_downloaded = Signal(object)

    def __init__(self, directory, url, row_position):
        # ...

    def my_hook(self, d):
        filename = d.get('filename').split('/')[-1].split('.')[0]
        data = (filename, d.get('_percent_str', '100%'), self.row_position)
        self.data_downloaded.emit(data)

Our signal expects an object to be passed through it. In Python everything is an object so when we try passing a tuple it just works!

12.6.5 Download Button

We have implemented the main logic for downloading the file. The next step is to connect that logic with some button so that we can execute that logic.

In our MainWidget, we need to modify one old method and add two new methods. We need to modify the setup_connections method and connect the clicked signal of download_btn with a method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MainWidget(QWidget):

    def __init__(self, parent):
        super(MainWidget, self).__init__(parent)
        self.threads = []
        self.initUI()

    # ...

    def setup_connections(self):
        self.browse_btn.clicked.connect(self.pick_location)
        self.download_btn.clicked.connect(self.start_download)

    def start_download(self):
        row_position = self.tableWidget.rowCount()
        self.tableWidget.insertRow(row_position)
        self.tableWidget.setItem(row_position,0, QTableWidgetItem(self.url_input.text()))
        self.tableWidget.setItem(row_position,1, QTableWidgetItem("0%"))

        downloader = DownloadThread(self.location_input.text(), self.url_input.text(),\
        row_position)
        downloader.data_downloaded.connect(self.on_data_ready)
        self.threads.append(downloader)
        downloader.start()

    def on_data_ready(self, data):
        self.tableWidget.setItem(data[2],0, QTableWidgetItem(data[0]))
        self.tableWidget.setItem(data[2],1, QTableWidgetItem(data[1]))

Let me explain what is happening here. We start off by connecting the clicked signal of download_btn to the start_method. The start_method retrieves the total number of rows in the QTableWidget. Initially, it will be 0 because we have not added any rows in the QTableWidget. We insert a row using the total number of rows as an index. The reason this works is that the index of rows is 0 based so adding a row at index 0 is a valid operation.

Remember: We programmers love to start indexing everything from 0 <3

Then we set the value of the first column item to the URL passed by the user and the second column item to 0% (it means that the download progress is 0% so far).

After that, we create a DownloadThread using the download location, URL, and the row position. We connect the data_donloaded signal to the on_data_ready method of the MainWidget. And finally, we add the thread to the threads list and start it. We add it to the threads list so that when we implement the thread termination strategy in the future we have a reference to all the threads currently running.

Disclaimer: I will just give you pointers on how to implement the thread termination. You will have to implement it yourself. Sort of like a homework assignment. More on that later.

In the on_data_ready method, we update the items in the QTableWidget by using the row information and the data sent by the data_downloaded signal. Just to remind you, data_downloaded signal sends the following information in a tuple:

  1. Name of the file being downloaded

  2. The percentage of the file which has been downloaded

  3. The row_position of this file in the QTableWidget

12.7 Testing

At this point, most of the basic code which is required for our app to run has been implemented. The full source code of the application is listed below:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import sys
from PySide2.QtWidgets import (QWidget, QPushButton, QFileDialog,
    QLabel, QLineEdit, QMainWindow, QGridLayout, QTableWidget,
    QTableWidgetItem, QHeaderView, QTableView, QHBoxLayout,
    QVBoxLayout, QApplication)
from PySide2 import QtGui, QtCore
from PySide2.QtCore import QThread, Signal, Slot
import requests
import youtube_dl
import os

class MyLogger(object):
    def debug(self, msg):
        print(msg)

    def warning(self, msg):
        pass

    def error(self, msg):
        print(msg)


class DownloadThread(QThread):

    data_downloaded = Signal(object)


    def __init__(self, directory, url, row_position):
        super(DownloadThread, self).__init__()
        self.ydl_opts = {
            'logger': MyLogger(),
            'outtmpl': os.path.join(directory,'%(title)s.%(ext)s'),
            'progress_hooks': [self.my_hook],
        }
        self.url = url
        self.row_position = row_position

    def my_hook(self, d):
        filename = d.get('filename').split('/')[-1].split('.')[0]
        self.data_downloaded.emit((filename, d.get('_percent_str', '100%'),
            self.row_position))

    def run(self):
        with youtube_dl.YoutubeDL(self.ydl_opts) as ydl:
            ydl.download([self.url])


class MainWidget(QWidget):

    def __init__(self, parent):
        super(MainWidget, self).__init__(parent)
        self.threads = []
        self.initUI()


    def initUI(self):

        self.logo_label = QLabel(self)

        self.url_label = QLabel(self)
        self.url_label.setText('Url:')
        self.url_input = QLineEdit(self)

        self.location_label = QLabel(self)
        self.location_label.setText('Location:')
        self.location_input = QLineEdit(self)

        self.browse_btn = QPushButton("Browse")
        self.download_btn = QPushButton("Download")

        logo = QtGui.QPixmap("logo.png")
        self.logo_label.setPixmap(logo)

        logoBox = QHBoxLayout()
        logoBox.addStretch(1)
        logoBox.addWidget(self.logo_label)
        logoBox.addStretch(1)

        self.tableWidget = QTableWidget()
        self.tableWidget.setColumnCount(2)
        self.tableWidget.verticalHeader().setVisible(False)
        self.tableWidget.horizontalHeader().setSectionResizeMode(0, \
            QHeaderView.Stretch)
        self.tableWidget.setColumnWidth(1, 140)
        self.tableWidget.setShowGrid(False)
        self.tableWidget.setSelectionBehavior(QTableView.SelectRows)
        self.tableWidget.setHorizontalHeaderLabels(["Name", "Downloaded"])


        grid = QGridLayout()
        grid.setSpacing(10)
        grid.addWidget(self.url_label, 0, 0)
        grid.addWidget(self.url_input, 0, 1, 1, 2)

        grid.addWidget(self.location_label, 1, 0)
        grid.addWidget(self.location_input, 1, 1)
        grid.addWidget(self.browse_btn, 1, 2)
        grid.addWidget(self.download_btn, 2, 0, 1, 3)

        vbox = QVBoxLayout()
        vbox.addLayout(logoBox)
        vbox.addLayout(grid)
        vbox.addWidget(self.tableWidget)

        self.setup_connections()
        self.setLayout(vbox)

    def setup_connections(self):
        self.browse_btn.clicked.connect(self.pick_location)
        self.download_btn.clicked.connect(self.start_download)

    def pick_location(self):
        dialog = QFileDialog()
        folder_path = dialog.getExistingDirectory(self, "Select Folder")
        self.location_input.setText(folder_path)
        return folder_path

    def start_download(self):
        row_position = self.tableWidget.rowCount()
        self.tableWidget.insertRow(row_position)
        self.tableWidget.setItem(row_position,0,
            QTableWidgetItem(self.url_input.text()))
        self.tableWidget.setItem(row_position,1,
            QTableWidgetItem("0%"))

        downloader = DownloadThread(self.location_input.text() or os.getcwd(),
            self.url_input.text(), row_position)
        downloader.data_downloaded.connect(self.on_data_ready)
        self.threads.append(downloader)
        downloader.start()

    def on_data_ready(self, data):
        self.tableWidget.setItem(data[2],0, QTableWidgetItem(data[0]))
        self.tableWidget.setItem(data[2],1, QTableWidgetItem(data[1]))



class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        m_widget = MainWidget(self)
        self.setCentralWidget(m_widget)
        self.setGeometry(300, 300, 700, 350)
        self.setWindowTitle('Buttons')


if __name__ == '__main__':

    app = QApplication(sys.argv)
    m_window = MainWindow()
    m_window.show()
    sys.exit(app.exec_())

Now save this code in your app_gui.py file and run it. At this point, you should see a beautiful GUI come to life in front of your eyes.

Wait! Before you go ahead and start jumping in sheer joy, try downloading a file and closing the main window before the file has been completely downloaded.

Done? Are you pulling your hair out? The program crashes and doesn’t close gracefully. The reason is that the main thread terminates but child threads do not. We need to figure out a way to terminate the child threads as well.

I did a lot of research on this topic when I was starting out with multi-threading. I always hoped to find a clean way to terminate threads. My quest for a close method for threads never bore any fruit. As it turns out our best bet is to poll a “flag” variable in threads and call return in the thread based on the value of this flag. Think of it like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class DownloadThread(QThread):

    # ...
    def __init__(self,whatever):
        self.stop_execution = False

    def quit(self):
        self.stop_execution = True

    def forever_running_function(self, d):
        # ...
        while not self.stop_execution:
            # continue working
        return

The only problem is that at the time of writing, youtube_dl does not have a way to interrupt the download. There is an open issue about this. You can either wait for that issue to be fixed or you can subclass YoutubeDL and implement the polling logic in there. I will not go ahead and implement either of those methods in this post because I believe you know enough about Python to implement these as part of a learning exercise. If you get stuck, however, I would be more than happy to help - just open an issue on the public repo for this book.

Just remember one thing, you should never terminate a thread from the outside (main thread in this case). Always try to terminate the thread using a flag variable. This is not Python-specific, but rather a general multi-threading rule. It allows you to gracefully terminate a program.

Working GUI

Fig. 12.9 Working GUI

12.8 Issues

There are multiple issues with this code right now:

  1. The threads do not terminate properly if we close the main window before all of the threads have completed execution

  2. If we click download multiple times with the same URL, multiple download items are added to our table and multiple threads are created

  3. youtube_dl continues file download if the folder contains a partially downloaded file. What if we want to download the file from scratch?

12.9 Next steps

This is such a huge project and I barely scratched its surface. You can keep on improving this project by adding the following features:

  1. Pause/Resume functionality

  2. Limit the number of concurrent downloads

  3. Allow removal of a downloaded file from the QTableWidget and the disk

  4. Add program update feature (youtube_dl is frequently updated on GitHub)

  5. Add batch URL add feature

  6. Allow specifying login details for a website which requires authentication before a file can be downloaded

  7. Add a license and an about menu item

That is all for now. I hope you learned something new. This one project can help you drastically improve your GUI development skills because it involves multiple small features that will require you to explore the Qt framework. If you ever want inspiration or help, you should explore Qt projects on GitHub. There are a lot of really good Open Source projects over there. Exploring these will help you pick up some nice GUI programming habits as well. See you in the next chapter!