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
- you use something better than
youtube_dl
, and/or
- 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.
print
s 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()