2
\$\begingroup\$

I program in Python as a hobby and decided to try to use tkinter to make an application. I decided to make a YouTube video/audio downloader using youtube_dl. It's really slow, and the tkinter window freezes during the downloading process. But it still completes the download. Because this is the first time using tkinter, I'm not sure my program is very pythonic. Could I maybe get some pointers on how to improve? Thanks in advance!

import youtube_dl
import time
from tkinter import *
from tkinter.ttk import Progressbar
from tkinter import font as tkFont

#application to download youtube song's with or without video
#pretty slow but took a lot of work
#application looks like it's freezing during the downloading process. this is normal.


#name tk function "window" and give it a cool title.
window = Tk()
window.title("SUPER AWESOME YOUTUBE DOWNLOADER V1")


#create window size, xy method
window.geometry('575x400')

#setup useless progress bar (makes sense later)
progress = Progressbar(window, orient=HORIZONTAL,
                       length=250, mode='determinate')
#make background red
window.configure(bg='red')

def A_option():

    #download song
    #retrieve input

    vid = entry1.get()

    #to only get audio you need specific inputs in ytlib library. no idea why this works, but i got it from the web.
    #used a try statement because program can be buggy sometimes. this way it will work nonetheless.
    #outtmpl is for the download's destination, not sure if this means the code has to change on other computers...


    try:
        ytlib = {
            'format': 'bestaudio/best',
            'noplaylist': True,
            'outtmpl': '/python_downloads/%(title)s-%(id)s.%(ext)s',
            'continue_dl': True,
            'postprocessors_args': [{
                'key': 'FFmpegExtractAudio',
                'preferredcodec': 'wav',
                'preferredquality': 'max'}]
        }

    except Exception:
        print('something went wrong...')

    with youtube_dl.YoutubeDL(ytlib) as ydl:

        ydl.download([vid])
        print("download complete")

    bar()
    window.mainloop()


def bar():

    #progress bar
    #couldn't manage to make it show the actual downloading process...
    #looks pretty neat so i'm keeping it

    progress['value'] = 20
    window.update_idletasks()
    time.sleep(1)

    progress['value'] = 40
    window.update_idletasks()
    time.sleep(1)

    progress['value'] = 50
    window.update_idletasks()
    time.sleep(1)

    progress['value'] = 60
    window.update_idletasks()
    time.sleep(1)

    progress['value'] = 80
    window.update_idletasks()
    time.sleep(1)
    progress['value'] = 100

    window.mainloop()

def B_option():

    #download video
    #only had to put outtmpl here because it downloads video's automatically

    vid = entry1.get()

    ytlib = {
        'outtmpl': '/python_downloads/%(title)s-%(id)s.%(ext)s'
    }

    with youtube_dl.YoutubeDL(ytlib) as ydl:

        ydl.download([vid])
        print("download complete")

    bar()
    window.mainloop()

def delete():
    #clears input window (didn't go automatic because i suck)
    entry1.delete(0, 'end')

def stop():
    #imidiatly terminates process
    print('Program finished')
    quit()


def setup():

    #here comes the sloppy part

    #font size
    foptions = tkFont.Font(family='Helvetica', size=15, weight=tkFont.NORMAL)

    #create a collum
    #determine distance inbetween rows of buttons and stuff
    window.columnconfigure(index=0, weight=0)
    window.rowconfigure(index=0, weight=0)

    #create label to point to input box
    #row is how far down the item is, column is how far right.
    elink = Label(window, text='Enter youtube link below:', font=foptions, bg='red')
    elink.grid(row=0, column=0, columnspan=2)

    #get youtube link input here
    #make the entry field global, because that makes sense?
    global entry1
    entry1 = Entry(window, width=50, font=foptions, bg='red', fg='black')
    entry1.grid(row=1, column=0, columnspan=2)

    #empty progress bar. does not really do anything usefull but looks cool and took a lot of effort.
    #tried to apply it to the actual progress, but i'm not that good....
    #idk what "pady" does, but internet said to do so and it works.
    the_lie = Label(window, text='Completing download:', font=foptions, bg='red')
    the_lie.grid(row=4, column=0, columnspan=2)
    progress.grid(row=5, column=0, columnspan=2, pady=10)


    #buttons to the options of the app with some modifications to look slightly less bad.
    Button(window, text='download song', font=foptions, width=15, height=5, bg='black', fg='red', command=A_option).grid(row=2, column=0)
    Button(window, text='download video', font=foptions, width=15, height=5, bg='black', fg='red', command=B_option).grid(row=2, column=1)
    Button(window, text='clear entry', font=foptions, width=15, height=5, bg='black', fg='red', command=delete).grid(row=3, column=0)
    Button(window, text='exit', font=foptions, width=15, height=5, bg='black', fg='red', command=stop).grid(row=3, column=1)

    #always use them mainloops
    window.mainloop()

setup()
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

It's really slow

That can't be avoided.

the tkinter window freezes during the downloading process

Long story short, that can't be entirely avoided unless

  1. you use something better than youtube_dl, and/or
  2. you use your own threading.

My proposal includes a hack that reduces the freeze to the period between progress calls.

Metacommentary

i suck

Don't be so hard on yourself - you're learning, and you made something that more or less works!

no idea why this works, but i got it from the web.

idk what "pady" does, but internet said to do so and it works.

If you're at all serious about learning programming, even only as a hobby, you need to jettison this attitude right now. The moment you see something you don't understand, you should avoid blindly copy-and-pasting it, and do the research to find out how and why it's written on the internet the way it is. Otherwise, you will always be in the dark. It's OK for programs to start off as frankenstein-ed bits from StackOverflow, but it isn't OK for you to avoid learning from that.

Commentary: existing code

  • Avoid import *; it pollutes your namespace. Instead, particularly for tkinter, just import the library (potentially with an as alias) and get to its symbols through a fully-qualified dot expression.
  • Avoid globals. The easiest way to do this is to make a class.
  • Avoid calling get and delete on an Entry; use a StringVar instead.
  • used a try statement because program can be buggy sometimes wraps code that will never fail, so you should just delete that try.
  • Don't spin up a new YoutubeDL each time you download; keep one of each client type (music and video) for your program instance.
  • prints are unhelpful, since you should usually assume that your users only care about the UI. If you want to log, you can, but you should use the actual logging module.
  • Don't sleep. Update your progress bar based on calls from YoutubeDL's progress hook.

As for

look slightly less bad

I suppose that's in the eye of the beholder. I think the black, red and giant fonts you've used are mildly excruciating, and generally I prefer to use default UI themes.

Improvements

Consider:

  • factor out the downloader params of each type to global, constant dicts
  • Use the progress hook, and convert its dictionary to a dataclass for stronger typing
  • Use the information from the progress hook to populate your progress title label
  • Use a frame to allow auto-sized grid columns
  • Prevent the user from changing settings while a download is happening

Suggested

I can fairly guarantee that this suggested code will include syntax and concepts you've not seen before, but I consider it a learning opportunity.

#!/usr/bin/env python3
# application to download youtube songs with or without video

import tkinter as tk
import tkinter.ttk as ttk
from dataclasses import dataclass
from datetime import timedelta
from typing import Optional

from youtube_dl import YoutubeDL


# These parameters are shared by song mode.
VIDEO_PARAMS = {
    'outtmpl': '/python_downloads/%(title)s-%(id)s.%(ext)s'
}

SONG_PARAMS = {
    **VIDEO_PARAMS,
    'format': 'bestaudio/best',
    'noplaylist': True,
    'continue_dl': True,
    'postprocessors_args': [
        {
            'key': 'FFmpegExtractAudio',
            'preferredcodec': 'wav',
            'preferredquality': 'max',
        }
    ]
}


class CancelDownload(Exception):
    """
    Thrown in the middle of our progress callback if the window has been
    destroyed and we need to unwind the youtube_dl stack.
    """


@dataclass
class YTProgress:
    """
    The youtube_dl progress hook's information dictionary is weakly-typed. This
    represents it as a convenience dataclass.
    """

    status: str
    total_bytes: int
    filename: str
    downloaded_bytes: Optional[int] = None
    elapsed: Optional[float] = None
    tmpfilename: Optional[str] = None
    eta: Optional[int] = None
    speed: Optional[float] = None

    @property
    def is_finished(self) -> bool:
        return self.status == 'finished'

    @property
    def elapsed_delta(self) -> timedelta:
        return timedelta(seconds=round(self.elapsed))

    @property
    def eta_delta(self) -> timedelta:
        return self.elapsed_delta + timedelta(seconds=self.eta)

    def __str__(self):
        if self.is_finished:
            # Many of the members will be None if we're finished, so just
            # return the status.
            return self.status

        # Put as much detail here as you want.
        return (
            f'{self.status}'
            f': {self.downloaded_bytes/self.total_bytes:.1%}'
            f', {self.downloaded_bytes/2**20:.1f}/{self.total_bytes/2**20:.1f} MiB'
            f', {self.speed/2**10:.0f} KiB/s'
            f', {self.elapsed_delta}/{self.eta_delta}'
        )


class DownloaderUI:
    """
    One instance of this class will have
    - a reference to a parent Tk that will close on exit;
    - references to a container frame and its widgets; and
    - an instance of YoutubeDL for each of song and video modes.
    """

    def __init__(self, parent: tk.Tk) -> None:
        self.parent = parent

        # Use a frame container to allow grid auto-sizing
        self.frame = frame = tk.Frame(parent)
        frame.pack_configure(
            fill=tk.X,
            expand=tk.YES,
            anchor=tk.N,  # north
        )
        for x in range(2):
            frame.grid_columnconfigure(index=x, weight=1)

        self.link_var, self.entry, *buttons = self.make_settings_widgets(frame)
        self.settings_widgets = (self.entry, *buttons)

        tk.Button(
            frame, text='Exit', command=self.quit,
        ).grid(row=3, column=1, sticky=tk.EW, padx=5, pady=5)  # east-west edges
        parent.wm_protocol('WM_DELETE_WINDOW', self.quit)

        self.progress_widgets = self.make_progress_widgets(frame)
        self.progress_title, self.progress = self.progress_widgets
        self.hide_progress()

    def quit(self) -> None:
        # This could be reworked so that only the frame is destroyed, and a
        # callback offered for the owner.
        # `frame` is nulled out here to let ongoing downloader callbacks know
        # that they can no longer use widgets.
        self.frame = None
        self.parent.destroy()

    def make_settings_widgets(self, frame: tk.Widget) -> tuple[
        tk.StringVar,  # link var
        tk.Entry,      # link entry
        tk.Button,     # download song button
        tk.Button,     # download video button
        tk.Button,     # clear entry button
    ]:
        tk.Label(
            frame, text='Enter YouTube link below:'
        ).grid(row=0, column=0, columnspan=2, sticky=tk.EW)

        link_var = tk.StringVar(frame, name='link')
        entry = tk.Entry(frame, textvariable=link_var)
        entry.grid(row=1, column=0, columnspan=2, sticky=tk.EW)

        download_song_button = tk.Button(
            frame, text='Download song', command=self.download_song,
        )
        download_song_button.grid(row=2, column=0, sticky=tk.EW, padx=5, pady=5)

        download_video_button = tk.Button(
            frame, text='Download video', command=self.download_video,
        )
        download_video_button.grid(row=2, column=1, sticky=tk.EW, padx=5, pady=5)

        clear_entry_button = tk.Button(
            frame, text='Clear entry', command=self.clear_entry,
        )
        clear_entry_button.grid(row=3, column=0, sticky=tk.EW, padx=5, pady=5)

        return link_var, entry, download_song_button, download_video_button, clear_entry_button

    @staticmethod
    def make_progress_widgets(frame: tk.Widget) -> tuple[tk.Label, ttk.Progressbar]:
        progress_title = tk.Label(frame)
        progress_title.grid(row=4, column=0, columnspan=2, sticky=tk.EW)

        progress = ttk.Progressbar(frame, orient=tk.HORIZONTAL, mode='determinate')
        progress.grid(row=5, column=0, columnspan=2, sticky=tk.EW)

        return progress_title, progress

    def show_progress(self) -> None:
        # When the downloader is running, don't let the user change any
        # settings or start any new downloads, and show the progress widgets.
        for widget in self.progress_widgets:
            widget.grid()
        for widget in self.settings_widgets:
            widget.configure(state=tk.DISABLED)

    def hide_progress(self) -> None:
        # When the downloader isn't running, let the user change settings, and
        # hide the progress widgets.
        for widget in self.progress_widgets:
            widget.grid_remove()
        for widget in self.settings_widgets:
            widget.configure(state=tk.NORMAL)

    def clear_entry(self) -> None:
        # Do this via Tk string variable, not widget config() or delete().
        self.link_var.set('')

    def __enter__(self) -> 'DownloaderUI':
        # This is called when the owner uses our class in a `with`. Since we
        # have YoutubeDL instances that also expect `with` context management,
        # call it here.
        self.yt_song = YoutubeDL(SONG_PARAMS)
        self.yt_video = YoutubeDL(VIDEO_PARAMS)
        for yt in (self.yt_song, self.yt_video):
            yt.__enter__()
            yt.add_progress_hook(self.yt_progress)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        # This is called when the owner's `with()` completes on our class
        # instance. Chain the exit call to our downloader instances.
        for yt in (self.yt_song, self.yt_video):
            yt.__exit__()

    def yt_progress(self, kwargs: dict[str, object]) -> None:
        # Invoked by YoutubeDL during download. It gives us a weakly-typed pile
        # of dictionary soup; convert this to a dataclass. Ignore underscored
        # keys.
        progress = YTProgress(**{
            k: v for k, v in kwargs.items()
            if not k.startswith('_')
        })

        # If our frame was destroyed, we need to stop the download. The only
        # practical way to do this while keeping the process alive is to raise
        # an exception from within the progress callback, which unwinds the
        # stack all the way down to the download() call.
        if self.frame is None:
            raise CancelDownload()

        # If the download is finished, hide our progress controls.
        if progress.is_finished:
            self.hide_progress()
        else:
            self.progress.configure(
                value=progress.downloaded_bytes,
                maximum=progress.total_bytes,
            )
            # Use the __str__ definition on our progress object to set the title text.
            self.progress_title.configure(text=str(progress))

        # This is a hack: since youtube_dl has no asynchronous support, and
        # since I don't want to get into threading into this post, during each
        # progress callback we give tk the opportunity to process its message
        # queue. This means that the window will only freeze between progress
        # calls, not for the entire duration of the download.
        self.frame.update()

    def download(self, yt: YoutubeDL) -> None:
        self.show_progress()
        try:
            yt.download([self.link_var.get()])
            # If the above line didn't throw, that means the download completed
            # gracefully and we should hide our progress widgets.
            self.hide_progress()
        except CancelDownload:
            # The frame was destroyed and the download was cancelled.
            pass

    def download_song(self) -> None:
        # tk callbacks should be short-lived. Unless we get into threading, we
        # don't have that option; so we just need to sit here and download
        # until the download completes or is cancelled. To periodically
        # un-freeze the window, the inner progress callback issues update()s.
        self.download(self.yt_song)

    def download_video(self) -> None:
        self.download(self.yt_video)


def main() -> None:
    # Parent window owned(ish) by us. To make it truly owned by us and not
    # DownloaderUI, we would need a hook to notice when the frame is destroyed.
    window = tk.Tk()
    window.title('Super Awesome YouTube Downloader V2')
    window.geometry('400x200')

    with DownloaderUI(window) as downloader:
        downloader.frame.mainloop()


# Don't run main() if someone is importing us; only if we're being run at the
# top level.
if __name__ == '__main__':
    main()

screenshot

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Thank you for taking the time to answer so properly! I'll make sure to try to improve as best as i can. Especially the progress bar! I had no idea how to do so myself. By the way, I really like that you called it the V2! \$\endgroup\$ Dec 28, 2021 at 22:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.